diff --git a/AGENTS.md b/AGENTS.md index 944860254..4ec8f6804 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ This is a Rust-based AI coding agent harness with multiple operational modes. ### Crate Layers ``` -Layer 0 (generic): llm command_executor pty_session fs_explorer sandbox web git terminal terminal_output audio +Layer 0 (generic): llm command_executor pty_session fs_explorer sandbox web git github terminal terminal_output audio Layer 1 (generic): tools_core — tool trait, registry, render, spec, permissions mcp_client — wraps MCP server tools as registry tools Layer 2 (generic): agent_core — agent loop, hook traits, dialect trait, AgentUi trait @@ -140,6 +140,19 @@ Layer 5 (binary): code_assistant — CLI, config, feature-gated frontend `SessionManager::set_monitor_handle`; `UiEvent::UpdateActiveMonitors`); see `docs/background-monitors.md` +### Pull Requests +- Layer-0 `github` crate: HTTP client for api.github.com (token from + `GH_TOKEN` / `gh auth token`), typed model, every PR read and write; + `fake::FakeTransport` (feature `test-utils`) for tests +- `code_assistant_core::pull_requests`: repo resolution for a session + (`origin` remote → `RepoRef`), per-part read cache with TTLs, shared + in-flight reads, stale-while-revalidate and disk snapshots; reached + through `SessionService::pull_request_*`, refreshes announced with + app-scoped `UiEvent::PullRequestChanged` / `PullRequestListChanged` +- Client and snapshot dir injected via `AgentRuntimeOptions::pull_requests`; + the lifecycle sweep reads sessions' PRs through it in batches; see + `docs/pull-request-view.md` + ### Session Storage - One folder per session below `sessions/`, the ID is its path: `/-` (`persistence::layout`) diff --git a/Cargo.lock b/Cargo.lock index 412201e62..4cc47604b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -389,7 +389,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -400,7 +400,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -440,7 +440,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.60.2", "x11rb", ] @@ -1667,12 +1667,14 @@ dependencies = [ "fs_explorer", "futures", "git", + "github", "glob", "ignore", "image", "keepawake", "libc", "llm", + "lru 0.19.0", "mcp_client", "md5", "notify 7.0.0", @@ -1705,7 +1707,7 @@ checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ "serde", "termcolor", - "unicode-width 0.1.14", + "unicode-width 0.2.1", ] [[package]] @@ -2175,7 +2177,7 @@ dependencies = [ "cssparser-macros 0.6.1", "dtoa-short", "itoa", - "phf 0.10.1", + "phf 0.11.3", "smallvec", ] @@ -2487,7 +2489,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2769,7 +2771,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3373,6 +3375,24 @@ dependencies = [ "which 7.0.3", ] +[[package]] +name = "github" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "futures", + "github", + "lru 0.19.0", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "urlencoding", +] + [[package]] name = "gix" version = "0.83.0" @@ -4994,6 +5014,18 @@ dependencies = [ "url", ] +[[package]] +name = "gpui-pre-http-client-tls" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7616a90ec92c9832bbc80ddab34e10fdf8d23520d5cbb0b432b73e4e9f6c812" +dependencies = [ + "log", + "rustls", + "rustls-platform-verifier", + "webpki-roots 1.0.10", +] + [[package]] name = "gpui-pre-linux" version = "0.3.9" @@ -5167,6 +5199,24 @@ dependencies = [ "windows-registry 0.4.0", ] +[[package]] +name = "gpui-pre-reqwest-client" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92c2e70dc8550d60324b1eb3f3d08b4ccf20a8b577a9a42732dd3bafb2363bc0" +dependencies = [ + "anyhow", + "bytes", + "futures", + "gpui-pre-http-client", + "gpui-pre-http-client-tls", + "gpui-pre-reqwest", + "gpui-pre-util", + "log", + "regex", + "tokio", +] + [[package]] name = "gpui-pre-scheduler" version = "0.3.9" @@ -5424,6 +5474,9 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] [[package]] name = "hdrhistogram" @@ -5692,7 +5745,7 @@ dependencies = [ "tokio", "tower-service", "tracing", - "windows-registry 0.4.0", + "windows-registry 0.6.1", ] [[package]] @@ -5707,7 +5760,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.58.0", + "windows-core 0.62.2", ] [[package]] @@ -6141,7 +6194,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6595,6 +6648,15 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "lru" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9f71e3591c7ec44d3e563e87bf14ddc2bada8519dca046e5b0e560cd004e2d" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -6994,7 +7056,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7259,7 +7321,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8073,9 +8135,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" dependencies = [ - "phf_macros 0.10.0", "phf_shared 0.10.0", - "proc-macro-hack", ] [[package]] @@ -8084,6 +8144,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ + "phf_macros 0.11.3", "phf_shared 0.11.3", ] @@ -8181,16 +8242,15 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.10.0" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", + "phf_generator 0.11.3", + "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -8521,12 +8581,6 @@ dependencies = [ "toml_edit 0.25.11+spec-1.1.0", ] -[[package]] -name = "proc-macro-hack" -version = "0.5.20+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" - [[package]] name = "proc-macro2" version = "1.0.106" @@ -8743,7 +8797,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8895,7 +8949,7 @@ dependencies = [ "indoc", "instability", "itertools 0.13.0", - "lru", + "lru 0.12.5", "paste", "strum 0.26.3", "unicode-segmentation", @@ -9445,7 +9499,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -9466,6 +9520,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -9523,7 +9578,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -10111,7 +10166,7 @@ version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" dependencies = [ - "dirs 5.0.1", + "dirs 6.0.0", ] [[package]] @@ -10295,7 +10350,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10734,7 +10789,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -11803,7 +11858,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -11844,7 +11899,9 @@ dependencies = [ "dirs 5.0.1", "futures", "git", + "github", "gpui-kit", + "gpui-pre-reqwest-client", "image", "llm", "open", @@ -12846,7 +12903,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 599665a5c..8511b055a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/agent_core", "crates/agent_orchestration", "crates/code_assistant", "crates/code_assistant_core", "crates/audio", "crates/ui_acp", "crates/ui_gpui", "crates/ui_terminal", "crates/command_executor", "crates/fs_explorer", "crates/git", "crates/llm", "crates/mcp_client", "crates/mcp_server", "crates/pty_session", "crates/sandbox", "crates/terminal", "crates/terminal_output", "crates/terminal_test_app", "crates/terminal_view", "crates/tools_core", "crates/web"] +members = ["crates/agent_core", "crates/agent_orchestration", "crates/code_assistant", "crates/code_assistant_core", "crates/audio", "crates/ui_acp", "crates/ui_gpui", "crates/ui_terminal", "crates/command_executor", "crates/fs_explorer", "crates/git", "crates/github", "crates/llm", "crates/mcp_client", "crates/mcp_server", "crates/pty_session", "crates/sandbox", "crates/terminal", "crates/terminal_output", "crates/terminal_test_app", "crates/terminal_view", "crates/tools_core", "crates/web"] resolver = "2" diff --git a/crates/code_assistant/src/app/gpui.rs b/crates/code_assistant/src/app/gpui.rs index ad35b0205..555e15ae8 100644 --- a/crates/code_assistant/src/app/gpui.rs +++ b/crates/code_assistant/src/app/gpui.rs @@ -80,6 +80,7 @@ pub fn run(config: AgentRunConfig) -> Result<()> { project_manager_factory: code_assistant_core::session::service::default_project_manager_factory(), llm_client_factory: None, + pull_requests: None, }), events, ); diff --git a/crates/code_assistant_core/Cargo.toml b/crates/code_assistant_core/Cargo.toml index 93dc843e6..1101f71cb 100644 --- a/crates/code_assistant_core/Cargo.toml +++ b/crates/code_assistant_core/Cargo.toml @@ -17,6 +17,7 @@ agent_orchestration = { path = "../agent_orchestration" } command_executor = { path = "../command_executor" } fs_explorer = { path = "../fs_explorer" } git = { path = "../git" } +github = { path = "../github" } llm = { path = "../llm" } mcp_client = { path = "../mcp_client" } pty_session = { path = "../pty_session" } @@ -31,6 +32,8 @@ tokio = { version = "1.48", features = ["full"] } futures = "0.3" tempfile = "3.23" fs2 = "0.4" +# Bounded read caches of pull request data +lru = "0.19" notify = { version = "7", features = ["macos_fsevent"] } # JSON (de)serialization. @@ -93,6 +96,8 @@ chrono = "0.4" [dev-dependencies] axum = "0.7" +# The scripted GitHub transport pull request tests read through +github = { path = "../github", features = ["test-utils"] } # An in-process MCP server for the `:mcp:` scope tests rmcp = { version = "3.3", default-features = false, features = [ "base64", diff --git a/crates/code_assistant_core/src/goals.rs b/crates/code_assistant_core/src/goals.rs index 4be246500..dddc8036f 100644 --- a/crates/code_assistant_core/src/goals.rs +++ b/crates/code_assistant_core/src/goals.rs @@ -478,6 +478,7 @@ mod tests { }), project_manager_factory: default_project_manager_factory(), llm_client_factory: Some(factory), + pull_requests: None, }); let (service, worker) = SessionService::new(manager.clone(), runtime, events); tokio::spawn(worker); diff --git a/crates/code_assistant_core/src/lib.rs b/crates/code_assistant_core/src/lib.rs index f06b66a50..0338f43c6 100644 --- a/crates/code_assistant_core/src/lib.rs +++ b/crates/code_assistant_core/src/lib.rs @@ -20,6 +20,7 @@ pub mod injection; pub mod line_comments; pub mod persistence; pub mod plugins; +pub mod pull_requests; pub mod session; pub mod session_query; pub mod skills; diff --git a/crates/code_assistant_core/src/pull_requests/cache.rs b/crates/code_assistant_core/src/pull_requests/cache.rs new file mode 100644 index 000000000..e86be5c8c --- /dev/null +++ b/crates/code_assistant_core/src/pull_requests/cache.rs @@ -0,0 +1,535 @@ +//! One read cache per kind of value: each entry holds the last value read, +//! when it goes stale, and the read under way, if any. +//! +//! - A value younger than its time to live is served as it is. +//! - Up to [`LAST_GOOD`] past that, it is served at once while a background +//! read refreshes it; the cache's `refreshed` callback runs when that read +//! lands, so views can read again. +//! - With nothing servable (no value, too old, or invalidated) the reader +//! waits for the read. If it fails for a passing reason (rate limit, +//! network, server error), a value within that window is served instead. +//! - Concurrent readers of one key share one read. Invalidation drops the +//! read under way, so a result that started before a write never replaces +//! what the next read brings. + +use super::Fetched; +use futures::FutureExt; +use futures::future::{BoxFuture, Shared}; +use github::Priority; +use lru::LruCache; +use std::hash::Hash; +use std::num::NonZeroUsize; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime}; +use tokio::time::Instant; +use tracing::{debug, warn}; + +/// How long past its time to live a value is still served: while it is +/// refreshed, or when it cannot be. +pub(super) const LAST_GOOD: Duration = Duration::from_secs(10 * 60); + +/// Distinguishes reads across all caches, so a read never mistakes another +/// one for itself. +static NEXT_READ: AtomicU64 = AtomicU64::new(1); + +type Read = Shared>>; + +/// Runs when a background refresh of a key landed, stored or failed. +pub(super) type Refreshed = Box; + +/// What a value weighs in bytes. +pub(super) type Weight = fn(&V) -> usize; + +/// How a cache treats its values. +pub(super) struct Policy { + pub ttl: fn(&K, &V) -> Duration, + pub capacity: usize, + /// What a value weighs in bytes, and how much all values may weigh + /// together. A value heavier than that is not kept. + pub budget: Option<(Weight, usize)>, +} + +struct Held { + value: V, + fetched_at: SystemTime, + expires: Instant, + keep_until: Instant, +} + +impl Held { + fn fetched(&self, refreshing: bool) -> Fetched { + Fetched { + value: self.value.clone(), + fetched_at: self.fetched_at, + refreshing, + } + } +} + +struct InFlight { + id: u64, + read: Read, + background: bool, +} + +struct Entry { + held: Option>, + in_flight: Option>, + /// A write changed what the value shows: readers wait for a new read. + invalidated: bool, +} + +impl Default for Entry { + fn default() -> Self { + Self { + held: None, + in_flight: None, + invalidated: false, + } + } +} + +struct Inner { + policy: Policy, + entries: Mutex>>, + refreshed: Option>, +} + +pub(super) struct Cache { + inner: Arc>, +} + +/// Another handle on the same entries. +impl Clone for Cache { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } +} + +impl Cache +where + K: Hash + Eq + Clone + Send + Sync + 'static, + V: Clone + Send + Sync + 'static, +{ + pub fn new(policy: Policy, refreshed: Option>) -> Self { + let capacity = NonZeroUsize::new(policy.capacity).expect("a cache holds something"); + Self { + inner: Arc::new(Inner { + policy, + entries: Mutex::new(LruCache::new(capacity)), + refreshed, + }), + } + } + + /// The value for `key`, read with `fetch` when nothing servable is held. + /// `fetch` runs on its own task at the priority it is given: interactive + /// when a reader waits for it, background for a refresh. + pub async fn read(&self, key: K, fetch: F) -> github::Result> + where + F: FnOnce(Priority) -> BoxFuture<'static, github::Result>, + { + let now = Instant::now(); + let read = { + let mut entries = self.inner.entries.lock().unwrap(); + let entry = entries.get_or_insert_mut(key.clone(), Entry::default); + if !entry.invalidated + && let Some(held) = &entry.held + { + if now < held.expires { + return Ok(held.fetched(false)); + } + if now < held.keep_until { + let fetched = held.fetched(true); + if entry.in_flight.is_none() { + entry.in_flight = Some(self.start(key, true, fetch)); + } + return Ok(fetched); + } + } + entry + .in_flight + .get_or_insert_with(|| self.start(key.clone(), false, fetch)) + .read + .clone() + }; + match read.await { + Ok((value, fetched_at)) => Ok(Fetched { + value, + fetched_at, + refreshing: false, + }), + Err(error) if keeps_last_good(&error) => match self.last_good(&key) { + Some(held) => { + warn!("Serving an earlier pull request read after a failed one: {error}"); + Ok(held) + } + None => Err(error), + }, + Err(error) => Err(error), + } + } + + fn start(&self, key: K, background: bool, fetch: F) -> InFlight + where + F: FnOnce(Priority) -> BoxFuture<'static, github::Result>, + { + let id = NEXT_READ.fetch_add(1, Ordering::Relaxed); + let priority = if background { + Priority::Background + } else { + Priority::Interactive + }; + let fetching = fetch(priority); + let inner = self.inner.clone(); + let read = async move { + let result = fetching.await; + let fetched_at = SystemTime::now(); + inner.land(&key, id, &result, fetched_at); + result.map(|value| (value, fetched_at)) + } + .boxed() + .shared(); + // Finishes and lands even when every reader has gone. + tokio::spawn(read.clone()); + InFlight { + id, + read, + background, + } + } + + fn last_good(&self, key: &K) -> Option> { + let entries = self.inner.entries.lock().unwrap(); + let held = entries.peek(key)?.held.as_ref()?; + (Instant::now() < held.keep_until).then(|| held.fetched(false)) + } + + /// Whether a value is held for `key`, servable or not. + pub fn holds(&self, key: &K) -> bool { + let entries = self.inner.entries.lock().unwrap(); + entries.peek(key).is_some_and(|entry| entry.held.is_some()) + } + + /// Looks at the value held for `key` without reading. + pub fn peek(&self, key: &K, look: impl FnOnce(&V) -> R) -> Option { + let entries = self.inner.entries.lock().unwrap(); + entries + .peek(key) + .and_then(|entry| entry.held.as_ref()) + .map(|held| look(&held.value)) + } + + /// Holds a value read earlier (a disk snapshot) as already stale: it is + /// served once, at once, while a refresh runs. Ignored when the key + /// holds a value by now. + pub fn seed(&self, key: K, value: V, fetched_at: SystemTime) { + let now = Instant::now(); + let mut entries = self.inner.entries.lock().unwrap(); + let entry = entries.get_or_insert_mut(key, Entry::default); + if entry.held.is_none() { + entry.held = Some(Held { + value, + fetched_at, + expires: now, + keep_until: now + LAST_GOOD, + }); + } + } + + /// The next read of every matching key waits for a new value; reads + /// under way are dropped. + pub fn invalidate(&self, matches: impl Fn(&K) -> bool) { + let mut entries = self.inner.entries.lock().unwrap(); + for (key, entry) in entries.iter_mut() { + if matches(key) { + entry.invalidated = true; + entry.in_flight = None; + } + } + } +} + +impl Inner +where + K: Hash + Eq + Clone, + V: Clone, +{ + /// Stores what a read brought, unless the read was dropped meanwhile. + fn land(&self, key: &K, id: u64, result: &github::Result, fetched_at: SystemTime) { + let background = { + let mut entries = self.entries.lock().unwrap(); + let Some(entry) = entries.peek_mut(key) else { + return; + }; + let Some(read) = entry.in_flight.take_if(|read| read.id == id) else { + return; + }; + let now = Instant::now(); + match result { + Ok(value) if self.fits(value) => { + let expires = now + (self.policy.ttl)(key, value); + entry.held = Some(Held { + value: value.clone(), + fetched_at, + expires, + keep_until: expires + LAST_GOOD, + }); + entry.invalidated = false; + self.keep_within_budget(&mut entries); + } + Ok(_) => entry.held = None, + Err(error) => { + debug!("A pull request read failed: {error}"); + // A failed refresh waits a time to live before the next + // one, so views reading again do not retry in a loop. + if read.background + && let Some(held) = &mut entry.held + { + held.expires = now + (self.policy.ttl)(key, &held.value); + } + } + } + read.background + }; + if background && let Some(refreshed) = &self.refreshed { + refreshed(key); + } + } + + fn fits(&self, value: &V) -> bool { + self.policy + .budget + .is_none_or(|(weight, budget)| weight(value) <= budget) + } + + fn keep_within_budget(&self, entries: &mut LruCache>) { + let Some((weight, budget)) = self.policy.budget else { + return; + }; + loop { + let total: usize = entries + .iter() + .filter_map(|(_, entry)| entry.held.as_ref()) + .map(|held| weight(&held.value)) + .sum(); + if total <= budget || entries.pop_lru().is_none() { + return; + } + } + } +} + +/// Failures that say nothing about the pull request: the value read +/// before is still the best answer. +fn keeps_last_good(error: &github::Error) -> bool { + match error { + github::Error::Api { status, .. } => *status >= 500, + error => error.is_rate_limited() || matches!(error, github::Error::Request { .. }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::AtomicUsize; + + const TTL: Duration = Duration::from_secs(60); + + fn cache(refreshed: Option>) -> Cache { + Cache::new( + Policy { + ttl: |_, _| TTL, + capacity: 8, + budget: Some((String::len, 10)), + }, + refreshed, + ) + } + + /// A fetch that answers `value` and counts its calls. + fn answer( + calls: &Arc, + value: &str, + ) -> impl FnOnce(Priority) -> BoxFuture<'static, github::Result> + use<> { + let calls = calls.clone(); + let value = value.to_string(); + move |_| { + calls.fetch_add(1, Ordering::SeqCst); + async move { Ok(value) }.boxed() + } + } + + fn failure() -> github::Error { + github::Error::Request { + host: "github.com".into(), + operation: "test", + detail: "offline".into(), + } + } + + fn fail(_: Priority) -> BoxFuture<'static, github::Result> { + async { Err(failure()) }.boxed() + } + + /// Lets spawned reads run to their end. + async fn settle() { + for _ in 0..10 { + tokio::task::yield_now().await; + } + } + + #[tokio::test(start_paused = true)] + async fn a_fresh_value_is_served_without_reading() { + let cache = cache(None); + let calls = Arc::new(AtomicUsize::new(0)); + let first = cache.read(1, answer(&calls, "a")).await.unwrap(); + tokio::time::advance(TTL - Duration::from_secs(1)).await; + let second = cache.read(1, answer(&calls, "b")).await.unwrap(); + assert_eq!(first.value, "a"); + assert_eq!(second, first); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test(start_paused = true)] + async fn concurrent_reads_share_one_fetch() { + let cache = cache(None); + let calls = Arc::new(AtomicUsize::new(0)); + let (a, b) = tokio::join!( + cache.read(1, answer(&calls, "a")), + cache.read(1, answer(&calls, "b")) + ); + assert_eq!(a.unwrap().value, "a"); + assert_eq!(b.unwrap().value, "a"); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test(start_paused = true)] + async fn a_stale_value_is_served_while_a_background_read_refreshes_it() { + let refreshed = Arc::new(AtomicUsize::new(0)); + let counter = refreshed.clone(); + let cache = cache(Some(Box::new(move |_| { + counter.fetch_add(1, Ordering::SeqCst); + }))); + let calls = Arc::new(AtomicUsize::new(0)); + cache.read(1, answer(&calls, "a")).await.unwrap(); + tokio::time::advance(TTL).await; + + let priority = Arc::new(Mutex::new(None)); + let seen = priority.clone(); + let stale = cache + .read(1, move |p| { + *seen.lock().unwrap() = Some(p); + async { Ok("b".to_string()) }.boxed() + }) + .await + .unwrap(); + assert_eq!(stale.value, "a"); + assert!(stale.refreshing); + settle().await; + + assert_eq!(*priority.lock().unwrap(), Some(Priority::Background)); + assert_eq!(refreshed.load(Ordering::SeqCst), 1); + let fresh = cache.read(1, answer(&calls, "c")).await.unwrap(); + assert_eq!(fresh.value, "b"); + assert!(!fresh.refreshing); + } + + #[tokio::test(start_paused = true)] + async fn a_failed_refresh_keeps_the_value_and_waits_a_time_to_live() { + let cache = cache(Some(Box::new(|_| {}))); + let calls = Arc::new(AtomicUsize::new(0)); + cache.read(1, answer(&calls, "a")).await.unwrap(); + tokio::time::advance(TTL).await; + + assert!(cache.read(1, fail).await.unwrap().refreshing); + settle().await; + let after = cache.read(1, answer(&calls, "b")).await.unwrap(); + + assert_eq!(after.value, "a"); + assert!(!after.refreshing); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test(start_paused = true)] + async fn a_value_past_the_last_good_window_is_waited_for() { + let cache = cache(None); + let calls = Arc::new(AtomicUsize::new(0)); + cache.read(1, answer(&calls, "a")).await.unwrap(); + tokio::time::advance(TTL + LAST_GOOD).await; + + let read = cache.read(1, answer(&calls, "b")).await.unwrap(); + assert_eq!(read.value, "b"); + assert!(!read.refreshing); + // Too old to stand in for a failed read either. + tokio::time::advance(TTL + LAST_GOOD).await; + assert!(cache.read(1, fail).await.is_err()); + } + + #[tokio::test(start_paused = true)] + async fn an_invalidated_value_is_waited_for_and_stands_in_when_the_read_fails() { + let cache = cache(None); + let calls = Arc::new(AtomicUsize::new(0)); + cache.read(1, answer(&calls, "a")).await.unwrap(); + cache.invalidate(|key| *key == 1); + + let read = cache.read(1, answer(&calls, "b")).await.unwrap(); + assert_eq!(read.value, "b"); + + cache.invalidate(|key| *key == 1); + assert_eq!(cache.read(1, fail).await.unwrap().value, "b"); + let refused = github::Error::NotFound { operation: "test" }; + cache.invalidate(|key| *key == 1); + let error = cache + .read(1, move |_| async move { Err(refused) }.boxed()) + .await + .unwrap_err(); + assert!(error.is_not_found()); + } + + #[tokio::test(start_paused = true)] + async fn a_read_started_before_an_invalidation_is_not_kept() { + let cache = cache(None); + let (release, released) = tokio::sync::oneshot::channel::<()>(); + let early = cache.read(1, move |_| { + async move { + released.await.unwrap(); + Ok("old".to_string()) + } + .boxed() + }); + let write = async { + settle().await; + cache.invalidate(|key| *key == 1); + release.send(()).unwrap(); + }; + let (early, ()) = tokio::join!(early, write); + // The reader that waited still gets its answer... + assert_eq!(early.unwrap().value, "old"); + // ...but the next one reads again. + let calls = Arc::new(AtomicUsize::new(0)); + let read = cache.read(1, answer(&calls, "new")).await.unwrap(); + assert_eq!(read.value, "new"); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test(start_paused = true)] + async fn values_stay_within_the_byte_budget() { + let cache = cache(None); + let calls = Arc::new(AtomicUsize::new(0)); + cache.read(1, answer(&calls, "aaaa")).await.unwrap(); + cache.read(2, answer(&calls, "bbbb")).await.unwrap(); + cache.read(3, answer(&calls, "cccc")).await.unwrap(); + cache + .read(4, answer(&calls, "far too heavy")) + .await + .unwrap(); + + assert!(!cache.holds(&1), "the oldest value goes first"); + assert!(cache.holds(&2) && cache.holds(&3)); + assert!(!cache.holds(&4), "a value over the budget is not kept"); + } +} diff --git a/crates/code_assistant_core/src/pull_requests/mod.rs b/crates/code_assistant_core/src/pull_requests/mod.rs new file mode 100644 index 000000000..4fc80ed47 --- /dev/null +++ b/crates/code_assistant_core/src/pull_requests/mod.rs @@ -0,0 +1,58 @@ +//! GitHub pull requests for the views: which repository and pull request a +//! session belongs to, and a read cache in front of the `github` client. +//! +//! Every read is served from memory when it can be: a value younger than +//! its part's time to live as it is, an older one at once while a +//! background refresh runs. When the refresh lands, an app-scoped +//! [`UiEvent::PullRequestChanged`] (or [`UiEvent::PullRequestListChanged`]) +//! tells the views to read again. Identical reads in flight share one +//! request. Writes invalidate the parts they change and announce them the +//! same way. The last details, conversations and lists are kept on disk, +//! so a cold start shows them at once and revalidates. +//! +//! [`UiEvent::PullRequestChanged`]: crate::ui::UiEvent::PullRequestChanged +//! [`UiEvent::PullRequestListChanged`]: crate::ui::UiEvent::PullRequestListChanged + +mod cache; +mod repo; +mod snapshots; +mod store; +#[cfg(test)] +mod tests; + +pub use store::{PullRequestSource, PullRequests}; + +pub(crate) use repo::{github_remote, github_repo, own_pull_request}; + +use github::RepoRef; +use std::time::SystemTime; + +/// A cached read: the value plus whether a background refresh is under way +/// (a `PullRequestChanged` follows when it lands). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Fetched { + pub value: T, + /// When GitHub answered with this value. + pub fetched_at: SystemTime, + pub refreshing: bool, +} + +/// The separately cached parts of a pull request. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PullRequestPart { + Detail, + Checks, + Activity, + Diff, + FilesViewed, +} + +/// Where a session's pull requests live and which one is its own. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionPullRequests { + pub repo: RepoRef, + /// The branch the session works on, if any. + pub head_branch: Option, + /// The number of the pull request whose head is that branch. + pub own: Option, +} diff --git a/crates/code_assistant_core/src/pull_requests/repo.rs b/crates/code_assistant_core/src/pull_requests/repo.rs new file mode 100644 index 000000000..2818ea95a --- /dev/null +++ b/crates/code_assistant_core/src/pull_requests/repo.rs @@ -0,0 +1,122 @@ +//! Which GitHub repository a checkout belongs to, and which pull request +//! is a branch's own. + +use github::RepoRef; +use github::model::HeadPullRequest; +use std::path::PathBuf; + +/// The GitHub repository of a checkout: its `origin` remote, else the first +/// remote (by name) whose URL names a GitHub repository. +pub(crate) fn github_remote(repo: &git::GitRepository) -> Option { + let remotes = repo.remotes(); + let origin = remotes + .iter() + .find(|remote| remote.name == "origin") + .and_then(|remote| RepoRef::from_remote_url(&remote.url)); + origin.or_else(|| { + remotes + .iter() + .find_map(|remote| RepoRef::from_remote_url(&remote.url)) + }) +} + +/// [`github_remote`] of the checkout at `path`, opened off the async +/// threads. +pub(crate) async fn github_repo(path: PathBuf) -> Option { + tokio::task::spawn_blocking(move || github_remote(&git::GitRepository::open(&path).ok()?)) + .await + .ok() + .flatten() +} + +/// Among the pull requests GitHub found for a branch (newest first), the +/// newest opened from the repository itself, not from a fork that happens +/// to use the same branch name. +pub(crate) fn own_pull_request(found: &[HeadPullRequest]) -> Option { + found + .iter() + .find(|pr| !pr.is_cross_repository) + .map(|pr| pr.number) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + fn git(dir: &Path, args: &[&str]) { + let status = std::process::Command::new("git") + .args(args) + .current_dir(dir) + .status() + .unwrap_or_else(|e| panic!("git {args:?}: {e}")); + assert!(status.success(), "git {args:?} failed"); + } + + fn repo_with_remotes(dir: &Path, remotes: &[(&str, &str)]) -> git::GitRepository { + git(dir, &["init", "-q", "-b", "main"]); + for (name, url) in remotes { + git(dir, &["remote", "add", name, url]); + } + git::GitRepository::open(dir).unwrap() + } + + #[test] + fn origin_wins_over_other_github_remotes() { + let tmp = tempfile::tempdir().unwrap(); + let repo = repo_with_remotes( + tmp.path(), + &[ + ("fork", "git@github.com:me/tool.git"), + ("origin", "https://github.com/team/tool.git"), + ], + ); + assert_eq!( + github_remote(&repo), + Some(RepoRef::new("github.com", "team", "tool")) + ); + } + + #[test] + fn without_a_github_origin_the_first_github_remote_counts() { + let tmp = tempfile::tempdir().unwrap(); + let repo = repo_with_remotes( + tmp.path(), + &[ + ("origin", "/srv/mirrors/tool.git"), + ("upstream", "git@github.com:team/tool.git"), + ("zz", "https://github.com/other/tool.git"), + ], + ); + assert_eq!( + github_remote(&repo), + Some(RepoRef::new("github.com", "team", "tool")) + ); + } + + #[test] + fn a_checkout_without_github_remotes_has_no_repository() { + let tmp = tempfile::tempdir().unwrap(); + let repo = repo_with_remotes(tmp.path(), &[("origin", "/srv/mirrors/tool.git")]); + assert_eq!(github_remote(&repo), None); + } + + #[test] + fn a_fork_using_the_same_branch_name_is_not_the_branchs_own() { + let head = |number, is_cross_repository| HeadPullRequest { + number, + title: String::new(), + url: String::new(), + head_branch: "feature/x".into(), + base_branch: "main".into(), + head_sha: None, + state: github::model::PullRequestState::Open, + is_draft: false, + updated_at: chrono::Utc::now(), + is_cross_repository, + head_repository: None, + }; + assert_eq!(own_pull_request(&[head(9, true), head(7, false)]), Some(7)); + assert_eq!(own_pull_request(&[head(9, true)]), None); + } +} diff --git a/crates/code_assistant_core/src/pull_requests/snapshots.rs b/crates/code_assistant_core/src/pull_requests/snapshots.rs new file mode 100644 index 000000000..c321aef4b --- /dev/null +++ b/crates/code_assistant_core/src/pull_requests/snapshots.rs @@ -0,0 +1,233 @@ +//! Disk snapshots of the last pull request reads, for a cold start: the +//! detail and conversation of the most recently read pull requests and the +//! first page of the most recently read lists. Written off the hot path, +//! one write after the other; read only when memory holds nothing. + +use crate::utils::file_utils::atomic_write_unflushed; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::SystemTime; +use tokio::task::JoinHandle; +use tracing::debug; + +/// Pull requests (detail and conversation) kept, and lists kept. +const KEPT: usize = 20; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Kind { + Detail, + Activity, + List, +} + +impl Kind { + fn prefix(self) -> &'static str { + match self { + Kind::Detail => "detail", + Kind::Activity => "activity", + Kind::List => "list", + } + } +} + +/// The key is stored along with the value: the file name is only its hash. +#[derive(Serialize, Deserialize)] +struct Snapshot { + key: K, + fetched_at: SystemTime, + value: V, +} + +pub(super) struct Snapshots { + dir: PathBuf, + /// The last write; the next one waits for it. + last_write: Mutex>>, +} + +impl Snapshots { + pub fn new(dir: PathBuf) -> Self { + Self { + dir, + last_write: Mutex::new(None), + } + } + + /// Writes the snapshot in the background, then drops the oldest beyond + /// what is kept. + pub fn save(&self, kind: Kind, key: &K, value: &V) { + let snapshot = Snapshot { + key, + fetched_at: SystemTime::now(), + value, + }; + let bytes = match serde_json::to_vec(&snapshot) { + Ok(bytes) => bytes, + Err(e) => { + debug!("Cannot encode a pull request snapshot: {e}"); + return; + } + }; + let dir = self.dir.clone(); + let path = dir.join(file_name(kind, key)); + let mut last_write = self.last_write.lock().unwrap(); + let previous = last_write.take(); + *last_write = Some(tokio::spawn(async move { + if let Some(previous) = previous { + let _ = previous.await; + } + let written = tokio::task::spawn_blocking(move || { + std::fs::create_dir_all(&dir)?; + atomic_write_unflushed(&path, &bytes)?; + prune(&dir) + }) + .await; + match written { + Ok(Ok(())) => {} + Ok(Err(e)) => debug!("Cannot write a pull request snapshot: {e:#}"), + Err(e) => debug!("Writing a pull request snapshot failed: {e}"), + } + })); + } + + /// The value last saved for `key`, and when it was read from GitHub. + pub async fn load(&self, kind: Kind, key: &K) -> Option<(V, SystemTime)> + where + K: Serialize + DeserializeOwned + PartialEq, + V: DeserializeOwned, + { + let path = self.dir.join(file_name(kind, key)); + let bytes = tokio::fs::read(&path).await.ok()?; + match serde_json::from_slice::>(&bytes) { + Ok(snapshot) if snapshot.key == *key => Some((snapshot.value, snapshot.fetched_at)), + Ok(_) => None, + Err(e) => { + debug!("Ignoring the unreadable snapshot {}: {e}", path.display()); + None + } + } + } + + /// Waits for the writes started so far. + #[cfg(test)] + pub async fn flushed(&self) { + let last_write = self.last_write.lock().unwrap().take(); + if let Some(last_write) = last_write { + last_write.await.unwrap(); + } + } +} + +/// `-.json`; a pull request's detail and conversation +/// share the hash. +fn file_name(kind: Kind, key: &K) -> String { + let encoded = serde_json::to_vec(key).unwrap_or_default(); + let digest = Sha256::digest(&encoded); + let hash: String = digest[..16].iter().map(|b| format!("{b:02x}")).collect(); + format!("{}-{hash}.json", kind.prefix()) +} + +/// Keeps the [`KEPT`] pull requests and lists written last. +fn prune(dir: &Path) -> anyhow::Result<()> { + // (group, newest write, files) for pull requests and for lists. + let mut pull_requests: Vec<(String, SystemTime, Vec)> = Vec::new(); + let mut lists: Vec<(String, SystemTime, Vec)> = Vec::new(); + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().into_owned(); + let Some((prefix, hash)) = name + .strip_suffix(".json") + .and_then(|stem| stem.split_once('-')) + else { + continue; + }; + let groups = match prefix { + "detail" | "activity" => &mut pull_requests, + "list" => &mut lists, + _ => continue, + }; + let modified = entry.metadata()?.modified()?; + match groups.iter_mut().find(|(group, ..)| group == hash) { + Some((_, newest, files)) => { + *newest = (*newest).max(modified); + files.push(entry.path()); + } + None => groups.push((hash.to_string(), modified, vec![entry.path()])), + } + } + for mut groups in [pull_requests, lists] { + groups.sort_by_key(|(_, newest, _)| std::cmp::Reverse(*newest)); + for (_, _, files) in groups.into_iter().skip(KEPT) { + for file in files { + std::fs::remove_file(file)?; + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn a_snapshot_reads_back_with_its_time_and_only_for_its_key() { + let tmp = tempfile::tempdir().unwrap(); + let snapshots = Snapshots::new(tmp.path().join("pull-requests")); + let before = SystemTime::now(); + snapshots.save(Kind::Detail, &1u64, &"one".to_string()); + snapshots.flushed().await; + + let (value, fetched_at) = snapshots + .load::(Kind::Detail, &1) + .await + .unwrap(); + assert_eq!(value, "one"); + assert!(fetched_at >= before); + assert!( + snapshots + .load::(Kind::Activity, &1) + .await + .is_none() + ); + assert!( + snapshots + .load::(Kind::Detail, &2) + .await + .is_none() + ); + } + + #[tokio::test] + async fn only_the_pull_requests_and_lists_written_last_are_kept() { + let tmp = tempfile::tempdir().unwrap(); + let snapshots = Snapshots::new(tmp.path().to_path_buf()); + let save = async |kinds: &[Kind], number: u64| { + for kind in kinds { + snapshots.save(*kind, &number, &"value"); + } + snapshots.flushed().await; + // Distinct modification times, whatever the file system's resolution. + std::thread::sleep(std::time::Duration::from_millis(5)); + }; + let kept = |kind, number: u64| tmp.path().join(file_name(kind, &number)).exists(); + let all = [Kind::Detail, Kind::Activity, Kind::List]; + for number in 0..=KEPT as u64 { + save(&all, number).await; + } + assert!(!kept(Kind::Detail, 0) && !kept(Kind::Activity, 0)); + assert!(!kept(Kind::List, 0)); + assert!(kept(Kind::Detail, 1) && kept(Kind::Activity, 1) && kept(Kind::List, 1)); + + // Reading the oldest pull request again makes it the newest. + save(&[Kind::Detail], 1).await; + save(&[Kind::Detail, Kind::Activity], KEPT as u64 + 1).await; + + assert!(kept(Kind::Detail, 1) && kept(Kind::Activity, 1)); + assert!(!kept(Kind::Detail, 2) && !kept(Kind::Activity, 2)); + let files = std::fs::read_dir(tmp.path()).unwrap().count(); + assert_eq!(files, 3 * KEPT); + } +} diff --git a/crates/code_assistant_core/src/pull_requests/store.rs b/crates/code_assistant_core/src/pull_requests/store.rs new file mode 100644 index 000000000..d763c6d12 --- /dev/null +++ b/crates/code_assistant_core/src/pull_requests/store.rs @@ -0,0 +1,604 @@ +//! The pull request reads and writes behind the session service, each read +//! through its part's cache. + +use super::cache::{Cache, Policy, Refreshed}; +use super::snapshots::{Kind, Snapshots}; +use super::{Fetched, PullRequestPart, SessionPullRequests, github_remote, own_pull_request}; +use crate::session::event_stream::EventStream; +use crate::ui::UiEvent; +use anyhow::Result; +use futures::FutureExt; +use github::model::{ + Action, Activity, Checks, Detail, DiffFileContents, DiffPage, DiffStat, EditableCommentKind, + FileChange, FilesViewed, LabelCandidates, ListPage, ListQuery, PullRequestState, + PullRequestUpdate, ReactionContent, ReviewSubmission, Reviewer, ReviewerCandidates, + ThreadCommentsPage, Viewer, +}; +use github::{Priority, PullRequestRef, RepoRef}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use std::future::Future; +use std::hash::Hash; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +const LIST_TTL: Duration = Duration::from_secs(30); +const DETAIL_TTL: Duration = Duration::from_secs(60); +/// A merged or closed pull request rarely changes. +const SETTLED_DETAIL_TTL: Duration = Duration::from_secs(10 * 60); +const CHECKS_TTL: Duration = Duration::from_secs(15); +const ACTIVITY_TTL: Duration = Duration::from_secs(60); +const DIFF_TTL: Duration = Duration::from_secs(60); +/// A commit's diff never changes; the time to live only bounds memory. +const COMMIT_DIFF_TTL: Duration = Duration::from_secs(10 * 60); +const FILES_VIEWED_TTL: Duration = Duration::from_secs(15); +const VIEWER_TTL: Duration = Duration::from_secs(10 * 60); +const CANDIDATES_TTL: Duration = Duration::from_secs(10 * 60); +const HEAD_TTL: Duration = Duration::from_secs(60); + +const PULL_REQUESTS_HELD: usize = 128; +const LISTS_HELD: usize = 64; +const DIFF_PAGES_HELD: usize = 128; +/// What all held diff pages may weigh together. +const DIFF_BUDGET: usize = 32 * 1024 * 1024; +const VIEWERS_HELD: usize = 32; +const CANDIDATES_HELD: usize = 32; + +/// Where pull requests are read from and where their snapshots are kept. +#[derive(Clone)] +pub struct PullRequestSource { + pub client: github::Client, + /// Disk snapshots for a cold start. + pub snapshot_dir: PathBuf, +} + +impl Default for PullRequestSource { + /// GitHub over HTTPS with the token from the environment or `gh`; + /// snapshots under `/pull-requests/`. + fn default() -> Self { + Self { + client: github::Client::new(), + snapshot_dir: crate::config_dir::config_dir().join("pull-requests"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +/// A push drops the pull request's diffs when a detail read first sees the +/// new head (see [`PullRequests::detail`]); the head is not part of the key, +/// so a diff read before the detail arrived still serves later reads. +struct DiffKey { + pr: PullRequestRef, + commit: Option, + cursor: Option, +} + +type ListKey = (RepoRef, ListQuery); + +/// Pull request reads and writes for every view, cached per part. One per +/// session service. +pub struct PullRequests { + /// Background priority; reads and writes a user waits for ask for more. + client: github::Client, + events: EventStream, + snapshots: Arc, + lists: Cache, + details: Cache, + checks: Cache, + activities: Cache, + diffs: Cache, + files_viewed: Cache, + viewers: Cache, + reviewer_candidates: Cache, + label_candidates: Cache, + /// The own pull request of a branch, by repository and branch name. + heads: Cache<(RepoRef, String), Option>, +} + +/// Announces a pull request's part after a background refresh. +fn announce( + events: &EventStream, + part: PullRequestPart, + pr: fn(&K) -> &PullRequestRef, +) -> Option> { + let events = events.clone(); + Some(Box::new(move |key| { + events.publish_app(UiEvent::PullRequestChanged { + pr: pr(key).clone(), + part, + }) + })) +} + +fn itself(pr: &PullRequestRef) -> &PullRequestRef { + pr +} + +fn policy(ttl: fn(&K, &V) -> Duration, capacity: usize) -> Policy { + Policy { + ttl, + capacity, + budget: None, + } +} + +impl PullRequests { + pub fn new(source: PullRequestSource, events: EventStream) -> Self { + let lists_changed = { + let events = events.clone(); + Box::new(move |(repo, _): &ListKey| { + events.publish_app(UiEvent::PullRequestListChanged { repo: repo.clone() }) + }) as Refreshed + }; + Self { + client: source.client.with_priority(Priority::Background), + snapshots: Arc::new(Snapshots::new(source.snapshot_dir)), + lists: Cache::new(policy(|_, _| LIST_TTL, LISTS_HELD), Some(lists_changed)), + details: Cache::new( + policy( + |_, detail: &Detail| match detail.state { + PullRequestState::Open => DETAIL_TTL, + PullRequestState::Merged | PullRequestState::Closed => SETTLED_DETAIL_TTL, + }, + PULL_REQUESTS_HELD, + ), + announce(&events, PullRequestPart::Detail, itself), + ), + checks: Cache::new( + policy(|_, _| CHECKS_TTL, PULL_REQUESTS_HELD), + announce(&events, PullRequestPart::Checks, itself), + ), + activities: Cache::new( + policy(|_, _| ACTIVITY_TTL, PULL_REQUESTS_HELD), + announce(&events, PullRequestPart::Activity, itself), + ), + diffs: Cache::new( + Policy { + ttl: |key: &DiffKey, _| match key.commit { + Some(_) => COMMIT_DIFF_TTL, + None => DIFF_TTL, + }, + capacity: DIFF_PAGES_HELD, + budget: Some((|page: &DiffPage| page.patch.len(), DIFF_BUDGET)), + }, + announce(&events, PullRequestPart::Diff, |key: &DiffKey| &key.pr), + ), + files_viewed: Cache::new( + policy(|_, _| FILES_VIEWED_TTL, PULL_REQUESTS_HELD), + announce(&events, PullRequestPart::FilesViewed, itself), + ), + viewers: Cache::new(policy(|_, _| VIEWER_TTL, VIEWERS_HELD), None), + reviewer_candidates: Cache::new(policy(|_, _| CANDIDATES_TTL, CANDIDATES_HELD), None), + label_candidates: Cache::new(policy(|_, _| CANDIDATES_TTL, CANDIDATES_HELD), None), + heads: Cache::new(policy(|_, _| HEAD_TTL, PULL_REQUESTS_HELD), None), + events, + } + } + + /// The client at background priority, for reads nobody waits for (the + /// lifecycle sweep). + pub fn client(&self) -> github::Client { + self.client.clone() + } + + fn interactive(&self) -> github::Client { + self.client.with_priority(Priority::Interactive) + } + + /// The repository of the checkout at `path`, the branch the session + /// works on (`branch`, else the one checked out unless it is the base + /// branch) and its pull request (`known`, else looked up by branch). + /// `None` when the checkout has no GitHub remote. + pub async fn session_pull_requests( + &self, + path: PathBuf, + branch: Option, + known: Option, + ) -> Result> { + let checkout = tokio::task::spawn_blocking(move || { + let repo = git::GitRepository::open(&path).ok()?; + let github = github_remote(&repo)?; + let checked_out = repo.current_branch().filter(|branch| { + !repo + .default_base_branch() + .is_some_and(|base| crate::session::lifecycle::is_base_branch(branch, &base)) + }); + Some((github, checked_out)) + }) + .await?; + let Some((repo, checked_out)) = checkout else { + return Ok(None); + }; + let head_branch = branch.or(checked_out); + let own = match (known, &head_branch) { + (Some(number), _) => Some(number), + (None, Some(branch)) => { + let key = (repo.clone(), branch.clone()); + read( + &self.heads, + key, + &self.client, + |client, (repo, branch)| async move { + let found = client.pull_requests_for_head(&repo, &branch).await?; + Ok(own_pull_request(&found)) + }, + ) + .await? + .value + } + (None, None) => None, + }; + Ok(Some(SessionPullRequests { + repo, + head_branch, + own, + })) + } + + pub async fn list(&self, repo: RepoRef, query: ListQuery) -> Result> { + // Only first pages are kept on disk: a cold start opens no other. + let first_page = query.cursor.is_none(); + let key = (repo, query); + if first_page { + self.seed(&self.lists, Kind::List, &key).await; + } + let snapshots = self.snapshots.clone(); + read( + &self.lists, + key, + &self.client, + move |client, key| async move { + let page = client.list(&key.0, &key.1).await?; + if first_page { + snapshots.save(Kind::List, &key, &page); + } + Ok(page) + }, + ) + .await + } + + pub async fn list_stats(&self, prs: Vec) -> Result> { + Ok(self.interactive().list_stats(&prs).await?) + } + + pub async fn detail(&self, pr: PullRequestRef) -> Result> { + self.seed(&self.details, Kind::Detail, &pr).await; + let snapshots = self.snapshots.clone(); + let (details, diffs) = (self.details.clone(), self.diffs.clone()); + read( + &self.details, + pr, + &self.client, + move |client, pr| async move { + let detail = client.detail(&pr).await?; + let held_head = details.peek(&pr, |held| held.head_sha.clone()).flatten(); + if held_head.is_some() && held_head != detail.head_sha { + diffs.invalidate(|key| key.pr == pr && key.commit.is_none()); + } + snapshots.save(Kind::Detail, &pr, &detail); + Ok(detail) + }, + ) + .await + } + + pub async fn checks(&self, pr: PullRequestRef) -> Result> { + read(&self.checks, pr, &self.client, |client, pr| async move { + client.checks(&pr).await + }) + .await + } + + pub async fn activity(&self, pr: PullRequestRef) -> Result> { + self.seed(&self.activities, Kind::Activity, &pr).await; + let snapshots = self.snapshots.clone(); + read( + &self.activities, + pr, + &self.client, + move |client, pr| async move { + let activity = client.activity(&pr).await?; + snapshots.save(Kind::Activity, &pr, &activity); + Ok(activity) + }, + ) + .await + } + + /// A page of the pull request's diff, or of one of its commits'. + pub async fn diff( + &self, + pr: PullRequestRef, + commit: Option, + cursor: Option, + ) -> Result> { + let key = DiffKey { pr, commit, cursor }; + read(&self.diffs, key, &self.client, |client, key| async move { + let cursor = key.cursor.as_deref(); + match &key.commit { + Some(sha) => client.commit_diff(&key.pr.repo, sha, cursor).await, + None => client.diff(&key.pr, cursor).await, + } + }) + .await + } + + pub async fn files_viewed(&self, pr: PullRequestRef) -> Result> { + read( + &self.files_viewed, + pr, + &self.client, + |client, pr| async move { client.files_viewed(&pr).await }, + ) + .await + } + + pub async fn thread_comments( + &self, + pr: PullRequestRef, + thread_id: String, + cursor: String, + ) -> Result { + Ok(self + .interactive() + .thread_comments(&pr, &thread_id, &cursor) + .await?) + } + + pub async fn file_contents( + &self, + pr: PullRequestRef, + commit: Option, + change: FileChange, + old_path: String, + new_path: String, + ) -> Result { + Ok(self + .interactive() + .diff_file_contents(&pr, commit.as_deref(), change, &old_path, &new_path) + .await?) + } + + pub async fn reviewer_candidates(&self, pr: PullRequestRef) -> Result { + let read = read( + &self.reviewer_candidates, + pr, + &self.client, + |client, pr| async move { client.reviewer_candidates(&pr).await }, + ); + Ok(read.await?.value) + } + + pub async fn label_candidates(&self, pr: PullRequestRef) -> Result { + let read = read( + &self.label_candidates, + pr, + &self.client, + |client, pr| async move { client.label_candidates(&pr).await }, + ); + Ok(read.await?.value) + } + + pub async fn viewer(&self, host: String) -> Result { + let read = read( + &self.viewers, + host, + &self.client, + |client, host| async move { client.viewer(&host).await }, + ); + Ok(read.await?.value) + } + + pub async fn comment(&self, pr: PullRequestRef, body: String) -> Result<()> { + let written = self.interactive().add_comment(&pr, &body).await; + self.written(&pr, &[PullRequestPart::Activity], written) + } + + pub async fn update_comment( + &self, + pr: PullRequestRef, + id: String, + kind: EditableCommentKind, + body: String, + ) -> Result<()> { + let written = self + .interactive() + .update_comment(&pr, &id, kind, &body) + .await; + self.written(&pr, &[PullRequestPart::Activity], written) + } + + pub async fn update(&self, pr: PullRequestRef, update: PullRequestUpdate) -> Result<()> { + let written = self.interactive().update_pull_request(&pr, &update).await; + self.written(&pr, &[PullRequestPart::Detail], written) + } + + pub async fn submit_review(&self, pr: PullRequestRef, review: ReviewSubmission) -> Result<()> { + let written = self.interactive().submit_review(&pr, &review).await; + let parts = [PullRequestPart::Detail, PullRequestPart::Activity]; + self.written(&pr, &parts, written) + } + + pub async fn reply(&self, pr: PullRequestRef, thread_id: String, body: String) -> Result<()> { + let written = self + .interactive() + .reply_to_thread(&pr, &thread_id, &body) + .await; + self.written(&pr, &[PullRequestPart::Activity], written) + } + + pub async fn resolve_thread( + &self, + pr: PullRequestRef, + thread_id: String, + resolved: bool, + ) -> Result<()> { + let written = self + .interactive() + .set_thread_resolved(&pr, &thread_id, resolved) + .await; + self.written(&pr, &[PullRequestPart::Activity], written) + } + + pub async fn react( + &self, + pr: PullRequestRef, + subject_id: Option, + content: ReactionContent, + reacted: bool, + ) -> Result<()> { + let written = self + .interactive() + .set_reaction(&pr, subject_id.as_deref(), content, reacted) + .await; + self.written(&pr, &[PullRequestPart::Activity], written) + } + + pub async fn request_reviewers( + &self, + pr: PullRequestRef, + reviewers: Vec, + requested: bool, + ) -> Result<()> { + let written = self + .interactive() + .set_reviewers_requested(&pr, &reviewers, requested) + .await; + self.reviewer_candidates.invalidate(|key| *key == pr); + let parts = [PullRequestPart::Detail, PullRequestPart::Activity]; + self.written(&pr, &parts, written) + } + + pub async fn set_labels( + &self, + pr: PullRequestRef, + labels: Vec, + applied: bool, + ) -> Result<()> { + let written = self.interactive().set_labels(&pr, &labels, applied).await; + self.label_candidates.invalidate(|key| *key == pr); + self.written(&pr, &[PullRequestPart::Detail], written) + } + + /// Ticking files off changes nothing else: the diff stays. + pub async fn set_files_viewed( + &self, + pr: PullRequestRef, + files: Vec<(String, bool)>, + ) -> Result<()> { + let written = self.interactive().set_files_viewed(&pr, &files).await; + self.written(&pr, &[PullRequestPart::FilesViewed], written) + } + + pub async fn action(&self, pr: PullRequestRef, action: Action) -> Result<()> { + let written = self.interactive().run_action(&pr, action).await; + let parts = [ + PullRequestPart::Detail, + PullRequestPart::Checks, + PullRequestPart::Activity, + ]; + self.written(&pr, &parts, written) + } + + /// Every part of the pull request is read anew on its next read. + pub fn refresh(&self, pr: &PullRequestRef) { + self.reviewer_candidates.invalidate(|key| key == pr); + self.label_candidates.invalidate(|key| key == pr); + let parts = [ + PullRequestPart::Detail, + PullRequestPart::Checks, + PullRequestPart::Activity, + PullRequestPart::Diff, + PullRequestPart::FilesViewed, + ]; + self.invalidate(pr, &parts); + } + + /// The repository's lists are read anew on their next read. + pub fn refresh_list(&self, repo: &RepoRef) { + self.lists.invalidate(|(listed, _)| listed == repo); + self.events + .publish_app(UiEvent::PullRequestListChanged { repo: repo.clone() }); + } + + /// After a write, failed or not (GitHub may have applied part of it), + /// the parts it changes are read anew. + fn written( + &self, + pr: &PullRequestRef, + parts: &[PullRequestPart], + written: github::Result<()>, + ) -> Result<()> { + self.invalidate(pr, parts); + Ok(written?) + } + + /// Invalidates the parts and announces each; a change to the detail + /// also shows in the repository's lists. + fn invalidate(&self, pr: &PullRequestRef, parts: &[PullRequestPart]) { + for part in parts { + match part { + PullRequestPart::Detail => self.details.invalidate(|key| key == pr), + PullRequestPart::Checks => self.checks.invalidate(|key| key == pr), + PullRequestPart::Activity => self.activities.invalidate(|key| key == pr), + PullRequestPart::Diff => self.diffs.invalidate(|key| key.pr == *pr), + PullRequestPart::FilesViewed => self.files_viewed.invalidate(|key| key == pr), + } + self.events.publish_app(UiEvent::PullRequestChanged { + pr: pr.clone(), + part: *part, + }); + } + if parts.contains(&PullRequestPart::Detail) { + self.lists.invalidate(|(repo, _)| *repo == pr.repo); + self.events.publish_app(UiEvent::PullRequestListChanged { + repo: pr.repo.clone(), + }); + } + } + + /// Holds the disk snapshot of `key` when memory holds nothing for it. + async fn seed(&self, cache: &Cache, kind: Kind, key: &K) + where + K: Hash + Eq + Clone + Send + Sync + Serialize + DeserializeOwned + 'static, + V: Clone + Send + Sync + DeserializeOwned + 'static, + { + if cache.holds(key) { + return; + } + if let Some((value, fetched_at)) = self.snapshots.load(kind, key).await { + cache.seed(key.clone(), value, fetched_at); + } + } + + #[cfg(test)] + pub(super) async fn snapshots_written(&self) { + self.snapshots.flushed().await; + } +} + +/// Reads `key` through `cache`; `fetch` gets the client at the priority +/// the cache asks for. +async fn read( + cache: &Cache, + key: K, + client: &github::Client, + fetch: F, +) -> Result> +where + K: Hash + Eq + Clone + Send + Sync + 'static, + V: Clone + Send + Sync + 'static, + F: FnOnce(github::Client, K) -> Fut, + Fut: Future> + Send + 'static, +{ + let client = client.clone(); + let fetch_key = key.clone(); + let fetched = cache + .read(key, move |priority| { + fetch(client.with_priority(priority), fetch_key).boxed() + }) + .await?; + Ok(fetched) +} diff --git a/crates/code_assistant_core/src/pull_requests/tests.rs b/crates/code_assistant_core/src/pull_requests/tests.rs new file mode 100644 index 000000000..3c061e5a0 --- /dev/null +++ b/crates/code_assistant_core/src/pull_requests/tests.rs @@ -0,0 +1,420 @@ +//! The store against the scripted GitHub transport: time to live, +//! revalidation and its announcement, shared reads, invalidation by writes +//! and cold starts from disk. + +use super::*; +use crate::session::event_stream::{EventPayload, EventStream, Subscription}; +use crate::ui::UiEvent; +use github::fake::{FakeResponse, FakeTransport}; +use github::model::{PullRequestUpdate, ReactionContent}; +use github::transport::Method; +use github::{PullRequestRef, RepoRef}; +use serde_json::{Value, json}; +use std::sync::Arc; +use std::time::Duration; + +const HEAD: &str = "0123456789abcdef0123456789abcdef01234567"; +const PUSHED: &str = "89abcdef0123456789abcdef0123456789abcdef"; +const DIFF_PATH: &str = "repos/stippi/code-assistant/pulls/291"; + +fn pr() -> PullRequestRef { + RepoRef::new("github.com", "stippi", "code-assistant").pull_request(291) +} + +/// The `PullRequestCore` answer the `github` crate's tests read. +fn core(change: impl FnOnce(&mut Value)) -> Value { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../github/tests/fixtures/pull_request_core.json" + ); + let text = std::fs::read_to_string(path).unwrap(); + let mut data = serde_json::from_str::(&text).unwrap()["data"].take(); + change(&mut data["repository"]["pullRequest"]); + data +} + +fn titled(title: &str) -> Value { + core(|pr| pr["title"] = title.into()) +} + +fn files_viewed(state: &str) -> Value { + json!({"repository": {"pullRequest": {"files": { + "pageInfo": {"hasNextPage": false, "endCursor": null}, + "nodes": [{"path": "a.rs", "viewerViewedState": state}] + }}}}) +} + +struct Harness { + fake: Arc, + store: PullRequests, + events: Subscription, + dir: tempfile::TempDir, +} + +fn harness() -> Harness { + harness_in(tempfile::tempdir().unwrap()) +} + +fn harness_in(dir: tempfile::TempDir) -> Harness { + let fake = FakeTransport::new(); + let events = EventStream::new(); + let subscription = events.subscribe(); + let source = PullRequestSource { + client: fake.client(), + snapshot_dir: dir.path().join("pull-requests"), + }; + Harness { + store: PullRequests::new(source, events), + fake, + events: subscription, + dir, + } +} + +impl Harness { + fn requests(&self, operation: &str) -> usize { + self.fake + .requests() + .iter() + .filter(|r| r.operation.as_deref().unwrap_or(&r.path) == operation) + .count() + } + + async fn next_event(&mut self) -> UiEvent { + let event = tokio::time::timeout(Duration::from_secs(5), self.events.recv()) + .await + .expect("no event") + .unwrap(); + assert!( + event.session_id.is_none(), + "pull request events are app-scoped" + ); + match event.payload { + EventPayload::Ui(event) => event, + other => panic!("unexpected {other:?}"), + } + } + + /// Every event published so far. + async fn drain_events(&mut self) -> Vec { + let mut events = Vec::new(); + while let Ok(Ok(event)) = tokio::time::timeout(Duration::ZERO, self.events.recv()).await { + if let EventPayload::Ui(event) = event.payload { + events.push(event); + } + } + events + } +} + +fn changed(part: PullRequestPart) -> impl Fn(&UiEvent) -> bool { + move |event| { + matches!(event, UiEvent::PullRequestChanged { pr: p, part: changed } + if *p == pr() && *changed == part) + } +} + +#[tokio::test(start_paused = true)] +async fn a_stale_detail_is_served_while_it_revalidates_and_the_change_is_announced() { + let mut h = harness(); + h.fake + .on_graphql("PullRequestCore") + .once() + .data(titled("First")); + h.fake.on_graphql("PullRequestCore").data(titled("Second")); + + let first = h.store.detail(pr()).await.unwrap(); + tokio::time::advance(Duration::from_secs(30)).await; + let cached = h.store.detail(pr()).await.unwrap(); + assert_eq!( + (first.value.title.as_str(), first.refreshing), + ("First", false) + ); + assert_eq!(cached, first); + assert_eq!(h.requests("PullRequestCore"), 1); + + tokio::time::advance(Duration::from_secs(31)).await; + let stale = h.store.detail(pr()).await.unwrap(); + assert_eq!(stale.value.title, "First"); + assert!(stale.refreshing); + assert!(changed(PullRequestPart::Detail)(&h.next_event().await)); + + let fresh = h.store.detail(pr()).await.unwrap(); + assert_eq!(fresh.value.title, "Second"); + assert!(!fresh.refreshing); + assert_eq!(h.requests("PullRequestCore"), 2); +} + +#[tokio::test(start_paused = true)] +async fn concurrent_reads_of_one_part_share_one_request() { + let h = harness(); + h.fake.on_graphql("PullRequestCore").data(titled("Shared")); + + let (a, b) = tokio::join!(h.store.detail(pr()), h.store.detail(pr())); + + assert_eq!(a.unwrap().value.title, "Shared"); + assert_eq!(b.unwrap().value.title, "Shared"); + assert_eq!(h.requests("PullRequestCore"), 1); +} + +#[tokio::test(start_paused = true)] +async fn a_merged_pull_request_stays_fresh_for_ten_minutes() { + let h = harness(); + h.fake.on_graphql("PullRequestCore").data(core(|pr| { + pr["state"] = "MERGED".into(); + pr["mergedAt"] = "2026-10-30T12:00:00Z".into(); + })); + + h.store.detail(pr()).await.unwrap(); + tokio::time::advance(Duration::from_secs(9 * 60)).await; + assert!(!h.store.detail(pr()).await.unwrap().refreshing); + tokio::time::advance(Duration::from_secs(60)).await; + assert!(h.store.detail(pr()).await.unwrap().refreshing); +} + +#[tokio::test(start_paused = true)] +async fn a_failed_refresh_keeps_the_last_good_value() { + let mut h = harness(); + h.fake + .on_graphql("PullRequestFilesViewed") + .once() + .data(files_viewed("VIEWED")); + h.fake + .on_graphql("PullRequestFilesViewed") + .respond(FakeResponse::status(502)); + + h.store.files_viewed(pr()).await.unwrap(); + tokio::time::advance(Duration::from_secs(16)).await; + assert!(h.store.files_viewed(pr()).await.unwrap().refreshing); + // Announced even so: the view stops waiting for it. + assert!(changed(PullRequestPart::FilesViewed)(&h.next_event().await)); + + let kept = h.store.files_viewed(pr()).await.unwrap(); + assert_eq!(kept.value.files.len(), 1); + assert!(!kept.refreshing, "no refresh loop after a failure"); +} + +#[tokio::test(start_paused = true)] +async fn a_write_invalidates_what_it_changes_and_announces_it() { + let mut h = harness(); + h.fake + .on_graphql("PullRequestCore") + .once() + .data(titled("Before")); + h.fake.on_graphql("PullRequestCore").data(titled("After")); + h.fake + .on_graphql("PullRequestNodeId") + .data(json!({"repository": {"pullRequest": {"id": "PR_291"}}})); + h.fake.on_graphql("UpdatePullRequest").data(json!({})); + + h.store.detail(pr()).await.unwrap(); + let update = PullRequestUpdate { + title: Some("After".into()), + ..PullRequestUpdate::default() + }; + h.store.update(pr(), update).await.unwrap(); + + let events = h.drain_events().await; + assert!(events.iter().any(changed(PullRequestPart::Detail))); + assert!(events.iter().any(|event| matches!(event, + UiEvent::PullRequestListChanged { repo } if *repo == pr().repo))); + assert!(!events.iter().any(changed(PullRequestPart::Diff))); + // Read anew at once, not served stale. + let read = h.store.detail(pr()).await.unwrap(); + assert_eq!(read.value.title, "After"); + assert!(!read.refreshing); +} + +#[tokio::test(start_paused = true)] +async fn a_failed_write_still_invalidates() { + let mut h = harness(); + h.fake + .on_graphql("PullRequestNodeId") + .data(json!({"repository": {"pullRequest": {"id": "PR_291"}}})); + h.fake + .on_graphql("AddReaction") + .respond(FakeResponse::errors(json!([{"message": "nope"}]))); + + let result = h + .store + .react(pr(), None, ReactionContent::Heart, true) + .await; + + assert!(result.is_err()); + assert!( + h.drain_events() + .await + .iter() + .any(changed(PullRequestPart::Activity)) + ); +} + +#[tokio::test(start_paused = true)] +async fn ticking_files_viewed_keeps_the_diff() { + let mut h = harness(); + h.fake + .on_rest(Method::Get, DIFF_PATH) + .respond(FakeResponse::text(200, "diff --git a/a.rs b/a.rs\n")); + h.fake + .on_graphql("PullRequestFilesViewed") + .once() + .data(files_viewed("UNVIEWED")); + h.fake + .on_graphql("PullRequestFilesViewed") + .data(files_viewed("VIEWED")); + h.fake + .on_graphql("PullRequestNodeId") + .data(json!({"repository": {"pullRequest": {"id": "PR_291"}}})); + h.fake.on_graphql("SetFileViewed").data(json!({})); + + h.store.diff(pr(), None, None).await.unwrap(); + h.store.files_viewed(pr()).await.unwrap(); + h.store + .set_files_viewed(pr(), vec![("a.rs".into(), true)]) + .await + .unwrap(); + + let events = h.drain_events().await; + assert_eq!(events.len(), 1); + assert!(changed(PullRequestPart::FilesViewed)(&events[0])); + let viewed = h.store.files_viewed(pr()).await.unwrap(); + assert_eq!( + viewed.value.files[0].state, + github::model::FileViewedState::Viewed + ); + h.store.diff(pr(), None, None).await.unwrap(); + assert_eq!(h.requests(DIFF_PATH), 1); +} + +#[tokio::test(start_paused = true)] +async fn a_diff_read_alongside_the_detail_serves_later_reads() { + let h = harness(); + h.fake + .on_graphql("PullRequestCore") + .data(core(|pr| pr["headRefOid"] = HEAD.into())); + h.fake + .on_rest(Method::Get, DIFF_PATH) + .respond(FakeResponse::text(200, "diff --git a/a.rs b/a.rs\n")); + + // Opening a pull request prefetches its diff while the detail (and so + // the head) is still on its way. + let (detail, diff) = tokio::join!(h.store.detail(pr()), h.store.diff(pr(), None, None)); + detail.unwrap(); + diff.unwrap(); + h.store.diff(pr(), None, None).await.unwrap(); + assert_eq!(h.requests(DIFF_PATH), 1); +} + +#[tokio::test(start_paused = true)] +async fn a_push_seen_by_a_detail_revalidation_reads_the_diff_anew() { + let mut h = harness(); + h.fake + .on_graphql("PullRequestCore") + .once() + .data(core(|pr| pr["headRefOid"] = HEAD.into())); + h.fake + .on_graphql("PullRequestCore") + .data(core(|pr| pr["headRefOid"] = PUSHED.into())); + h.fake + .on_rest(Method::Get, DIFF_PATH) + .respond(FakeResponse::text(200, "diff --git a/a.rs b/a.rs\n")); + + h.store.detail(pr()).await.unwrap(); + tokio::time::advance(Duration::from_secs(30)).await; + h.store.diff(pr(), None, None).await.unwrap(); + + // The detail goes stale and its background read sees the push; the + // diff, still within its own time to live, must not be served. + tokio::time::advance(Duration::from_secs(31)).await; + h.store.detail(pr()).await.unwrap(); + assert!(changed(PullRequestPart::Detail)(&h.next_event().await)); + h.store.diff(pr(), None, None).await.unwrap(); + assert_eq!(h.requests(DIFF_PATH), 2); +} + +#[tokio::test(start_paused = true)] +async fn a_push_reads_the_diff_anew() { + let h = harness(); + h.fake + .on_graphql("PullRequestCore") + .once() + .data(core(|pr| pr["headRefOid"] = HEAD.into())); + h.fake + .on_graphql("PullRequestCore") + .data(core(|pr| pr["headRefOid"] = PUSHED.into())); + h.fake + .on_rest(Method::Get, DIFF_PATH) + .respond(FakeResponse::text(200, "diff --git a/a.rs b/a.rs\n")); + + h.store.detail(pr()).await.unwrap(); + h.store.diff(pr(), None, None).await.unwrap(); + h.store.diff(pr(), None, None).await.unwrap(); + assert_eq!(h.requests(DIFF_PATH), 1); + + // The detail learns of the push; the diff, still within its time to + // live, is read again for the new head. + h.store.refresh(&pr()); + h.store.detail(pr()).await.unwrap(); + h.store.diff(pr(), None, None).await.unwrap(); + assert_eq!(h.requests(DIFF_PATH), 2); +} + +#[tokio::test] +async fn a_cold_start_shows_the_snapshot_at_once_and_revalidates() { + let first = harness(); + first + .fake + .on_graphql("PullRequestCore") + .data(titled("Saved")); + let saved = first.store.detail(pr()).await.unwrap(); + first.store.snapshots_written().await; + + let mut second = harness_in(first.dir); + second + .fake + .on_graphql("PullRequestCore") + .data(titled("Current")); + let shown = second.store.detail(pr()).await.unwrap(); + assert_eq!(shown.value, saved.value); + // When GitHub answered, not when the snapshot was read. + let age = |fetched: &Fetched<_>| fetched.fetched_at.elapsed().unwrap(); + assert!(age(&shown) >= age(&saved)); + assert!(shown.refreshing); + + assert!(changed(PullRequestPart::Detail)(&second.next_event().await)); + let current = second.store.detail(pr()).await.unwrap(); + assert_eq!(current.value.title, "Current"); +} + +#[tokio::test(start_paused = true)] +async fn refreshing_a_repositorys_lists_reads_them_anew_and_announces_it() { + let mut h = harness(); + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../github/tests/fixtures/pull_request_search.json" + ); + let search = serde_json::from_str::(&std::fs::read_to_string(path).unwrap()).unwrap(); + h.fake + .on_graphql("PullRequestSearch") + .data(search["data"].clone()); + let repo = pr().repo; + let query = github::model::ListQuery { + limit: 25, + ..github::model::ListQuery::default() + }; + + h.store.list(repo.clone(), query.clone()).await.unwrap(); + h.store.list(repo.clone(), query.clone()).await.unwrap(); + assert_eq!( + h.requests("PullRequestSearch"), + 1, + "the second read is cached" + ); + + h.store.refresh_list(&repo); + assert!(matches!(h.next_event().await, + UiEvent::PullRequestListChanged { repo: changed } if changed == repo)); + let read = h.store.list(repo, query).await.unwrap(); + assert!(!read.refreshing, "read anew, not served stale"); + assert_eq!(h.requests("PullRequestSearch"), 2); +} diff --git a/crates/code_assistant_core/src/session/lifecycle.rs b/crates/code_assistant_core/src/session/lifecycle.rs index 6bd2f6d6f..df0f4b87e 100644 --- a/crates/code_assistant_core/src/session/lifecycle.rs +++ b/crates/code_assistant_core/src/session/lifecycle.rs @@ -28,7 +28,9 @@ use tracing::{debug, warn}; use crate::session::SessionService; use crate::session::instance::SessionActivityState; -use crate::session::pull_request::{PullRequestSnapshot, PullRequestState}; +use crate::session::pull_request::{ + BranchLookup, PullRequestSnapshot, PullRequestState, read_branch_pull_requests, +}; use crate::utils::file_utils::atomic_write_json; /// Per-session lifecycle record. @@ -42,8 +44,8 @@ pub struct SessionLifecycle { /// When the user last pulled the session back out of the settled shelf. /// Automatic settlement waits for activity newer than this. pub unsettled_at: Option, - /// The pull request behind the session's branch, as last read from the - /// GitHub CLI. Refreshed by the lifecycle sweep and after each run. + /// The pull request behind the session's branch, as last read from + /// GitHub. Refreshed by the lifecycle sweep and after each run. pub pull_request: Option, } @@ -358,8 +360,18 @@ pub(crate) async fn sessions_with_merged_branch(inputs: &[SettlementInput]) -> H pub(crate) async fn refresh_branch_after_run( manager: &std::sync::Arc>, session_id: &str, + github: &github::Client, ) { - let observed = manager.lock().await.record_observed_branch(session_id); + let (observed, known) = { + let mut manager = manager.lock().await; + let observed = manager.record_observed_branch(session_id); + let known = manager + .session_lifecycles() + .ok() + .and_then(|lifecycles| lifecycles.get(session_id)?.pull_request.clone()) + .map(|pr| pr.number); + (observed, known) + }; let (root, branch) = match observed { Ok(Some(observed)) => observed, Ok(None) => return, @@ -368,12 +380,21 @@ pub(crate) async fn refresh_branch_after_run( return; } }; - let fetched = match crate::session::pull_request::fetch_pull_request(&root, &branch).await { - Ok(fetched) => fetched, - Err(e) => { - debug!("Cannot read the pull request of {branch}: {e}"); + let Some(repo) = crate::pull_requests::github_repo(root).await else { + return; + }; + let lookup = BranchLookup { + repo, + branch, + known, + }; + let fetched = match read_branch_pull_requests(github, &[lookup]).await.pop() { + Some(Ok(fetched)) => fetched, + Some(Err(e)) => { + debug!("Cannot read the pull request of {session_id}: {e}"); return; } + None => return, }; let manager = manager.lock().await; if let Err(e) = diff --git a/crates/code_assistant_core/src/session/manager.rs b/crates/code_assistant_core/src/session/manager.rs index f552e6641..f0e9d9513 100644 --- a/crates/code_assistant_core/src/session/manager.rs +++ b/crates/code_assistant_core/src/session/manager.rs @@ -42,6 +42,10 @@ pub(crate) struct RunConfig { /// Builds the LLM clients of sub-agents and of the auto-mode reviewer; /// `None` uses the configured providers. pub sub_agent_llm: Option, + /// Reads the pull request of the session's branch once the run ends; + /// `None` (runs started on the manager directly) leaves that to the + /// lifecycle sweep. + pub github: Option, } /// What a run does. @@ -1177,6 +1181,7 @@ impl SessionManager { model: instance.session.model_config.clone(), task: RunTask::Answer, sub_agent_llm: None, + github: None, }; self.refresh_tool_registry(session_id, registry_request) .await; @@ -1338,6 +1343,7 @@ impl SessionManager { }; let task = run_config.task; let sub_agent_llm = run_config.sub_agent_llm; + let github = run_config.github; let idle_handoff = self.idle_handoff.clone(); let pending_message_for_task = pending_message_ref.clone(); @@ -1676,11 +1682,14 @@ impl SessionManager { activity_state: crate::session::instance::SessionActivityState::Idle, }, ); - crate::session::lifecycle::refresh_branch_after_run( - &manager_for_outcome, - &session_id_clone, - ) - .await; + if let Some(github) = &github { + crate::session::lifecycle::refresh_branch_after_run( + &manager_for_outcome, + &session_id_clone, + github, + ) + .await; + } } Err(e) => { error!("Agent failed for session {}: {:#}", session_id_clone, e); diff --git a/crates/code_assistant_core/src/session/pull_request.rs b/crates/code_assistant_core/src/session/pull_request.rs index fa5272430..20e654cd6 100644 --- a/crates/code_assistant_core/src/session/pull_request.rs +++ b/crates/code_assistant_core/src/session/pull_request.rs @@ -1,16 +1,20 @@ -//! The pull request behind a session's branch, read through the GitHub CLI. +//! The pull request behind a session's branch, as the sidebar shows it. //! -//! `gh pr view ` is the only dependency: when `gh` is missing or not -//! logged in, sessions simply show their branch without a pull request. The +//! Read through the GitHub client in batches: one summary request covers +//! every session whose pull request is known, a lookup by branch finds the +//! others. Without a token (no `gh` login, no `GH_TOKEN`) the reads fail +//! and sessions simply show their branch without a pull request. The //! snapshot is kept in the session's lifecycle record and refreshed by the //! lifecycle sweep and after each run. -use std::path::Path; -use std::time::{Duration, SystemTime}; +use std::time::SystemTime; -use anyhow::{Context, Result, bail}; +use github::model::Summary; +use github::{PullRequestRef, RepoRef}; use serde::{Deserialize, Serialize}; +use crate::pull_requests::own_pull_request; + /// What the sidebar shows about a session's pull request. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PullRequestSnapshot { @@ -48,211 +52,285 @@ pub enum ChecksState { Pending, } -/// The fields asked of `gh pr view --json`. -const JSON_FIELDS: &str = "number,url,title,state,isDraft,reviewDecision,statusCheckRollup"; - -const GH_TIMEOUT: Duration = Duration::from_secs(30); +impl PullRequestSnapshot { + pub fn from_summary(summary: &Summary, fetched_at: SystemTime) -> Self { + let state = match summary.state { + github::model::PullRequestState::Open if summary.is_draft => PullRequestState::Draft, + github::model::PullRequestState::Open => PullRequestState::Open, + github::model::PullRequestState::Merged => PullRequestState::Merged, + github::model::PullRequestState::Closed => PullRequestState::Closed, + }; + Self { + number: summary.number, + url: summary.url.clone(), + title: summary.title.clone(), + state, + review: summary.review_decision.map(|review| match review { + github::model::ReviewDecision::Approved => ReviewDecision::Approved, + github::model::ReviewDecision::ChangesRequested => ReviewDecision::ChangesRequested, + github::model::ReviewDecision::ReviewRequired => ReviewDecision::ReviewRequired, + }), + checks: summary.checks_state.map(|checks| match checks { + github::model::ChecksState::Passing => ChecksState::Passing, + github::model::ChecksState::Failing => ChecksState::Failing, + github::model::ChecksState::Pending => ChecksState::Pending, + }), + fetched_at, + } + } +} -/// Why a pull request could not be read. -#[derive(Debug)] -pub enum PullRequestError { - /// `gh` is not installed; nothing on this machine will answer. - GhUnavailable, - Other(anyhow::Error), +/// A session's branch whose pull request is read. +#[derive(Debug, Clone)] +pub(crate) struct BranchLookup { + pub repo: RepoRef, + pub branch: String, + /// The pull request last seen for the branch. + pub known: Option, } -impl std::fmt::Display for PullRequestError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::GhUnavailable => write!(f, "the GitHub CLI (gh) is not installed"), - Self::Other(e) => write!(f, "{e:#}"), +/// The pull request of each branch, in order: `Ok(None)` when the branch +/// has none, an error when it could not be read. Known pull requests are +/// read in one batch; a branch whose known pull request is gone or now +/// has another head, and a branch without one, is looked up by name. +pub(crate) async fn read_branch_pull_requests( + client: &github::Client, + lookups: &[BranchLookup], +) -> Vec>> { + let mut results: Vec>>> = + lookups.iter().map(|_| None).collect(); + let mut by_branch: Vec = Vec::new(); + + let known: Vec<(usize, PullRequestRef)> = lookups + .iter() + .enumerate() + .filter_map(|(index, lookup)| Some((index, lookup.repo.pull_request(lookup.known?)))) + .collect(); + for ((index, _), summary) in known.iter().zip(summaries(client, &known).await) { + match summary { + Ok(summary) if summary.head_branch == lookups[*index].branch => { + results[*index] = Some(Ok(Some(PullRequestSnapshot::from_summary( + &summary, + SystemTime::now(), + )))); + } + Ok(_) => by_branch.push(*index), + Err(e) if e.is_not_found() => by_branch.push(*index), + Err(e) => results[*index] = Some(Err(e)), } } -} + by_branch.extend( + lookups + .iter() + .enumerate() + .filter(|(_, lookup)| lookup.known.is_none()) + .map(|(index, _)| index), + ); -/// The pull request whose head is `branch`, or `None` when there is none. -pub async fn fetch_pull_request( - repo_root: &Path, - branch: &str, -) -> std::result::Result, PullRequestError> { - let output = tokio::time::timeout( - GH_TIMEOUT, - tokio::process::Command::new("gh") - .args(["pr", "view", branch, "--json", JSON_FIELDS]) - .current_dir(repo_root) - .output(), - ) - .await - .map_err(|_| PullRequestError::Other(anyhow::anyhow!("gh pr view timed out")))?; - let output = match output { - Ok(output) => output, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - return Err(PullRequestError::GhUnavailable); + let found = futures::future::join_all(by_branch.iter().map(|&index| { + let lookup = &lookups[index]; + client.pull_requests_for_head(&lookup.repo, &lookup.branch) + })) + .await; + let mut numbered: Vec<(usize, PullRequestRef)> = Vec::new(); + for (index, found) in by_branch.into_iter().zip(found) { + match found.map(|found| own_pull_request(&found)) { + Ok(Some(number)) => numbered.push((index, lookups[index].repo.pull_request(number))), + Ok(None) => results[index] = Some(Ok(None)), + Err(e) => results[index] = Some(Err(e)), } - Err(e) => return Err(PullRequestError::Other(e.into())), - }; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - if stderr.contains("no pull requests found") { - return Ok(None); - } - return Err(PullRequestError::Other(anyhow::anyhow!( - "gh pr view {branch} failed: {}", - stderr.trim() - ))); } - let stdout = String::from_utf8_lossy(&output.stdout); - parse_pr_view(&stdout, SystemTime::now()) - .map(Some) - .map_err(PullRequestError::Other) -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct PrView { - number: u64, - url: String, - #[serde(default)] - title: String, - state: String, - #[serde(default)] - is_draft: bool, - #[serde(default)] - review_decision: String, - #[serde(default)] - status_check_rollup: Vec, -} - -/// One entry of `statusCheckRollup`: a check run (`status` + `conclusion`) -/// or a commit status context (`state`). -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct CheckRollupItem { - #[serde(default)] - status: Option, - #[serde(default)] - conclusion: Option, - #[serde(default)] - state: Option, -} + for ((index, _), summary) in numbered.iter().zip(summaries(client, &numbered).await) { + results[*index] = Some(summary.map(|summary| { + Some(PullRequestSnapshot::from_summary( + &summary, + SystemTime::now(), + )) + })); + } -/// Parse the JSON of `gh pr view --json` with [`JSON_FIELDS`]. -pub fn parse_pr_view(json: &str, fetched_at: SystemTime) -> Result { - let view: PrView = serde_json::from_str(json).context("unexpected gh pr view output")?; - let state = match (view.state.as_str(), view.is_draft) { - ("OPEN", true) => PullRequestState::Draft, - ("OPEN", false) => PullRequestState::Open, - ("MERGED", _) => PullRequestState::Merged, - ("CLOSED", _) => PullRequestState::Closed, - (other, _) => bail!("unknown pull request state {other:?}"), - }; - let review = match view.review_decision.as_str() { - "APPROVED" => Some(ReviewDecision::Approved), - "CHANGES_REQUESTED" => Some(ReviewDecision::ChangesRequested), - "REVIEW_REQUIRED" => Some(ReviewDecision::ReviewRequired), - _ => None, - }; - Ok(PullRequestSnapshot { - number: view.number, - url: view.url, - title: view.title, - state, - review, - checks: summarize_checks(&view.status_check_rollup), - fetched_at, - }) + results + .into_iter() + .map(|result| result.expect("every branch was read")) + .collect() } -/// One verdict for the whole rollup: any failure fails it, any unfinished -/// check leaves it pending, otherwise it passes. No checks, no verdict. -fn summarize_checks(items: &[CheckRollupItem]) -> Option { - if items.is_empty() { - return None; +async fn summaries( + client: &github::Client, + prs: &[(usize, PullRequestRef)], +) -> Vec> { + if prs.is_empty() { + return Vec::new(); } - let mut pending = false; - for item in items { - let verdict = item - .conclusion - .as_deref() - .filter(|c| !c.is_empty()) - .or(item.state.as_deref()); - match verdict { - Some( - "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "ACTION_REQUIRED" - | "STARTUP_FAILURE", - ) => { - return Some(ChecksState::Failing); - } - Some("SUCCESS" | "NEUTRAL" | "SKIPPED") => {} - // No conclusion yet, or a status context still pending/expected. - _ => pending = true, - } - if item.status.as_deref().is_some_and(|s| s != "COMPLETED") { - pending = true; - } - } - Some(if pending { - ChecksState::Pending - } else { - ChecksState::Passing - }) + let prs: Vec = prs.iter().map(|(_, pr)| pr.clone()).collect(); + client.summaries(&prs).await } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; + use github::fake::{FakeResponse, FakeTransport}; + use serde_json::{Value, json}; - const MERGED: &str = r#"{"number":219,"url":"https://github.com/o/r/pull/219","title":"Record a tab", - "state":"MERGED","isDraft":false,"reviewDecision":"", - "statusCheckRollup":[ - {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS","name":"a"}, - {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS","name":"b"}]}"#; + /// A `PullRequestSummary` node as GitHub answers it. + pub(crate) fn summary_node(number: u64, head: &str, state: &str) -> Value { + json!({ + "number": number, + "title": format!("PR {number}"), + "url": format!("https://github.com/stippi/code-assistant/pull/{number}"), + "state": state, + "isDraft": false, + "mergeable": "MERGEABLE", + "reviewDecision": "APPROVED", + "additions": 1, + "deletions": 2, + "changedFiles": 3, + "updatedAt": "2026-10-30T10:00:00Z", + "mergedAt": null, + "closedAt": null, + "headRefName": head, + "baseRefName": "main", + "author": {"__typename": "User", "login": "stippi"}, + "latestReviews": {"nodes": []}, + "commits": {"nodes": [{"commit": {"statusCheckRollup": {"state": "SUCCESS"}}}]} + }) + } - #[test] - fn a_merged_pull_request_with_green_checks() { - let pr = parse_pr_view(MERGED, SystemTime::UNIX_EPOCH).unwrap(); - assert_eq!(pr.number, 219); - assert_eq!(pr.state, PullRequestState::Merged); - assert_eq!(pr.review, None); - assert_eq!(pr.checks, Some(ChecksState::Passing)); + /// A `PullRequestsByHead` answer listing `numbers`, newest first. + pub(crate) fn head_nodes(head: &str, numbers: &[u64]) -> Value { + let nodes: Vec = numbers + .iter() + .map(|number| { + json!({ + "number": number, "title": format!("PR {number}"), + "url": format!("https://github.com/stippi/code-assistant/pull/{number}"), + "baseRefName": "main", "headRefName": head, + "headRefOid": "0123456789abcdef0123456789abcdef01234567", + "state": "OPEN", "isDraft": false, "mergedAt": null, "closedAt": null, + "updatedAt": "2026-10-30T10:00:00Z", "isCrossRepository": false, + "headRepository": {"name": "code-assistant", "nameWithOwner": "stippi/code-assistant"}, + "headRepositoryOwner": {"login": "stippi"} + }) + }) + .collect(); + json!({"repository": {"pullRequests": {"nodes": nodes}}}) } - #[test] - fn an_open_draft_with_a_running_check_is_pending() { - let json = r#"{"number":1,"url":"u","title":"t","state":"OPEN","isDraft":true, - "reviewDecision":"REVIEW_REQUIRED", - "statusCheckRollup":[{"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":""}]}"#; - let pr = parse_pr_view(json, SystemTime::UNIX_EPOCH).unwrap(); - assert_eq!(pr.state, PullRequestState::Draft); - assert_eq!(pr.review, Some(ReviewDecision::ReviewRequired)); - assert_eq!(pr.checks, Some(ChecksState::Pending)); + fn lookup(branch: &str, known: Option) -> BranchLookup { + BranchLookup { + repo: RepoRef::new("github.com", "stippi", "code-assistant"), + branch: branch.into(), + known, + } } - #[test] - fn one_failing_status_context_fails_the_rollup() { - let json = r#"{"number":2,"url":"u","title":"t","state":"OPEN","isDraft":false, - "reviewDecision":"CHANGES_REQUESTED", - "statusCheckRollup":[ - {"__typename":"StatusContext","state":"SUCCESS","context":"ci"}, - {"__typename":"StatusContext","state":"FAILURE","context":"lint"}]}"#; - let pr = parse_pr_view(json, SystemTime::UNIX_EPOCH).unwrap(); - assert_eq!(pr.state, PullRequestState::Open); - assert_eq!(pr.review, Some(ReviewDecision::ChangesRequested)); - assert_eq!(pr.checks, Some(ChecksState::Failing)); + #[tokio::test] + async fn known_pull_requests_are_read_in_one_batch_and_the_rest_by_branch() { + let fake = FakeTransport::new(); + // 5 has moved to another head, 6 is gone, 7 is current. + fake.on_graphql("PullRequestSummary") + .with_variables(json!({"number_0": 5})) + .respond(FakeResponse::json( + 200, + json!({ + "data": { + "a0_repository": {"pullRequest": summary_node(5, "elsewhere", "OPEN")}, + "a1_repository": {"pullRequest": null}, + "a2_repository": {"pullRequest": summary_node(7, "feature/c", "MERGED")} + }, + "errors": [{"type": "NOT_FOUND", "path": ["a1_repository", "pullRequest"], + "message": "Could not resolve to a PullRequest"}] + }), + )); + fake.on_graphql("PullRequestsByHead") + .with_variables(json!({"head": "feature/a"})) + .data(head_nodes("feature/a", &[9])); + fake.on_graphql("PullRequestsByHead") + .with_variables(json!({"head": "feature/b"})) + .data(head_nodes("feature/b", &[])); + fake.on_graphql("PullRequestsByHead") + .with_variables(json!({"head": "feature/d"})) + .data(head_nodes("feature/d", &[10])); + fake.on_graphql("PullRequestSummary") + .with_variables(json!({"number_0": 9})) + .data(json!({ + "a0_repository": {"pullRequest": summary_node(9, "feature/a", "OPEN")}, + "a1_repository": {"pullRequest": summary_node(10, "feature/d", "OPEN")} + })); + + let read = read_branch_pull_requests( + &fake.client(), + &[ + lookup("feature/a", Some(5)), + lookup("feature/b", Some(6)), + lookup("feature/c", Some(7)), + lookup("feature/d", None), + ], + ) + .await; + + let numbers: Vec> = read + .iter() + .map(|r| r.as_ref().unwrap().as_ref().map(|pr| pr.number)) + .collect(); + assert_eq!(numbers, [Some(9), None, Some(7), Some(10)]); + let merged = read[2].as_ref().unwrap().as_ref().unwrap(); + assert_eq!(merged.state, PullRequestState::Merged); + assert_eq!(merged.review, Some(ReviewDecision::Approved)); + assert_eq!(merged.checks, Some(ChecksState::Passing)); + assert_eq!( + fake.operations(), + [ + "PullRequestSummary", + "PullRequestsByHead", + "PullRequestsByHead", + "PullRequestsByHead", + "PullRequestSummary" + ] + ); } - #[test] - fn no_checks_means_no_verdict_and_closed_is_closed() { - let json = r#"{"number":3,"url":"u","title":"t","state":"CLOSED","isDraft":false, - "reviewDecision":"","statusCheckRollup":[]}"#; - let pr = parse_pr_view(json, SystemTime::UNIX_EPOCH).unwrap(); - assert_eq!(pr.state, PullRequestState::Closed); - assert_eq!(pr.checks, None); + #[tokio::test] + async fn without_a_credential_every_branch_fails_and_nothing_is_guessed() { + struct NoToken; + #[async_trait::async_trait] + impl github::TokenSource for NoToken { + async fn token(&self, host: &str) -> github::Result { + Err(github::Error::NoCredential { + host: host.into(), + detail: "gh is not installed".into(), + }) + } + } + let fake = FakeTransport::new(); + let client = github::Client::with_parts(fake.clone(), std::sync::Arc::new(NoToken)); + + let read = + read_branch_pull_requests(&client, &[lookup("a", Some(1)), lookup("b", None)]).await; + + assert!( + read.iter() + .all(|r| matches!(r, Err(github::Error::NoCredential { .. }))) + ); + assert!(fake.requests().is_empty()); } #[test] - fn an_unknown_state_is_an_error() { - let json = r#"{"number":3,"url":"u","title":"t","state":"WEIRD","isDraft":false}"#; - assert!(parse_pr_view(json, SystemTime::UNIX_EPOCH).is_err()); + fn an_open_draft_reads_as_a_draft() { + let mut summary: Summary = serde_json::from_value(json!({ + "number": 1, "title": "t", "url": "u", "head_branch": "h", "base_branch": "main", + "state": "open", "is_draft": true, "closed_at": null, "merged_at": null, + "updated_at": "2026-10-30T10:00:00Z", "author": null, "additions": 0, + "deletions": 0, "changed_files": 0, "review_decision": null, + "checks_state": "pending", "mergeability": "unknown" + })) + .unwrap(); + let snapshot = PullRequestSnapshot::from_summary(&summary, SystemTime::UNIX_EPOCH); + assert_eq!(snapshot.state, PullRequestState::Draft); + assert_eq!(snapshot.review, None); + assert_eq!(snapshot.checks, Some(ChecksState::Pending)); + summary.state = github::model::PullRequestState::Closed; + let closed = PullRequestSnapshot::from_summary(&summary, SystemTime::UNIX_EPOCH); + assert_eq!(closed.state, PullRequestState::Closed); } } diff --git a/crates/code_assistant_core/src/session/service.rs b/crates/code_assistant_core/src/session/service.rs index 5c7473aa8..f8163e28f 100644 --- a/crates/code_assistant_core/src/session/service.rs +++ b/crates/code_assistant_core/src/session/service.rs @@ -17,6 +17,7 @@ use crate::config::{DefaultProjectManager, ProjectManager, save_project}; use crate::injection::without_injections; use crate::persistence::{ChatMetadata, DraftAttachment, MessageEdit, NodeId, SessionModelConfig}; +use crate::pull_requests::{PullRequestSource, PullRequests}; use crate::session::SessionManager; use crate::session::effort::EffortChoice; use crate::session::event_stream::EventStream; @@ -79,6 +80,10 @@ pub struct AgentRuntimeOptions { pub project_manager_factory: ProjectManagerFactory, /// Overrides LLM client construction per run. See [`LlmClientFactory`]. pub llm_client_factory: Option, + /// Where pull requests are read from and their snapshots kept. `None` + /// uses [`PullRequestSource::default`]; tests supply a client over a + /// scripted transport. + pub pull_requests: Option, } /// A single entry in the input-area skill picker. @@ -217,6 +222,7 @@ struct ServiceCtx { manager: Arc>, runtime: Arc, events: EventStream, + pull_requests: Arc, } impl ServiceCtx { @@ -268,10 +274,15 @@ impl SessionService { let (tx, rx) = async_channel::bounded::(COMMAND_CAPACITY); let (control_tx, control_rx) = async_channel::bounded::(16); let admission = Arc::new(tokio::sync::Semaphore::new(COMMAND_CAPACITY)); + let pull_requests = Arc::new(PullRequests::new( + runtime.pull_requests.clone().unwrap_or_default(), + events.clone(), + )); let ctx = ServiceCtx { manager, runtime, events: events.clone(), + pull_requests, }; let worker = async move { let control = { @@ -2474,6 +2485,7 @@ fn schedule_agent_impl( let owner = Arc::downgrade(&ctx.manager); let runtime = ctx.runtime.clone(); let events = ctx.events.clone(); + let github = ctx.pull_requests.client(); let session_id = session_id.to_string(); let task_session = session_id.clone(); let task = tokio::spawn(async move { @@ -2591,6 +2603,7 @@ fn schedule_agent_impl( model: Some(session_config), task, sub_agent_llm: runtime.llm_client_factory.clone(), + github: Some(github), }, ) .await @@ -2650,6 +2663,7 @@ pub use files::{ }; mod lifecycle; mod new_context; +mod pull_requests; #[cfg(test)] mod recovery_tests; @@ -2701,6 +2715,7 @@ mod tests { }), project_manager_factory: default_project_manager_factory(), llm_client_factory: None, + pull_requests: None, }); let (service, worker) = SessionService::new(manager.clone(), runtime, events); tokio::spawn(worker); @@ -2755,6 +2770,38 @@ mod tests { }), project_manager_factory: default_project_manager_factory(), llm_client_factory: Some(factory), + pull_requests: None, + }); + let (service, worker) = SessionService::new(manager.clone(), runtime, events); + tokio::spawn(worker); + (service, manager) + } + + /// Service whose pull request reads go through `source` (a scripted + /// GitHub transport). + pub(super) fn test_service_with_pull_requests( + root: &std::path::Path, + source: PullRequestSource, + ) -> (SessionService, Arc>) { + let events = EventStream::new(); + let persistence = FileSessionPersistence::new_with_root_dir(root.to_path_buf()); + let manager = Arc::new(Mutex::new(SessionManager::new( + persistence, + SessionConfig::default(), + "test-model".to_string(), + crate::tools::test_registry(), + events.clone(), + ))); + let runtime = Arc::new(AgentRuntimeOptions { + record_path: None, + playback_path: None, + fast_playback: false, + command_executor_factory: Arc::new(|_| { + Box::new(crate::mocks::create_command_executor_mock()) + }), + project_manager_factory: default_project_manager_factory(), + llm_client_factory: None, + pull_requests: Some(source), }); let (service, worker) = SessionService::new(manager.clone(), runtime, events); tokio::spawn(worker); @@ -3724,6 +3771,7 @@ mod tests { }), project_manager_factory: default_project_manager_factory(), llm_client_factory: None, + pull_requests: None, }); let (service, worker) = SessionService::new(manager, runtime, events); drop(worker); // never spawned @@ -3988,6 +4036,7 @@ mod tests { ) }), llm_client_factory: Some(factory), + pull_requests: None, }); let (service, worker) = SessionService::new(manager.clone(), runtime, events); tokio::spawn(worker); diff --git a/crates/code_assistant_core/src/session/service/lifecycle.rs b/crates/code_assistant_core/src/session/service/lifecycle.rs index 8ce37e378..ed34ac9b3 100644 --- a/crates/code_assistant_core/src/session/service/lifecycle.rs +++ b/crates/code_assistant_core/src/session/service/lifecycle.rs @@ -4,12 +4,15 @@ use std::collections::{HashMap, HashSet}; use std::time::SystemTime; +use github::RepoRef; + use super::*; +use crate::pull_requests::github_repo; use crate::session::lifecycle::{ LifecycleConfig, SessionLifecycle, SettledReason, SettlementCandidate, auto_settlement, sessions_with_merged_branch, }; -use crate::session::pull_request::{PullRequestError, PullRequestSnapshot, fetch_pull_request}; +use crate::session::pull_request::{BranchLookup, PullRequestSnapshot, read_branch_pull_requests}; impl SessionService { /// Every session's lifecycle record, keyed by session id. @@ -61,8 +64,8 @@ impl SessionService { /// Refresh the pull request of every unsettled session on a branch, then /// apply the automatic settlement rules once; returns what settled. Git - /// and `gh` run outside the session lock so a slow repository or host - /// never stalls other commands. + /// and GitHub are asked outside the session lock so a slow repository or + /// host never stalls other commands. pub async fn sweep_lifecycle( &self, config: &LifecycleConfig, @@ -83,23 +86,51 @@ impl SessionService { .await?; // Pull requests: a merged one is the strongest merge signal. - let mut gh_available = true; - for candidate in candidates.iter_mut() { + let mut repos: HashMap> = HashMap::new(); + let mut lookups: Vec<(usize, BranchLookup)> = Vec::new(); + for (index, candidate) in candidates.iter().enumerate() { let (Some(branch), Some(root)) = (&candidate.branch, &candidate.repo_root) else { continue; }; - if candidate.busy || !gh_available { + if candidate.busy { continue; } - let fetched = match fetch_pull_request(root, branch).await { + if !repos.contains_key(root) { + repos.insert(root.clone(), github_repo(root.clone()).await); + } + let Some(repo) = repos[root].clone() else { + continue; + }; + let known = candidate + .lifecycle + .pull_request + .as_ref() + .map(|pr| pr.number); + lookups.push(( + index, + BranchLookup { + repo, + branch: branch.clone(), + known, + }, + )); + } + let branches: Vec = lookups.iter().map(|(_, l)| l.clone()).collect(); + let reads = self + .call_io(move |ctx| async move { + let client = ctx.pull_requests.client(); + Ok(read_branch_pull_requests(&client, &branches).await) + }) + .await?; + for ((index, lookup), read) in lookups.into_iter().zip(reads) { + let candidate = &mut candidates[index]; + let fetched = match read { Ok(fetched) => fetched, - Err(PullRequestError::GhUnavailable) => { - debug!("Lifecycle sweep: gh is not installed, skipping pull requests"); - gh_available = false; - continue; - } Err(e) => { - debug!("Lifecycle sweep: cannot read the pull request of {branch}: {e}"); + debug!( + "Lifecycle sweep: cannot read the pull request of {}: {e}", + lookup.branch + ); continue; } }; @@ -411,6 +442,104 @@ mod tests { assert_eq!(listed.iter().find(|s| s.id == id).unwrap().branch, None); } + #[tokio::test] + async fn the_sweep_reads_pull_requests_through_github_and_settles_a_merged_one() { + use crate::session::pull_request::tests::{head_nodes, summary_node}; + use crate::session::pull_request::{PullRequestState, ReviewDecision}; + use github::fake::FakeTransport; + use serde_json::json; + + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + std::fs::create_dir(&repo).unwrap(); + repo_on_feature_branch(&repo); + git(&repo, &["commit", "-q", "--allow-empty", "-m", "x"]); + git(&repo, &["checkout", "-q", "-b", "feature/y", "main"]); + git(&repo, &["commit", "-q", "--allow-empty", "-m", "y"]); + git( + &repo, + &[ + "remote", + "add", + "origin", + "https://github.com/stippi/code-assistant.git", + ], + ); + let fake = FakeTransport::new(); + fake.on_graphql("PullRequestSummary") + .with_variables(json!({"number_0": 7})) + .data( + json!({"a0_repository": {"pullRequest": summary_node(7, "feature/x", "MERGED")}}), + ); + fake.on_graphql("PullRequestsByHead") + .with_variables(json!({"head": "feature/y"})) + .data(head_nodes("feature/y", &[8])); + fake.on_graphql("PullRequestSummary") + .with_variables(json!({"number_0": 8})) + .data(json!({"a0_repository": {"pullRequest": summary_node(8, "feature/y", "OPEN")}})); + let source = crate::pull_requests::PullRequestSource { + client: fake.client(), + snapshot_dir: tmp.path().join("pull-requests"), + }; + let (service, manager) = super::super::tests::test_service_with_pull_requests( + &tmp.path().join("sessions"), + source, + ); + let mut ids = Vec::new(); + for branch in ["feature/x", "feature/y"] { + let config = crate::session::SessionConfig { + init_path: Some(repo.clone()), + initial_project: "pull-request-sweep-test".into(), + branch: Some(branch.into()), + ..crate::session::SessionConfig::default() + }; + let id = service + .create_session_with_config(None, config, None) + .await + .unwrap(); + service.load_session(id.clone(), None).await.unwrap(); + ids.push(id); + } + manager + .lock() + .await + .update_session_lifecycle(&ids[0], |lifecycle| { + lifecycle.pull_request = Some(PullRequestSnapshot { + number: 7, + url: String::new(), + title: "PR 7".into(), + state: PullRequestState::Open, + review: None, + checks: None, + fetched_at: SystemTime::UNIX_EPOCH, + }) + }) + .unwrap(); + let config = LifecycleConfig { + auto_settle_after_days: 0, + auto_settle_on_merge: true, + }; + + let settled = service.sweep_lifecycle(&config).await.unwrap(); + + assert_eq!(settled, vec![(ids[0].clone(), SettledReason::BranchMerged)]); + let lifecycles = service.list_session_lifecycles().await.unwrap(); + let merged = lifecycles[&ids[0]].pull_request.as_ref().unwrap(); + assert_eq!(merged.state, PullRequestState::Merged); + let found = lifecycles[&ids[1]].pull_request.as_ref().unwrap(); + assert_eq!((found.number, found.state), (8, PullRequestState::Open)); + assert_eq!(found.review, Some(ReviewDecision::Approved)); + // Known pull requests in one batch, the rest by branch. + assert_eq!( + fake.operations(), + [ + "PullRequestSummary", + "PullRequestsByHead", + "PullRequestSummary" + ] + ); + } + #[tokio::test] async fn the_sweep_does_nothing_when_both_rules_are_off() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/code_assistant_core/src/session/service/pull_requests.rs b/crates/code_assistant_core/src/session/service/pull_requests.rs new file mode 100644 index 000000000..7841bc0b4 --- /dev/null +++ b/crates/code_assistant_core/src/session/service/pull_requests.rs @@ -0,0 +1,439 @@ +//! GitHub pull requests through the service: where a session's pull +//! requests live, cached reads and the writes (see +//! [`crate::pull_requests`]). Everything runs on its own, outside the +//! session lanes: a slow host never holds up session commands. + +use super::*; +use crate::pull_requests::{Fetched, SessionPullRequests}; +use github::model::{ + Action, Activity, Checks, Detail, DiffFileContents, DiffPage, DiffStat, EditableCommentKind, + FileChange, FilesViewed, LabelCandidates, ListPage, ListQuery, PullRequestUpdate, + ReactionContent, ReviewSubmission, Reviewer, ReviewerCandidates, ThreadCommentsPage, Viewer, +}; +use github::{PullRequestRef, RepoRef}; + +impl SessionService { + /// The GitHub repository of the session's project, its branch and that + /// branch's pull request. `None` when the project has no GitHub remote + /// (or the session no project). + pub async fn session_pull_requests( + &self, + session_id: String, + ) -> Result> { + self.call_io(move |ctx| async move { + let (path, branch, known) = { + let manager = ctx.manager.lock().await; + let session = manager + .get_session(&session_id) + .ok_or_else(|| anyhow!("Session {session_id} not found"))?; + let Some(path) = session.session.config.effective_project_path().cloned() else { + return Ok(None); + }; + let branch = session.session.config.branch.clone(); + let known = manager + .session_lifecycles()? + .remove(&session_id) + .and_then(|lifecycle| lifecycle.pull_request) + .map(|pr| pr.number); + (path, branch, known) + }; + ctx.pull_requests + .session_pull_requests(path, branch, known) + .await + }) + .await + } + + // Reads: the held value at once (memory, else disk snapshot) and a + // background refresh when it is past its time to live; with nothing + // held, they wait for GitHub. + + pub async fn pull_request_list( + &self, + repo: RepoRef, + query: ListQuery, + ) -> Result> { + self.call_io(move |ctx| async move { ctx.pull_requests.list(repo, query).await }) + .await + } + + pub async fn pull_request_list_stats(&self, prs: Vec) -> Result> { + self.call_io(move |ctx| async move { ctx.pull_requests.list_stats(prs).await }) + .await + } + + pub async fn pull_request_detail(&self, pr: PullRequestRef) -> Result> { + self.call_io(move |ctx| async move { ctx.pull_requests.detail(pr).await }) + .await + } + + pub async fn pull_request_checks(&self, pr: PullRequestRef) -> Result> { + self.call_io(move |ctx| async move { ctx.pull_requests.checks(pr).await }) + .await + } + + pub async fn pull_request_activity(&self, pr: PullRequestRef) -> Result> { + self.call_io(move |ctx| async move { ctx.pull_requests.activity(pr).await }) + .await + } + + /// A page of the diff: the pull request's, or with `commit` that + /// commit's. `cursor` is the previous page's `next_cursor`. + pub async fn pull_request_diff( + &self, + pr: PullRequestRef, + commit: Option, + cursor: Option, + ) -> Result> { + self.call_io(move |ctx| async move { ctx.pull_requests.diff(pr, commit, cursor).await }) + .await + } + + pub async fn pull_request_files_viewed( + &self, + pr: PullRequestRef, + ) -> Result> { + self.call_io(move |ctx| async move { ctx.pull_requests.files_viewed(pr).await }) + .await + } + + /// More comments of a review thread, after `cursor` (the thread's + /// `next_comments_cursor`, then the page's `next_cursor`). + pub async fn pull_request_thread_comments( + &self, + pr: PullRequestRef, + thread_id: String, + cursor: String, + ) -> Result { + self.call_io(move |ctx| async move { + ctx.pull_requests + .thread_comments(pr, thread_id, cursor) + .await + }) + .await + } + + /// Both sides of a changed file, of the pull request or of `commit`. + pub async fn pull_request_file_contents( + &self, + pr: PullRequestRef, + commit: Option, + change: FileChange, + old_path: String, + new_path: String, + ) -> Result { + self.call_io(move |ctx| async move { + ctx.pull_requests + .file_contents(pr, commit, change, old_path, new_path) + .await + }) + .await + } + + pub async fn pull_request_reviewer_candidates( + &self, + pr: PullRequestRef, + ) -> Result { + self.call_io(move |ctx| async move { ctx.pull_requests.reviewer_candidates(pr).await }) + .await + } + + pub async fn pull_request_label_candidates( + &self, + pr: PullRequestRef, + ) -> Result { + self.call_io(move |ctx| async move { ctx.pull_requests.label_candidates(pr).await }) + .await + } + + /// The account the host's credential signs in as. + pub async fn github_viewer(&self, host: String) -> Result { + self.call_io(move |ctx| async move { ctx.pull_requests.viewer(host).await }) + .await + } + + // Writes: run at once, then invalidate what they change and announce it + // with `PullRequestChanged` (and `PullRequestListChanged`). + + pub async fn pull_request_comment(&self, pr: PullRequestRef, body: String) -> Result<()> { + self.call_io(move |ctx| async move { ctx.pull_requests.comment(pr, body).await }) + .await + } + + pub async fn pull_request_update_comment( + &self, + pr: PullRequestRef, + id: String, + kind: EditableCommentKind, + body: String, + ) -> Result<()> { + self.call_io(move |ctx| async move { + ctx.pull_requests.update_comment(pr, id, kind, body).await + }) + .await + } + + pub async fn pull_request_update( + &self, + pr: PullRequestRef, + update: PullRequestUpdate, + ) -> Result<()> { + self.call_io(move |ctx| async move { ctx.pull_requests.update(pr, update).await }) + .await + } + + pub async fn pull_request_submit_review( + &self, + pr: PullRequestRef, + review: ReviewSubmission, + ) -> Result<()> { + self.call_io(move |ctx| async move { ctx.pull_requests.submit_review(pr, review).await }) + .await + } + + pub async fn pull_request_reply( + &self, + pr: PullRequestRef, + thread_id: String, + body: String, + ) -> Result<()> { + self.call_io(move |ctx| async move { ctx.pull_requests.reply(pr, thread_id, body).await }) + .await + } + + pub async fn pull_request_resolve_thread( + &self, + pr: PullRequestRef, + thread_id: String, + resolved: bool, + ) -> Result<()> { + self.call_io(move |ctx| async move { + ctx.pull_requests + .resolve_thread(pr, thread_id, resolved) + .await + }) + .await + } + + /// Adds or removes the viewer's reaction on a comment or review + /// (`subject_id`), or on the pull request itself (`None`). + pub async fn pull_request_react( + &self, + pr: PullRequestRef, + subject_id: Option, + content: ReactionContent, + reacted: bool, + ) -> Result<()> { + self.call_io(move |ctx| async move { + ctx.pull_requests + .react(pr, subject_id, content, reacted) + .await + }) + .await + } + + pub async fn pull_request_request_reviewers( + &self, + pr: PullRequestRef, + reviewers: Vec, + requested: bool, + ) -> Result<()> { + self.call_io(move |ctx| async move { + ctx.pull_requests + .request_reviewers(pr, reviewers, requested) + .await + }) + .await + } + + pub async fn pull_request_set_labels( + &self, + pr: PullRequestRef, + labels: Vec, + applied: bool, + ) -> Result<()> { + self.call_io( + move |ctx| async move { ctx.pull_requests.set_labels(pr, labels, applied).await }, + ) + .await + } + + /// Marks files viewed (`true`) or not; only the viewed states are read + /// anew, the diff stays. + pub async fn pull_request_set_files_viewed( + &self, + pr: PullRequestRef, + files: Vec<(String, bool)>, + ) -> Result<()> { + self.call_io(move |ctx| async move { ctx.pull_requests.set_files_viewed(pr, files).await }) + .await + } + + pub async fn pull_request_action(&self, pr: PullRequestRef, action: Action) -> Result<()> { + self.call_io(move |ctx| async move { ctx.pull_requests.action(pr, action).await }) + .await + } + + /// Drops the repository's cached lists; each is read anew on its next + /// read. + pub async fn pull_request_list_refresh(&self, repo: RepoRef) -> Result<()> { + self.call_io(move |ctx| async move { + ctx.pull_requests.refresh_list(&repo); + Ok(()) + }) + .await + } + + /// Drops every cached part of the pull request; each is read anew on its + /// next read. + pub async fn pull_request_refresh(&self, pr: PullRequestRef) -> Result<()> { + self.call_io(move |ctx| async move { + ctx.pull_requests.refresh(&pr); + Ok(()) + }) + .await + } +} + +#[cfg(test)] +mod tests { + use super::super::tests::test_service_with_pull_requests; + use super::*; + use crate::pull_requests::PullRequestSource; + use crate::session::pull_request::tests::head_nodes; + use crate::session::pull_request::{PullRequestSnapshot, PullRequestState}; + use github::fake::FakeTransport; + use serde_json::json; + + fn git(dir: &std::path::Path, args: &[&str]) { + let status = std::process::Command::new("git") + .args(args) + .current_dir(dir) + .status() + .unwrap_or_else(|e| panic!("git {args:?}: {e}")); + assert!(status.success(), "git {args:?} failed"); + } + + #[tokio::test] + async fn a_sessions_pull_request_comes_from_its_lifecycle_or_its_branch() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + std::fs::create_dir(&repo).unwrap(); + git(&repo, &["init", "-q", "-b", "main"]); + git(&repo, &["config", "user.email", "t@t.t"]); + git(&repo, &["config", "user.name", "t"]); + git(&repo, &["config", "commit.gpgsign", "false"]); + git(&repo, &["commit", "-q", "--allow-empty", "-m", "init"]); + git(&repo, &["checkout", "-q", "-b", "feature/x"]); + git( + &repo, + &[ + "remote", + "add", + "origin", + "git@github.com:stippi/code-assistant.git", + ], + ); + let fake = FakeTransport::new(); + fake.on_graphql("PullRequestsByHead") + .with_variables(json!({"head": "feature/x"})) + .data(head_nodes("feature/x", &[291])); + let source = PullRequestSource { + client: fake.client(), + snapshot_dir: tmp.path().join("pull-requests"), + }; + let (service, manager) = + test_service_with_pull_requests(&tmp.path().join("sessions"), source); + let config = crate::session::SessionConfig { + init_path: Some(repo.clone()), + ..crate::session::SessionConfig::default() + }; + let id = service + .create_session_with_config(None, config, None) + .await + .unwrap(); + service.load_session(id.clone(), None).await.unwrap(); + + // The branch checked out, looked up on GitHub. + let found = service.session_pull_requests(id.clone()).await.unwrap(); + let expected = SessionPullRequests { + repo: RepoRef::new("github.com", "stippi", "code-assistant"), + head_branch: Some("feature/x".into()), + own: Some(291), + }; + assert_eq!(found, Some(expected.clone())); + + // A pull request the lifecycle knows needs no lookup. + manager + .lock() + .await + .update_session_lifecycle(&id, |lifecycle| { + lifecycle.pull_request = Some(PullRequestSnapshot { + number: 17, + url: String::new(), + title: String::new(), + state: PullRequestState::Open, + review: None, + checks: None, + fetched_at: std::time::SystemTime::UNIX_EPOCH, + }) + }) + .unwrap(); + let known = service.session_pull_requests(id).await.unwrap(); + assert_eq!(known.and_then(|found| found.own), Some(17)); + assert_eq!(fake.operations(), ["PullRequestsByHead"]); + + // A session without a project has no pull requests. + let bare = service.create_session(None, None).await.unwrap(); + service.load_session(bare.clone(), None).await.unwrap(); + assert_eq!(service.session_pull_requests(bare).await.unwrap(), None); + } + + #[tokio::test] + async fn reads_and_writes_reach_github_through_the_service() { + let tmp = tempfile::tempdir().unwrap(); + let fake = FakeTransport::new(); + fake.on_rest( + github::transport::Method::Get, + "repos/stippi/code-assistant/pulls/291", + ) + .respond(github::fake::FakeResponse::text( + 200, + "diff --git a/a b/a\n", + )); + fake.on_graphql("PullRequestNodeId") + .data(json!({"repository": {"pullRequest": {"id": "PR_291"}}})); + fake.on_graphql("AddComment").data(json!({})); + let source = PullRequestSource { + client: fake.client(), + snapshot_dir: tmp.path().join("pull-requests"), + }; + let (service, _manager) = + test_service_with_pull_requests(&tmp.path().join("sessions"), source); + let pr = RepoRef::new("github.com", "stippi", "code-assistant").pull_request(291); + let mut subscription = service.subscribe(); + + let diff = service + .pull_request_diff(pr.clone(), None, None) + .await + .unwrap(); + service + .pull_request_comment(pr.clone(), "Looks good".into()) + .await + .unwrap(); + + assert_eq!(diff.value.patch, "diff --git a/a b/a\n"); + let event = subscription.recv().await.unwrap(); + assert!(event.session_id.is_none()); + assert!(matches!( + event.payload, + crate::session::EventPayload::Ui(UiEvent::PullRequestChanged { + pr: changed, + part: crate::pull_requests::PullRequestPart::Activity, + }) if changed == pr + )); + let comment = fake.requests().into_iter().last().unwrap(); + assert_eq!(comment.operation.as_deref(), Some("AddComment")); + assert_eq!(comment.variables.unwrap()["body"], json!("Looks good")); + } +} diff --git a/crates/code_assistant_core/src/session/service/sub_agent_tests.rs b/crates/code_assistant_core/src/session/service/sub_agent_tests.rs index d44fd138e..fa14ff370 100644 --- a/crates/code_assistant_core/src/session/service/sub_agent_tests.rs +++ b/crates/code_assistant_core/src/session/service/sub_agent_tests.rs @@ -84,6 +84,7 @@ fn service_with_background_child( }), project_manager_factory: Arc::new(|| Box::new(crate::mocks::MockProjectManager::new())), llm_client_factory: Some(factory), + pull_requests: None, }); let (service, worker) = SessionService::new(manager.clone(), runtime, events); tokio::spawn(worker); diff --git a/crates/code_assistant_core/src/ui/ui_events.rs b/crates/code_assistant_core/src/ui/ui_events.rs index 572f2f670..7329effb6 100644 --- a/crates/code_assistant_core/src/ui/ui_events.rs +++ b/crates/code_assistant_core/src/ui/ui_events.rs @@ -456,6 +456,18 @@ pub enum UiEvent { /// The UI should reload model lists, settings sections, etc. ConfigChanged, + // === Pull requests (app-scoped) === + /// A cached part of a pull request changed: a background refresh + /// landed, or a write invalidated it. Views showing that pull request + /// read the part again. + PullRequestChanged { + pr: github::PullRequestRef, + part: crate::pull_requests::PullRequestPart, + }, + /// The cached lists of a repository's pull requests changed; views + /// listing them read again. + PullRequestListChanged { repo: github::RepoRef }, + // === Voice mode (app-scoped) === /// The voice agent's state changed. VoiceStatusChanged { status: crate::voice::VoiceStatus }, diff --git a/crates/code_assistant_core/src/voice/tests.rs b/crates/code_assistant_core/src/voice/tests.rs index 88a807980..344fed3c3 100644 --- a/crates/code_assistant_core/src/voice/tests.rs +++ b/crates/code_assistant_core/src/voice/tests.rs @@ -218,6 +218,7 @@ fn session_service(dir: &tempfile::TempDir) -> (SessionService, EventStream) { MockLLMProvider::new(vec![Ok(create_test_response_text("done"))]).streaming(), )) })), + pull_requests: None, }); let (sessions, worker) = SessionService::new(manager, runtime, events.clone()); tokio::spawn(worker); diff --git a/crates/git/src/lib.rs b/crates/git/src/lib.rs index 24891ff7b..dfb17e611 100644 --- a/crates/git/src/lib.rs +++ b/crates/git/src/lib.rs @@ -2,6 +2,7 @@ mod binary; mod branch; mod diff; mod merged; +mod patch; mod repository; mod types; mod watch; @@ -10,6 +11,7 @@ pub mod worktree; pub use binary::GitBinary; pub use branch::BranchNotMerged; pub use diff::*; +pub use patch::{FilePatch, PatchHunk, PatchLine, PatchLineKind, parse_hunks, parse_patch}; pub use repository::GitRepository; pub use types::*; pub use watch::ChangeWatcher; diff --git a/crates/git/src/patch.rs b/crates/git/src/patch.rs new file mode 100644 index 000000000..8ac7d4837 --- /dev/null +++ b/crates/git/src/patch.rs @@ -0,0 +1,733 @@ +//! Unified diff text, as `git diff` and GitHub print it, parsed into files +//! and hunks. Input from the network is not trusted to be well formed: +//! anything that does not parse is skipped, never a panic. + +use crate::ChangeStatus; + +/// What a hunk line does. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PatchLineKind { + Context, + Added, + Removed, +} + +/// One line of a hunk. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PatchLine { + pub kind: PatchLineKind, + /// Without the `+`/`-`/` ` prefix and the line ending. + pub text: String, + /// A `\ No newline at end of file` followed: the line is the last of its + /// side and has no line ending. + pub missing_newline: bool, +} + +/// One `@@ -old_start,old_lines +new_start,new_lines @@ section` hunk. +/// Start lines are 1-based; a side without lines starts at 0, as in the +/// header (`-0,0` for an added file). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PatchHunk { + pub old_start: usize, + pub old_lines: usize, + pub new_start: usize, + pub new_lines: usize, + /// The text after the closing `@@`, usually the enclosing function; + /// empty when there is none. + pub section: String, + pub lines: Vec, +} + +/// One file of a multi-file patch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FilePatch { + /// The file's path after the change; for a deletion, the deleted path. + pub path: String, + /// The previous path of a rename or copy. + pub orig_path: Option, + pub status: ChangeStatus, + /// Similarity of a rename or copy, in percent. + pub similarity: Option, + /// File modes (`100644`, …) the patch names: both for a mode change, the + /// new one for an added file, the old one for a deleted file. + pub old_mode: Option, + pub new_mode: Option, + pub is_binary: bool, + pub hunks: Vec, +} + +/// Parse a multi-file patch (`diff --git` sections; plain `---`/`+++` +/// unified diffs work too). +pub fn parse_patch(text: &str) -> Vec { + let mut files = Vec::new(); + let mut file: Option = None; + for line in text.lines() { + // Inside a hunk its counts decide: "--- x" there is a removed line. + if let Some(current) = &mut file + && current.hunks.is_open() + && current.hunks.take(line) + { + continue; + } + if let Some(paths) = line.strip_prefix("diff --git ") { + files.extend(file.take().map(FileBuilder::finish)); + file = Some(FileBuilder::from_git_header(paths)); + continue; + } + // A plain unified diff starts each file with its `---` line. + if line.starts_with("--- ") && file.as_ref().is_none_or(|f| !f.hunks.hunks.is_empty()) { + files.extend(file.take().map(FileBuilder::finish)); + file = Some(FileBuilder::default()); + } + if let Some(current) = &mut file { + current.header_line(line); + } + } + files.extend(file.map(FileBuilder::finish)); + files +} + +/// Parse the hunks of a single file, e.g. a GitHub `files[].patch` field. +/// Anything before the first `@@` header is ignored. +pub fn parse_hunks(text: &str) -> Vec { + let mut hunks = HunkReader::default(); + for line in text.lines() { + hunks.take(line); + } + hunks.hunks +} + +/// Collects a file's hunks line by line. +#[derive(Default)] +struct HunkReader { + hunks: Vec, + /// Lines the open hunk still expects on each side. + old_left: usize, + new_left: usize, +} + +impl HunkReader { + fn is_open(&self) -> bool { + self.old_left > 0 || self.new_left > 0 + } + + /// Take `line` if it is a hunk header, a line of the open hunk or the + /// no-newline marker after one; `false` leaves it to the caller. + fn take(&mut self, line: &str) -> bool { + if line.starts_with('\\') { + // "\ No newline at end of file", after the line it is about. + if let Some(last) = self.hunks.last_mut().and_then(|h| h.lines.last_mut()) { + last.missing_newline = true; + } + return !self.hunks.is_empty(); + } + if line.starts_with("@@") { + match parse_hunk_header(line) { + Some(hunk) => { + (self.old_left, self.new_left) = (hunk.old_lines, hunk.new_lines); + self.hunks.push(hunk); + } + // A broken header: its lines are skipped as stray text. + None => (self.old_left, self.new_left) = (0, 0), + } + return true; + } + if !self.is_open() { + return false; + } + let (kind, text) = match line.as_bytes().first() { + // An empty context line whose single space was stripped. + None => (PatchLineKind::Context, ""), + Some(b' ') => (PatchLineKind::Context, &line[1..]), + Some(b'+') => (PatchLineKind::Added, &line[1..]), + Some(b'-') => (PatchLineKind::Removed, &line[1..]), + Some(_) => { + (self.old_left, self.new_left) = (0, 0); + return false; + } + }; + let (old, new) = match kind { + PatchLineKind::Context => (1, 1), + PatchLineKind::Added => (0, 1), + PatchLineKind::Removed => (1, 0), + }; + // More lines than the header announced: the hunk is over. + if self.old_left < old || self.new_left < new { + (self.old_left, self.new_left) = (0, 0); + return false; + } + self.old_left -= old; + self.new_left -= new; + if let Some(hunk) = self.hunks.last_mut() { + hunk.lines.push(PatchLine { + kind, + text: text.to_string(), + missing_newline: false, + }); + } + true + } +} + +/// `@@ -a[,b] +c[,d] @@[ section]`, without lines yet. +fn parse_hunk_header(line: &str) -> Option { + let rest = line.strip_prefix("@@ -")?; + let (ranges, section) = rest.split_once(" @@")?; + let (old, new) = ranges.split_once(" +")?; + let range = |range: &str| -> Option<(usize, usize)> { + match range.split_once(',') { + Some((start, count)) => Some((start.parse().ok()?, count.parse().ok()?)), + None => Some((range.parse().ok()?, 1)), + } + }; + let (old_start, old_lines) = range(old)?; + let (new_start, new_lines) = range(new)?; + Some(PatchHunk { + old_start, + old_lines, + new_start, + new_lines, + section: section.trim().to_string(), + lines: Vec::new(), + }) +} + +/// A file of [`parse_patch`] while its header and hunks are read. +#[derive(Default)] +struct FileBuilder { + /// `None` once a header names `/dev/null`. + old_path: Option, + new_path: Option, + added: bool, + deleted: bool, + renamed: bool, + copied: bool, + similarity: Option, + old_mode: Option, + new_mode: Option, + is_binary: bool, + /// Inside `GIT binary patch` data, which runs to the next file. + in_binary_data: bool, + hunks: HunkReader, +} + +impl FileBuilder { + fn from_git_header(paths: &str) -> Self { + let (old_path, new_path) = split_git_paths(paths).unwrap_or_default(); + Self { + old_path: Some(strip_prefix(old_path, "a/")), + new_path: Some(strip_prefix(new_path, "b/")), + ..Self::default() + } + } + + /// A line outside the hunks: an extended header, a file header, a hunk + /// header or binary data. + fn header_line(&mut self, line: &str) { + if self.in_binary_data || self.hunks.take(line) { + return; + } + let path = |rest: &str| unquote(rest).unwrap_or_else(|| rest.to_string()); + if let Some(rest) = line.strip_prefix("--- ") { + self.old_path = file_header_path(rest, "a/"); + self.added |= self.old_path.is_none(); + } else if let Some(rest) = line.strip_prefix("+++ ") { + self.new_path = file_header_path(rest, "b/"); + self.deleted |= self.new_path.is_none(); + } else if let Some(mode) = line.strip_prefix("new file mode ") { + self.added = true; + self.new_mode = Some(mode.to_string()); + } else if let Some(mode) = line.strip_prefix("deleted file mode ") { + self.deleted = true; + self.old_mode = Some(mode.to_string()); + } else if let Some(mode) = line.strip_prefix("old mode ") { + self.old_mode = Some(mode.to_string()); + } else if let Some(mode) = line.strip_prefix("new mode ") { + self.new_mode = Some(mode.to_string()); + } else if let Some(rest) = line.strip_prefix("rename from ") { + self.renamed = true; + self.old_path = Some(path(rest)); + } else if let Some(rest) = line.strip_prefix("rename to ") { + self.renamed = true; + self.new_path = Some(path(rest)); + } else if let Some(rest) = line.strip_prefix("copy from ") { + self.copied = true; + self.old_path = Some(path(rest)); + } else if let Some(rest) = line.strip_prefix("copy to ") { + self.copied = true; + self.new_path = Some(path(rest)); + } else if let Some(rest) = line.strip_prefix("similarity index ") { + self.similarity = rest + .strip_suffix('%') + .and_then(|n| n.parse().ok()) + .filter(|n| *n <= 100); + } else if let Some(rest) = line.strip_prefix("Binary files ") { + self.is_binary = true; + self.added |= rest.starts_with("/dev/null and "); + self.deleted |= rest.ends_with(" and /dev/null differ"); + } else if line == "GIT binary patch" { + self.is_binary = true; + self.in_binary_data = true; + } + } + + fn finish(self) -> FilePatch { + let status = if self.added { + ChangeStatus::Added + } else if self.deleted { + ChangeStatus::Deleted + } else if self.renamed { + ChangeStatus::Renamed + } else if self.copied { + ChangeStatus::Copied + } else { + ChangeStatus::Modified + }; + let (path, orig_path) = match status { + ChangeStatus::Deleted => (self.old_path.or(self.new_path), None), + ChangeStatus::Renamed | ChangeStatus::Copied => (self.new_path, self.old_path), + _ => (self.new_path.or(self.old_path), None), + }; + FilePatch { + path: path.unwrap_or_default(), + orig_path, + status, + similarity: self.similarity, + old_mode: self.old_mode, + new_mode: self.new_mode, + is_binary: self.is_binary, + hunks: self.hunks.hunks, + } + } +} + +/// The path of a `---`/`+++` line; `None` for `/dev/null`. Git ends the line +/// with a tab when the path has spaces; other tools put a timestamp there. +fn file_header_path(rest: &str, prefix: &str) -> Option { + let path = match unquote(rest) { + Some(path) => path, + None => rest.split('\t').next().unwrap_or(rest).to_string(), + }; + (path != "/dev/null").then(|| strip_prefix(path, prefix)) +} + +fn strip_prefix(path: String, prefix: &str) -> String { + match path.strip_prefix(prefix) { + Some(rest) => rest.to_string(), + None => path, + } +} + +/// The two paths of a `diff --git` line (still with their `a/`, `b/` +/// prefixes). Unquoted paths may contain spaces, which makes the split +/// ambiguous; the usual case of equal paths is split in the middle, else at +/// the first ` b/`. Later headers name the paths unambiguously when the file +/// has them. +fn split_git_paths(paths: &str) -> Option<(String, String)> { + if paths.starts_with('"') { + let end = quoted_len(paths)?; + let old = unquote(&paths[..end])?; + let rest = paths[end..].strip_prefix(' ')?; + let new = unquote(rest).unwrap_or_else(|| rest.to_string()); + return Some((old, new)); + } + if let Some(ix) = paths.find(" \"") { + return Some((paths[..ix].to_string(), unquote(&paths[ix + 1..])?)); + } + let mid = paths.len() / 2; + if paths.len() % 2 == 1 + && paths.is_char_boundary(mid) + && paths.as_bytes()[mid] == b' ' + && paths[..mid].strip_prefix("a/") == paths[mid + 1..].strip_prefix("b/") + { + return Some((paths[..mid].to_string(), paths[mid + 1..].to_string())); + } + let (old, new) = paths.split_once(" b/")?; + Some((old.to_string(), format!("b/{new}"))) +} + +/// Byte length of the C-quoted string `text` starts with, quotes included. +fn quoted_len(text: &str) -> Option { + let bytes = text.as_bytes(); + let mut ix = 1; + while ix < bytes.len() { + match bytes[ix] { + b'\\' => ix += 2, + b'"' => return Some(ix + 1), + _ => ix += 1, + } + } + None +} + +/// Decode a path git quoted C-style (`"d\303\244t\ta"`): escapes and octal +/// bytes, which together form UTF-8. `None` unless `text` is exactly one +/// quoted string. +fn unquote(text: &str) -> Option { + let inner = text.strip_prefix('"')?; + if quoted_len(text)? != text.len() { + return None; + } + let inner = &inner.as_bytes()[..inner.len() - 1]; + let mut bytes = Vec::with_capacity(inner.len()); + let mut ix = 0; + while ix < inner.len() { + let byte = inner[ix]; + ix += 1; + if byte != b'\\' { + bytes.push(byte); + continue; + } + let escaped = *inner.get(ix)?; + ix += 1; + bytes.push(match escaped { + b'a' => 0x07, + b'b' => 0x08, + b't' => b'\t', + b'n' => b'\n', + b'v' => 0x0b, + b'f' => 0x0c, + b'r' => b'\r', + b'0'..=b'7' => { + let digits = inner.get(ix - 1..ix + 2)?; + ix += 2; + u8::from_str_radix(std::str::from_utf8(digits).ok()?, 8).ok()? + } + other => other, + }); + } + Some(String::from_utf8_lossy(&bytes).into_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + use PatchLineKind::*; + + fn kinds(hunk: &PatchHunk) -> Vec { + hunk.lines.iter().map(|l| l.kind).collect() + } + + fn texts(hunk: &PatchHunk) -> Vec<&str> { + hunk.lines.iter().map(|l| l.text.as_str()).collect() + } + + #[test] + fn hunk_header_numbers_and_section() { + let hunks = + parse_hunks("@@ -10,4 +10,5 @@ fn main() {\n ctx\n-old\n+new\n+more\n ctx2\n tail\n"); + assert_eq!(hunks.len(), 1); + let hunk = &hunks[0]; + assert_eq!( + ( + hunk.old_start, + hunk.old_lines, + hunk.new_start, + hunk.new_lines + ), + (10, 4, 10, 5) + ); + assert_eq!(hunk.section, "fn main() {"); + assert_eq!( + kinds(hunk), + [Context, Removed, Added, Added, Context, Context] + ); + assert_eq!(texts(hunk), ["ctx", "old", "new", "more", "ctx2", "tail"]); + } + + #[test] + fn omitted_counts_mean_one_and_no_section_is_empty() { + let hunks = parse_hunks("@@ -3 +3 @@\n-a\n+b\n"); + assert_eq!( + ( + hunks[0].old_start, + hunks[0].old_lines, + hunks[0].new_start, + hunks[0].new_lines + ), + (3, 1, 3, 1) + ); + assert_eq!(hunks[0].section, ""); + } + + #[test] + fn several_hunks() { + let hunks = parse_hunks("@@ -1,2 +1,2 @@\n-a\n+A\n b\n@@ -20,1 +20,2 @@ impl X\n c\n+d\n"); + assert_eq!(hunks.len(), 2); + assert_eq!(hunks[1].new_start, 20); + assert_eq!(hunks[1].section, "impl X"); + assert_eq!(kinds(&hunks[1]), [Context, Added]); + } + + #[test] + fn lines_that_look_like_headers_inside_a_hunk_are_lines() { + // A removed "-- x" and an added "++ y" print as "--- x" / "+++ y". + let hunks = parse_hunks("@@ -1,2 +1,2 @@\n--- x\n+++ y\n context\n"); + assert_eq!(kinds(&hunks[0]), [Removed, Added, Context]); + assert_eq!(texts(&hunks[0]), ["-- x", "++ y", "context"]); + } + + #[test] + fn no_newline_marker_flags_the_line_before() { + let hunks = parse_hunks( + "@@ -1 +1 @@\n-old\n\\ No newline at end of file\n+new\n\\ No newline at end of file\n", + ); + let lines = &hunks[0].lines; + assert_eq!(lines.len(), 2); + assert!(lines.iter().all(|l| l.missing_newline)); + let hunks = parse_hunks("@@ -1,2 +1,2 @@\n a\n-b\n+c\n"); + assert!(hunks[0].lines.iter().all(|l| !l.missing_newline)); + } + + #[test] + fn crlf_line_endings_are_stripped() { + let hunks = parse_hunks("@@ -1,2 +1,2 @@ sec\r\n a\r\n-b\r\n+c\r\n"); + assert_eq!(hunks[0].section, "sec"); + assert_eq!(texts(&hunks[0]), ["a", "b", "c"]); + } + + #[test] + fn empty_lines_inside_a_hunk_are_empty_context() { + // Some tools strip the single space of an empty context line. + let hunks = parse_hunks("@@ -1,3 +1,3 @@\n a\n\n-b\n+c\n"); + assert_eq!(kinds(&hunks[0]), [Context, Context, Removed, Added]); + assert_eq!(texts(&hunks[0])[1], ""); + } + + #[test] + fn malformed_input_is_skipped() { + assert!(parse_hunks("").is_empty()); + assert!(parse_hunks("not a patch\n+x\n").is_empty()); + // A broken header drops its lines; the next good hunk still parses. + let hunks = parse_hunks("@@ -x,1 +1 @@\n+lost\n@@ -5 +5,2 @@\n a\n+b\n"); + assert_eq!(hunks.len(), 1); + assert_eq!(hunks[0].old_start, 5); + // Lines beyond the header's counts do not belong to the hunk. + let hunks = parse_hunks("@@ -1 +1 @@\n-a\n+b\n+extra\n"); + assert_eq!(texts(&hunks[0]), ["a", "b"]); + // A truncated hunk keeps what is there; unknown prefixes end it. + let hunks = parse_hunks("@@ -1,5 +1,5 @@\n a\n*junk\n b\n"); + assert_eq!(texts(&hunks[0]), ["a"]); + // Huge numbers do not overflow. + assert!(parse_hunks("@@ -99999999999999999999999 +1 @@\n+a\n").is_empty()); + } + + #[test] + fn text_before_the_first_header_is_ignored() { + let hunks = parse_hunks("index 123..456\n--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b\n"); + assert_eq!(hunks.len(), 1); + assert_eq!(kinds(&hunks[0]), [Removed, Added]); + } + + const MULTI: &str = "\ +diff --git a/src/lib.rs b/src/lib.rs +index 83db48f..bf269f4 100644 +--- a/src/lib.rs ++++ b/src/lib.rs +@@ -1,3 +1,3 @@ mod a; + a +-b ++B + c +diff --git a/new.txt b/new.txt +new file mode 100644 +index 0000000..e69de29 +--- /dev/null ++++ b/new.txt +@@ -0,0 +1,2 @@ ++one ++two +diff --git a/gone.txt b/gone.txt +deleted file mode 100755 +index e69de29..0000000 +--- a/gone.txt ++++ /dev/null +@@ -1 +0,0 @@ +-bye +"; + + #[test] + fn multi_file_patch_with_statuses() { + let files = parse_patch(MULTI); + assert_eq!(files.len(), 3); + + assert_eq!(files[0].path, "src/lib.rs"); + assert_eq!(files[0].status, ChangeStatus::Modified); + assert_eq!(files[0].orig_path, None); + assert_eq!(files[0].hunks.len(), 1); + assert_eq!(files[0].hunks[0].section, "mod a;"); + assert_eq!( + kinds(&files[0].hunks[0]), + [Context, Removed, Added, Context] + ); + + assert_eq!(files[1].path, "new.txt"); + assert_eq!(files[1].status, ChangeStatus::Added); + assert_eq!(files[1].new_mode.as_deref(), Some("100644")); + assert_eq!(files[1].hunks[0].old_start, 0); + assert_eq!(texts(&files[1].hunks[0]), ["one", "two"]); + + assert_eq!(files[2].path, "gone.txt"); + assert_eq!(files[2].status, ChangeStatus::Deleted); + assert_eq!(files[2].old_mode.as_deref(), Some("100755")); + assert_eq!(kinds(&files[2].hunks[0]), [Removed]); + assert!(files.iter().all(|f| !f.is_binary)); + } + + #[test] + fn renames_and_copies() { + let files = parse_patch( + "diff --git a/old/name.rs b/new/name.rs\n\ + similarity index 92%\n\ + rename from old/name.rs\n\ + rename to new/name.rs\n\ + index 1..2 100644\n\ + --- a/old/name.rs\n\ + +++ b/new/name.rs\n\ + @@ -1 +1 @@\n\ + -a\n\ + +b\n\ + diff --git a/x.rs b/y.rs\n\ + similarity index 100%\n\ + rename from x.rs\n\ + rename to y.rs\n\ + diff --git a/c.rs b/d.rs\n\ + similarity index 80%\n\ + copy from c.rs\n\ + copy to d.rs\n", + ); + assert_eq!(files.len(), 3); + assert_eq!(files[0].path, "new/name.rs"); + assert_eq!(files[0].orig_path.as_deref(), Some("old/name.rs")); + assert_eq!(files[0].status, ChangeStatus::Renamed); + assert_eq!(files[0].similarity, Some(92)); + assert_eq!(files[0].hunks.len(), 1); + // A pure rename has no hunks. + assert_eq!(files[1].path, "y.rs"); + assert_eq!(files[1].orig_path.as_deref(), Some("x.rs")); + assert_eq!(files[1].status, ChangeStatus::Renamed); + assert_eq!(files[1].similarity, Some(100)); + assert!(files[1].hunks.is_empty()); + assert_eq!(files[2].status, ChangeStatus::Copied); + assert_eq!(files[2].orig_path.as_deref(), Some("c.rs")); + assert_eq!(files[2].path, "d.rs"); + } + + #[test] + fn binary_files_and_mode_changes() { + let files = parse_patch( + "diff --git a/img.png b/img.png\n\ + new file mode 100644\n\ + index 0000000..1234567\n\ + Binary files /dev/null and b/img.png differ\n\ + diff --git a/blob.bin b/blob.bin\n\ + index 1..2 100644\n\ + GIT binary patch\n\ + literal 12\n\ + TcmZ?wbhEHbRA*pfU|;|M2XO&j\n\ + \n\ + literal 0\n\ + HcmV?d00001\n\ + \n\ + diff --git a/run.sh b/run.sh\n\ + old mode 100644\n\ + new mode 100755\n", + ); + assert_eq!(files.len(), 3); + assert!(files[0].is_binary); + assert_eq!(files[0].status, ChangeStatus::Added); + assert!(files[0].hunks.is_empty()); + assert!(files[1].is_binary); + assert_eq!(files[1].status, ChangeStatus::Modified); + assert!(files[1].hunks.is_empty()); + assert_eq!(files[2].path, "run.sh"); + assert_eq!(files[2].status, ChangeStatus::Modified); + assert_eq!(files[2].old_mode.as_deref(), Some("100644")); + assert_eq!(files[2].new_mode.as_deref(), Some("100755")); + assert!(!files[2].is_binary); + } + + #[test] + fn binary_deletion_without_mode_lines() { + let files = parse_patch( + "diff --git a/img.png b/img.png\nBinary files a/img.png and /dev/null differ\n", + ); + assert_eq!(files[0].status, ChangeStatus::Deleted); + assert!(files[0].is_binary); + } + + #[test] + fn quoted_and_spaced_paths() { + let files = parse_patch( + "diff --git \"a/d\\303\\244t a.txt\" \"b/d\\303\\244t a.txt\"\n\ + index 1..2 100644\n\ + --- \"a/d\\303\\244t a.txt\"\n\ + +++ \"b/d\\303\\244t a.txt\"\n\ + @@ -1 +1 @@\n\ + -a\n\ + +b\n\ + diff --git a/with space.txt b/with space.txt\n\ + index 1..2 100644\n\ + --- a/with space.txt\t\n\ + +++ b/with space.txt\t\n\ + @@ -1 +1 @@\n\ + -a\n\ + +b\n\ + diff --git a/tab\\there b/tab\\there\n\ + similarity index 100%\n\ + rename from \"tab\\there\"\n\ + rename to \"tab\\\"quoted\\\"\"\n", + ); + assert_eq!(files.len(), 3); + assert_eq!(files[0].path, "dät a.txt"); + assert_eq!(files[1].path, "with space.txt"); + assert_eq!(files[2].orig_path.as_deref(), Some("tab\there")); + assert_eq!(files[2].path, "tab\"quoted\""); + } + + #[test] + fn spaced_paths_without_file_headers_split_at_the_middle() { + let files = parse_patch("diff --git a/a b/c b/a b/c\nold mode 100644\nnew mode 100755\n"); + assert_eq!(files[0].path, "a b/c"); + } + + #[test] + fn crlf_patch() { + let files = parse_patch(&MULTI.replace('\n', "\r\n")); + assert_eq!(files.len(), 3); + assert_eq!(files[0].path, "src/lib.rs"); + assert_eq!(texts(&files[0].hunks[0]), ["a", "b", "B", "c"]); + } + + #[test] + fn plain_unified_diff_without_git_headers() { + let files = parse_patch( + "--- a/one.txt\n+++ b/one.txt\n@@ -1 +1 @@\n-a\n+b\n\ + --- a/two.txt\n+++ b/two.txt\n@@ -1 +1 @@\n-c\n+d\n", + ); + assert_eq!(files.len(), 2); + assert_eq!(files[0].path, "one.txt"); + assert_eq!(files[1].path, "two.txt"); + assert_eq!(texts(&files[1].hunks[0]), ["c", "d"]); + } + + #[test] + fn garbage_never_panics() { + assert!(parse_patch("").is_empty()); + assert!(parse_patch("hello\nworld\n").is_empty()); + for text in [ + "diff --git\n", + "diff --git \"a/unterminated\n", + "diff --git \"a/\\\n", + "diff --git a/x b/x\n@@\n@@ -\n@@ -1, +1 @@\n+x\n", + "diff --git a/x b/x\nsimilarity index 999%\nrename from\n", + "diff --git a/x b/x\n--- \n+++ \n\\\n", + "\\ No newline at end of file\n", + ] { + let _ = parse_patch(text); + let _ = parse_hunks(text); + } + let files = parse_patch("diff --git a/x b/x\nsimilarity index 999%\n"); + assert_eq!(files[0].similarity, None); + } +} diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 06f10206b..9b940d260 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -1,5 +1,5 @@ use crate::binary::GitBinary; -use crate::types::{RepoInfo, Worktree}; +use crate::types::{Remote, RepoInfo, Worktree}; use anyhow::{Context, Result}; use gix::ThreadSafeRepository; use std::path::{Path, PathBuf}; @@ -74,6 +74,23 @@ impl GitRepository { }) } + /// The configured remotes in name order, each with the URL it fetches + /// from (after `insteadOf` rewrites). Remotes without one are left out. + pub fn remotes(&self) -> Vec { + let repo = self.repo.to_thread_local(); + repo.remote_names() + .iter() + .filter_map(|name| { + let remote = repo.find_remote(name.as_ref()).ok()?; + let url = remote.url(gix::remote::Direction::Fetch)?; + Some(Remote { + name: name.to_string(), + url: url.to_bstring().to_string(), + }) + }) + .collect() + } + /// Return the working directory path. pub fn workdir(&self) -> &Path { &self.workdir @@ -162,4 +179,35 @@ mod tests { assert!(info.head_branch.is_some()); assert!(!info.has_origin); } + + #[test] + fn lists_remotes_in_name_order_with_their_fetch_urls() { + let dir = TempDir::new().unwrap(); + init_repo(dir.path()); + for (name, url) in [ + ("upstream", "git@github.com:team/tool.git"), + ("origin", "https://github.com/me/tool.git"), + ] { + let status = std::process::Command::new("git") + .args(["remote", "add", name, url]) + .current_dir(dir.path()) + .status() + .unwrap(); + assert!(status.success()); + } + + let remotes = GitRepository::open(dir.path()).unwrap().remotes(); + + let listed: Vec<_> = remotes + .iter() + .map(|r| (r.name.as_str(), r.url.as_str())) + .collect(); + assert_eq!( + listed, + [ + ("origin", "https://github.com/me/tool.git"), + ("upstream", "git@github.com:team/tool.git"), + ] + ); + } } diff --git a/crates/git/src/types.rs b/crates/git/src/types.rs index bbfa2bfbb..10908d3bb 100644 --- a/crates/git/src/types.rs +++ b/crates/git/src/types.rs @@ -35,6 +35,13 @@ impl Worktree { } } +/// A configured remote and the URL it fetches from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Remote { + pub name: String, + pub url: String, +} + /// Summary information about a git repository. #[derive(Debug, Clone)] pub struct RepoInfo { diff --git a/crates/github/Cargo.toml b/crates/github/Cargo.toml new file mode 100644 index 000000000..2de633c61 --- /dev/null +++ b/crates/github/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "github" +version = "0.1.0" +edition = "2024" +license = "MIT" + +[features] +## The scripted `FakeTransport` and `StaticTokenSource` users of the client +## test against. +test-utils = [] + +[dependencies] +async-trait = "0.1" +chrono = { version = "0.4", features = ["serde"] } +futures = "0.3" +lru = "0.19" +reqwest = { version = "0.13", default-features = false, features = [ + "rustls", + "charset", + "http2", + "system-proxy", +] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "2.0" +tokio = { version = "1.48", features = ["process", "sync", "time"] } +tracing = "0.1" +urlencoding = "2.1" + +[dev-dependencies] +github = { path = ".", features = ["test-utils"] } +tokio = { version = "1.48", features = ["full", "test-util"] } diff --git a/crates/github/src/auth.rs b/crates/github/src/auth.rs new file mode 100644 index 000000000..9d2de11f2 --- /dev/null +++ b/crates/github/src/auth.rs @@ -0,0 +1,246 @@ +//! Where tokens come from. `GH_TOKEN` / `GITHUB_TOKEN` when set, else the +//! GitHub CLI's login (`gh auth token`). Tokens are cached for five minutes +//! and never logged. + +use crate::error::{Error, Result}; +use async_trait::async_trait; +use std::collections::HashMap; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::Mutex; +use tokio::time::Instant; + +const TOKEN_TTL: Duration = Duration::from_secs(5 * 60); +/// A missing login is asked again soon, but not for every request. +const MISSING_TTL: Duration = Duration::from_secs(10); +const GH_TIMEOUT: Duration = Duration::from_secs(10); + +/// Supplies the token for a host. +#[async_trait] +pub trait TokenSource: Send + Sync { + async fn token(&self, host: &str) -> Result; +} + +/// The environment, then `gh auth token --hostname `. +#[derive(Debug, Default)] +pub struct GhTokenSource; + +#[async_trait] +impl TokenSource for GhTokenSource { + async fn token(&self, host: &str) -> Result { + let env: HashMap = std::env::vars().collect(); + if let Some(token) = environment_token(host, &env) { + return Ok(token); + } + gh_auth_token(host).await + } +} + +/// One fixed token for every host. +pub struct StaticTokenSource(pub String); + +#[async_trait] +impl TokenSource for StaticTokenSource { + async fn token(&self, _host: &str) -> Result { + Ok(self.0.clone()) + } +} + +/// `GH_TOKEN` / `GITHUB_TOKEN` belong to github.com (and GHE.com tenancies); +/// `GH_ENTERPRISE_TOKEN` only to the server `GH_HOST` names, so a remote URL +/// pointing elsewhere cannot draw it out. +pub(crate) fn environment_token(host: &str, env: &HashMap) -> Option { + let names: &[&str] = if host == "github.com" || host.ends_with(".ghe.com") { + &["GH_TOKEN", "GITHUB_TOKEN"] + } else if env + .get("GH_HOST") + .is_some_and(|gh_host| gh_host.trim().eq_ignore_ascii_case(host)) + { + &["GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN"] + } else { + &[] + }; + names + .iter() + .filter_map(|name| env.get(*name)) + .map(|value| value.trim()) + .find(|value| !value.is_empty()) + .map(str::to_string) +} + +async fn gh_auth_token(host: &str) -> Result { + let no_credential = |detail: &str| Error::NoCredential { + host: host.to_string(), + detail: detail.to_string(), + }; + let child = tokio::process::Command::new("gh") + .args(["auth", "token", "--hostname", host]) + // Never let gh print the token into a debug log or prompt. + .env("GH_DEBUG", "") + .env("GH_PROMPT_DISABLED", "1") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + .map_err(|e| match e.kind() { + std::io::ErrorKind::NotFound => { + no_credential("set GH_TOKEN, or install the GitHub CLI and run `gh auth login`") + } + _ => no_credential(&format!("could not run the GitHub CLI: {e}")), + })?; + let output = tokio::time::timeout(GH_TIMEOUT, child.wait_with_output()) + .await + .map_err(|_| no_credential("`gh auth token` did not answer within 10 s"))? + .map_err(|e| no_credential(&format!("could not run the GitHub CLI: {e}")))?; + let token = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !output.status.success() || token.is_empty() { + return Err(no_credential(&format!( + "run `gh auth login --hostname {host}`" + ))); + } + Ok(token) +} + +struct Held { + token: Result, + until: Instant, +} + +/// Remembers each host's token, so `gh` runs once per five minutes rather +/// than once per request. +pub(crate) struct TokenCache { + source: Arc, + held: Mutex>, +} + +impl TokenCache { + pub fn new(source: Arc) -> Self { + Self { + source, + held: Mutex::new(HashMap::new()), + } + } + + pub async fn get(&self, host: &str) -> Result { + // Held across the lookup: concurrent requests wait for one `gh` run. + let mut held = self.held.lock().await; + let now = Instant::now(); + if let Some(entry) = held.get(host) + && entry.until > now + { + return entry.token.clone(); + } + let token = self.source.token(host).await; + let ttl = if token.is_ok() { + TOKEN_TTL + } else { + MISSING_TTL + }; + held.insert( + host.to_string(), + Held { + token: token.clone(), + until: now + ttl, + }, + ); + token + } + + /// Forgets a token GitHub refused; the source may hold a newer one. + pub async fn invalidate(&self, host: &str) { + self.held.lock().await.remove(host); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct Counting { + calls: AtomicUsize, + fail: bool, + } + + #[async_trait] + impl TokenSource for Counting { + async fn token(&self, host: &str) -> Result { + let n = self.calls.fetch_add(1, Ordering::SeqCst) + 1; + if self.fail { + Err(Error::NoCredential { + host: host.into(), + detail: "none".into(), + }) + } else { + Ok(format!("token-{n}")) + } + } + } + + fn cache(fail: bool) -> (Arc, TokenCache) { + let source = Arc::new(Counting { + calls: AtomicUsize::new(0), + fail, + }); + (source.clone(), TokenCache::new(source)) + } + + #[tokio::test(start_paused = true)] + async fn keeps_a_token_for_five_minutes_per_host() { + let (source, cache) = cache(false); + assert_eq!(cache.get("github.com").await.unwrap(), "token-1"); + tokio::time::advance(Duration::from_secs(299)).await; + assert_eq!(cache.get("github.com").await.unwrap(), "token-1"); + assert_eq!(cache.get("ghe.example").await.unwrap(), "token-2"); + tokio::time::advance(Duration::from_secs(2)).await; + assert_eq!(cache.get("github.com").await.unwrap(), "token-3"); + assert_eq!(source.calls.load(Ordering::SeqCst), 3); + } + + #[tokio::test(start_paused = true)] + async fn asks_again_after_invalidation() { + let (_, cache) = cache(false); + assert_eq!(cache.get("github.com").await.unwrap(), "token-1"); + cache.invalidate("github.com").await; + assert_eq!(cache.get("github.com").await.unwrap(), "token-2"); + } + + #[tokio::test(start_paused = true)] + async fn remembers_a_missing_login_only_briefly() { + let (source, cache) = cache(true); + assert!(cache.get("github.com").await.is_err()); + assert!(cache.get("github.com").await.is_err()); + assert_eq!(source.calls.load(Ordering::SeqCst), 1); + tokio::time::advance(MISSING_TTL).await; + assert!(cache.get("github.com").await.is_err()); + assert_eq!(source.calls.load(Ordering::SeqCst), 2); + } + + #[test] + fn environment_tokens_stay_with_their_hosts() { + let env: HashMap = [ + ("GH_TOKEN", " "), + ("GITHUB_TOKEN", "dotcom"), + ("GH_ENTERPRISE_TOKEN", "enterprise"), + ("GH_HOST", "ghe.corp"), + ] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + assert_eq!( + environment_token("github.com", &env).as_deref(), + Some("dotcom") + ); + assert_eq!( + environment_token("acme.ghe.com", &env).as_deref(), + Some("dotcom") + ); + assert_eq!( + environment_token("ghe.corp", &env).as_deref(), + Some("enterprise") + ); + assert_eq!(environment_token("other.corp", &env), None); + } +} diff --git a/crates/github/src/client/cache.rs b/crates/github/src/client/cache.rs new file mode 100644 index 000000000..65f0ef118 --- /dev/null +++ b/crates/github/src/client/cache.rs @@ -0,0 +1,97 @@ +//! The client's small caches: validators of REST reads and pull request +//! node ids. + +use lru::LruCache; +use std::num::NonZeroUsize; +use std::sync::Mutex; +use std::time::Duration; +use tokio::time::Instant; + +use crate::transport::Headers; + +const ETAG_CAPACITY: usize = 512; +const ETAG_TTL: Duration = Duration::from_secs(60 * 60); +const NODE_ID_CAPACITY: usize = 128; + +/// The last answer to a REST read and its validator. +#[derive(Debug, Clone)] +pub(crate) struct Validated { + pub etag: String, + pub headers: Headers, + pub body: Vec, + stored: Instant, +} + +/// Answers by credential, host and path, so an unchanged read comes back as +/// a 304, which GitHub does not charge. A 304 renews the entry, so a read +/// still being polled outlives the hour. +pub(crate) struct EtagCache { + entries: Mutex>, +} + +impl EtagCache { + pub fn new() -> Self { + Self { + entries: Mutex::new(LruCache::new(NonZeroUsize::new(ETAG_CAPACITY).unwrap())), + } + } + + pub fn get(&self, key: &str) -> Option { + let mut entries = self.entries.lock().unwrap(); + match entries.get(key) { + Some(entry) if entry.stored.elapsed() < ETAG_TTL => Some(entry.clone()), + Some(_) => { + entries.pop(key); + None + } + None => None, + } + } + + pub fn renew(&self, key: &str) { + if let Some(entry) = self.entries.lock().unwrap().get_mut(key) { + entry.stored = Instant::now(); + } + } + + pub fn store(&self, key: String, etag: Option<&str>, headers: &Headers, body: &[u8]) { + let mut entries = self.entries.lock().unwrap(); + match etag.map(str::trim).filter(|e| !e.is_empty()) { + Some(etag) => { + entries.put( + key, + Validated { + etag: etag.to_string(), + headers: headers.clone(), + body: body.to_vec(), + stored: Instant::now(), + }, + ); + } + None => { + entries.pop(&key); + } + } + } +} + +/// Pull request node ids never change, so mutations look each up once. +pub(crate) struct NodeIdCache { + ids: Mutex>, +} + +impl NodeIdCache { + pub fn new() -> Self { + Self { + ids: Mutex::new(LruCache::new(NonZeroUsize::new(NODE_ID_CAPACITY).unwrap())), + } + } + + pub fn get(&self, key: &str) -> Option { + self.ids.lock().unwrap().get(key).cloned() + } + + pub fn put(&self, key: String, id: String) { + self.ids.lock().unwrap().put(key, id); + } +} diff --git a/crates/github/src/client/http.rs b/crates/github/src/client/http.rs new file mode 100644 index 000000000..f23e069a0 --- /dev/null +++ b/crates/github/src/client/http.rs @@ -0,0 +1,512 @@ +//! Sending one request: token, slot, quota and pause checks, the deadline, +//! reading the answer, and turning refusals into errors. + +use super::{Client, Priority}; +use crate::error::{Error, GraphQlError, Result}; +use crate::graphql::{self, Envelope, alias}; +use crate::limits::{self, is_rate_limited, now_ms, system_time}; +use crate::repo::{api_urls, is_github_host}; +use crate::transport::{Headers, Method, Request, Response}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::time::Duration; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); +const DEFAULT_MAX_BODY_BYTES: usize = 8 * 1024 * 1024; +const API_VERSION: &str = "2022-11-28"; +const USER_AGENT: &str = "code-assistant"; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Api { + Rest, + GraphQl, +} + +impl Api { + /// The quota a request spends. + fn resource(self) -> &'static str { + match self { + Api::Rest => "core", + Api::GraphQl => "graphql", + } + } +} + +struct Call<'a> { + host: &'a str, + operation: &'static str, + api: Api, + method: Method, + url: String, + headers: Headers, + body: Option>, + timeout: Duration, + max_body_bytes: usize, + allow_reserve: Option, +} + +/// A REST request relative to the API root (`repos/o/n/pulls/1?x=y`). +pub(crate) struct RestCall<'a> { + host: &'a str, + operation: &'static str, + method: Method, + path: String, + body: Option, + accept: &'static str, + if_none_match: Option, + timeout: Duration, + max_body_bytes: usize, + allow_reserve: Option, +} + +impl<'a> RestCall<'a> { + pub fn new(host: &'a str, operation: &'static str, method: Method, path: String) -> Self { + Self { + host, + operation, + method, + path, + body: None, + accept: "application/vnd.github+json", + if_none_match: None, + timeout: DEFAULT_TIMEOUT, + max_body_bytes: DEFAULT_MAX_BODY_BYTES, + allow_reserve: None, + } + } + + pub fn get(host: &'a str, operation: &'static str, path: String) -> Self { + Self::new(host, operation, Method::Get, path) + } + + pub fn json(mut self, body: Value) -> Self { + self.body = Some(body); + self + } + + pub fn accept(mut self, accept: &'static str) -> Self { + self.accept = accept; + self + } + + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + pub fn max_body_bytes(mut self, max: usize) -> Self { + self.max_body_bytes = max; + self + } + + pub fn allow_reserve(mut self) -> Self { + self.allow_reserve = Some(true); + self + } +} + +/// A REST read through the ETag cache. +pub(crate) struct Revalidated { + pub headers: Headers, + pub body: Vec, + /// False when GitHub answered 304: the body is the stored one. + pub changed: bool, +} + +/// The messages of a REST error body: `message` and `errors[]`. +fn rest_error_messages(body: &[u8]) -> Vec { + #[derive(Deserialize)] + #[serde(untagged)] + enum Detail { + Text(String), + Fields { + message: Option, + field: Option, + code: Option, + }, + } + #[derive(Deserialize)] + struct Body { + message: Option, + #[serde(default)] + errors: Vec, + } + let Ok(body) = serde_json::from_slice::(body) else { + return Vec::new(); + }; + body.message + .into_iter() + .chain(body.errors.into_iter().filter_map(|detail| match detail { + Detail::Text(text) => Some(text), + Detail::Fields { + message: Some(message), + .. + } => Some(message), + Detail::Fields { + field: Some(field), + code: Some(code), + .. + } => Some(format!("{field} {code}")), + Detail::Fields { .. } => None, + })) + .collect() +} + +fn graphql_failure(operation: &'static str, errors: Vec) -> Error { + if errors.iter().all(|e| e.is_kind("NOT_FOUND")) { + Error::NotFound { operation } + } else { + Error::GraphQl { operation, errors } + } +} + +impl Client { + async fn execute(&self, call: Call<'_>) -> Result<(Response, Option)> { + let host = call.host; + if !is_github_host(host) { + return Err(Error::Invalid(format!( + "'{host}' is not a GitHub host name" + ))); + } + let allow_reserve = call + .allow_reserve + .unwrap_or(self.priority == Priority::Interactive); + let resource = call.api.resource(); + let inner = &self.inner; + // A refused token is dropped and asked for again, once. + for attempt in 0..2 { + let token = inner.tokens.get(host).await?; + let permit = inner.slots.acquire().await.expect("slots are never closed"); + let lease = { + let now = now_ms(); + let limits = inner.limits.lock().unwrap(); + let paused = |retry_at: u64| Error::Paused { + host: host.to_string(), + retry_at: system_time(retry_at), + }; + limits + .quota + .admit(host, resource, allow_reserve, now) + .map_err(paused)?; + limits + .pauses + .check(host, now, allow_reserve) + .map_err(paused)? + }; + let mut headers = call.headers.clone(); + headers.insert("authorization", format!("Bearer {token}")); + headers.insert("x-github-api-version", API_VERSION); + headers.insert("user-agent", USER_AGENT); + let request = Request { + method: call.method, + url: call.url.clone(), + headers, + body: call.body.clone(), + max_body_bytes: call.max_body_bytes, + }; + let failed = |detail: String| Error::Request { + host: host.to_string(), + operation: call.operation, + detail, + }; + // One deadline for headers and body: a host that stalls mid-body + // must not hold a slot. + let response = + match tokio::time::timeout(call.timeout, inner.transport.send(request)).await { + Err(_) => { + return Err(failed(format!( + "no answer within {} s", + call.timeout.as_secs() + ))); + } + Ok(Err(e)) => return Err(failed(e.0)), + Ok(Ok(response)) => response, + }; + drop(permit); + + let envelope = if call.api == Api::GraphQl + && (200..300).contains(&response.status) + && !response.truncated + { + Some( + serde_json::from_slice::(&response.body) + .map_err(|e| Error::decode(call.operation, e))?, + ) + } else { + None + }; + + { + let now = now_ms(); + let mut limits = inner.limits.lock().unwrap(); + limits.quota.observe(host, &response.headers); + let body = if matches!(response.status, 403 | 429) { + response.text() + } else { + String::new() + }; + let errors = envelope.as_ref().map(|e| e.errors.as_slice()); + if is_rate_limited(response.status, &response.headers, errors, &body) { + let retry_at = limits::retry_at(&response.headers, now); + limits.pauses.record_rate_limit(host, lease, retry_at, now); + return Err(Error::RateLimited { + host: host.to_string(), + retry_at: retry_at.map(system_time), + }); + } + if response.status != 401 { + limits.pauses.record_success(host, lease, now); + } + } + + if response.status == 401 { + inner.tokens.invalidate(host).await; + if attempt == 0 { + continue; + } + return Err(Error::Unauthorized { + host: host.to_string(), + }); + } + return Ok((response, envelope)); + } + unreachable!("the loop returns on its second attempt") + } + + /// A REST request. 2xx (and a 304 when revalidating) is an answer; a + /// 404 is `NotFound`; anything else an `Api` error with GitHub's messages. + pub(crate) async fn rest(&self, call: RestCall<'_>) -> Result { + self.rest_with(call, None).await + } + + async fn rest_with( + &self, + call: RestCall<'_>, + if_none_match: Option, + ) -> Result { + let operation = call.operation; + let revalidating = if_none_match.is_some() || call.if_none_match.is_some(); + let mut headers = Headers::new().with("accept", call.accept); + if let Some(etag) = if_none_match.or(call.if_none_match) { + headers.insert("if-none-match", etag); + } + let body = call.body.map(|body| { + headers.insert("content-type", "application/json"); + serde_json::to_vec(&body).expect("JSON values serialize") + }); + let (response, _) = self + .execute(Call { + host: call.host, + operation, + api: Api::Rest, + method: call.method, + url: format!( + "{}/{}", + api_urls(call.host).rest, + call.path.trim_start_matches('/') + ), + headers, + body, + timeout: call.timeout, + max_body_bytes: call.max_body_bytes, + allow_reserve: call.allow_reserve, + }) + .await?; + match response.status { + 200..=299 => Ok(response), + 304 if revalidating => Ok(response), + 404 => Err(Error::NotFound { operation }), + status => Err(Error::Api { + operation, + status, + messages: rest_error_messages(&response.body), + }), + } + } + + /// A REST GET answered from the ETag cache when GitHub says nothing changed. + pub(crate) async fn rest_revalidated(&self, call: RestCall<'_>) -> Result { + let token = self.inner.tokens.get(call.host).await?; + let mut hasher = DefaultHasher::new(); + token.hash(&mut hasher); + // Keyed by credential, so one account never revalidates another's answer. + let key = format!("{:x}\0{}\0{}", hasher.finish(), call.host, call.path); + let previous = self.inner.etags.get(&key); + let response = self + .rest_with(call, previous.as_ref().map(|p| p.etag.clone())) + .await?; + if response.status == 304 + && let Some(previous) = previous + { + self.inner.etags.renew(&key); + return Ok(Revalidated { + headers: previous.headers, + body: previous.body, + changed: false, + }); + } + if !response.truncated { + self.inner.etags.store( + key, + response.headers.get("etag"), + &response.headers, + &response.body, + ); + } + Ok(Revalidated { + headers: response.headers, + body: response.body, + changed: true, + }) + } + + async fn graphql_exchange( + &self, + host: &str, + operation: &'static str, + query: &str, + variables: Value, + allow_reserve: Option, + ) -> Result { + let body = json!({ "query": query, "variables": variables, "operationName": operation }); + let (response, envelope) = self + .execute(Call { + host, + operation, + api: Api::GraphQl, + method: Method::Post, + url: api_urls(host).graphql, + headers: Headers::new().with("content-type", "application/json"), + body: Some(serde_json::to_vec(&body).expect("JSON values serialize")), + timeout: DEFAULT_TIMEOUT, + max_body_bytes: DEFAULT_MAX_BODY_BYTES, + allow_reserve, + }) + .await?; + match (response.status, envelope) { + (_, Some(envelope)) => Ok(envelope), + (404, None) => Err(Error::NotFound { operation }), + (status, None) if response.truncated => Err(Error::Api { + operation, + status, + messages: vec![format!( + "the answer is longer than {DEFAULT_MAX_BODY_BYTES} bytes" + )], + }), + (status, None) => { + let messages = match serde_json::from_slice::(&response.body) { + Ok(envelope) if !envelope.errors.is_empty() => { + envelope.errors.into_iter().map(|e| e.message).collect() + } + _ => rest_error_messages(&response.body), + }; + Err(Error::Api { + operation, + status, + messages, + }) + } + } + } + + /// Runs a GraphQL operation and returns its `data`. Any error fails the + /// call: `NotFound` when every error is NOT_FOUND, else `GraphQl`. + pub(crate) async fn graphql( + &self, + host: &str, + operation: &'static str, + variables: Value, + ) -> Result { + self.graphql_with(host, operation, variables, None).await + } + + /// Like `graphql`, but may spend the quota reserve: small reads a write + /// or an open picker depends on. + pub(crate) async fn graphql_reserved( + &self, + host: &str, + operation: &'static str, + variables: Value, + ) -> Result { + self.graphql_with(host, operation, variables, Some(true)) + .await + } + + async fn graphql_with( + &self, + host: &str, + operation: &'static str, + variables: Value, + allow_reserve: Option, + ) -> Result { + let envelope = self + .graphql_exchange( + host, + operation, + graphql::operation(operation), + variables, + allow_reserve, + ) + .await?; + if !envelope.errors.is_empty() { + return Err(graphql_failure(operation, envelope.errors)); + } + envelope + .data + .ok_or_else(|| Error::decode(operation, "the answer has no data")) + } + + /// Runs `operation` once per item in one aliased request. Each item + /// fails on its own (a missing pull request is `NotFound`); errors that + /// concern the whole request fail the call. + pub(crate) async fn graphql_batch( + &self, + host: &str, + operation: &'static str, + items: &[Value], + ) -> Result>> { + if items.is_empty() { + return Ok(Vec::new()); + } + let query = alias::aliased_operation(graphql::operation(operation), items.len()); + let envelope = self + .graphql_exchange( + host, + operation, + &query, + alias::aliased_variables(items), + None, + ) + .await?; + let (answers, general) = alias::split_answer(envelope.data, envelope.errors, items.len()); + if !general.is_empty() { + return Err(graphql_failure(operation, general)); + } + Ok(answers + .into_iter() + .map(|answer| { + if answer.errors.is_empty() { + Ok(answer.data) + } else { + Err(graphql_failure(operation, answer.errors)) + } + }) + .collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_rest_error_messages_in_every_shape() { + let body = br#"{"message":"Validation Failed","errors":["plain",{"message":"with message"},{"field":"head","code":"invalid"},{"resource":"Issue"}]}"#; + assert_eq!( + rest_error_messages(body), + ["Validation Failed", "plain", "with message", "head invalid"] + ); + assert!(rest_error_messages(b"").is_empty()); + } +} diff --git a/crates/github/src/client/mod.rs b/crates/github/src/client/mod.rs new file mode 100644 index 000000000..bfc85949d --- /dev/null +++ b/crates/github/src/client/mod.rs @@ -0,0 +1,97 @@ +//! The client: one per process, shared by every caller. It holds the +//! token cache, the request slots, quota and pause state, the ETag cache +//! and the node-id cache, so all reads of a host share one view of them. + +mod cache; +mod http; + +pub(crate) use http::RestCall; + +use crate::auth::{GhTokenSource, TokenCache, TokenSource}; +use crate::limits::{HostPauses, Quota}; +use crate::ops::checks::ChecksRevalidator; +use crate::transport::{ReqwestTransport, Transport}; +use cache::{EtagCache, NodeIdCache}; +use std::sync::{Arc, Mutex}; +use tokio::sync::Semaphore; + +/// At most this many requests in flight: GitHub's secondary limits punish +/// concurrent bursts. +const CONCURRENT_REQUESTS: usize = 8; + +/// Who is waiting for a request. Background requests leave the last 10 % +/// of a quota to interactive ones and stop while a host is paused; +/// interactive requests may spend the quota down to its last request and +/// pass a pause. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum Priority { + #[default] + Background, + Interactive, +} + +#[derive(Default)] +pub(crate) struct Limits { + pub quota: Quota, + pub pauses: HostPauses, +} + +pub(crate) struct Inner { + pub transport: Arc, + pub tokens: TokenCache, + pub slots: Semaphore, + pub limits: Mutex, + pub etags: EtagCache, + pub node_ids: NodeIdCache, + pub checks: ChecksRevalidator, +} + +/// A GitHub API client for pull request reads and writes. Cheap to clone; +/// clones share all state. +#[derive(Clone)] +pub struct Client { + pub(crate) inner: Arc, + pub(crate) priority: Priority, +} + +impl Client { + /// Talks to GitHub over HTTPS with the token from the environment or `gh`. + pub fn new() -> Self { + Self::with_parts(Arc::new(ReqwestTransport::new()), Arc::new(GhTokenSource)) + } + + /// A client over the given transport and token source (tests use a + /// scripted transport). + pub fn with_parts(transport: Arc, tokens: Arc) -> Self { + Self { + inner: Arc::new(Inner { + transport, + tokens: TokenCache::new(tokens), + slots: Semaphore::new(CONCURRENT_REQUESTS), + limits: Mutex::new(Limits::default()), + etags: EtagCache::new(), + node_ids: NodeIdCache::new(), + checks: ChecksRevalidator::new(), + }), + priority: Priority::Background, + } + } + + /// A handle sharing this client's state whose requests run at `priority`. + pub fn with_priority(&self, priority: Priority) -> Self { + Self { + inner: self.inner.clone(), + priority, + } + } + + pub fn priority(&self) -> Priority { + self.priority + } +} + +impl Default for Client { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/github/src/convert/activity.rs b/crates/github/src/convert/activity.rs new file mode 100644 index 000000000..1cde947bf --- /dev/null +++ b/crates/github/src/convert/activity.rs @@ -0,0 +1,1036 @@ +//! The conversation: `PullRequestActivity` (remarks, commits), +//! `ReviewThreads` (threads, reactions, reviewers, edit times), +//! `ReviewDismissals` and `ReviewThreadComments`, and how they combine. + +use super::detail::{RawReviewRequest, RawReviewer, with_fallback_avatar}; +use super::list::RawCommitNode; +use super::{Connection, RawActor, actor, login_avatar_url, trimmed}; +use crate::model::{ + Activity, Actor, Comment, CommentKind, Commit, DiffSide, Reaction, ReactionContent, + ReviewState, ReviewThread, ThreadComment, ThreadCommentsPage, +}; +use chrono::{DateTime, Utc}; +use serde::Deserialize; +use std::collections::{HashMap, HashSet}; + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +struct RawLogin { + login: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase", default)] +struct RawReactors { + total_count: u64, + nodes: Option>>, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub(crate) struct RawReactionGroup { + content: Option, + viewer_has_reacted: bool, + reactors: RawReactors, +} + +/// The viewer counts in `count` but is left out of `actors`, so a hover +/// does not list "you" twice. +fn reactions(groups: &[RawReactionGroup], viewer: Option<&str>) -> Vec { + groups + .iter() + .filter_map(|group| { + let content = ReactionContent::from_graphql(group.content.as_deref()?)?; + let logins: Vec = group + .reactors + .nodes + .iter() + .flatten() + .flatten() + .filter_map(|node| trimmed(node.login.as_deref())) + .collect(); + let count = group.reactors.total_count.max(logins.len() as u64); + if count == 0 { + return None; + } + let actors = logins + .into_iter() + .filter(|login| viewer.is_none_or(|viewer| !login.eq_ignore_ascii_case(viewer))) + .collect(); + Some(Reaction { + content, + count: count as u32, + actors, + viewer_has_reacted: group.viewer_has_reacted, + }) + }) + .collect() +} + +// --- PullRequestActivity --------------------------------------------------- + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub(crate) struct ActivityData { + repository: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase", default)] +struct RawActivityRepository { + pull_request: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +struct RawActivityPullRequest { + author: Option, + commits: Connection>, + comments: Connection, + reviews: Connection, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase", default)] +struct RawCount { + total_count: u64, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase", default)] +struct RawCommitUser { + login: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase", default)] +struct RawCommitAuthor { + name: Option, + email: Option, + avatar_url: Option, + user: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase", default)] +struct RawActivityCommit { + id: Option, + oid: Option, + message_headline: String, + committed_date: Option>, + parents: RawCount, + authors: Connection, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawIssueComment { + id: String, + #[serde(default)] + body: Option, + created_at: DateTime, + #[serde(default)] + last_edited_at: Option>, + #[serde(default)] + url: Option, + #[serde(default)] + author: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawReview { + id: String, + #[serde(default)] + body: Option, + #[serde(default)] + state: Option, + #[serde(default)] + submitted_at: Option>, + #[serde(default)] + last_edited_at: Option>, + #[serde(default)] + url: Option, + #[serde(default)] + author: Option, +} + +/// One page of the activity read. +pub(crate) struct ActivityPage { + /// Node ids of commits with one parent, whose stats are worth reading. + pub non_merge_commit_ids: Vec, + pub author: Option, + pub commits: Vec, + /// Issue comments and reviews, oldest first. + pub remarks: Vec, + pub next_comments_cursor: Option, + pub next_reviews_cursor: Option, +} + +/// An unlinked commit signature has no login; its name or e-mail stands in. +fn commit_actor(raw: &RawCommitAuthor) -> Option { + let login = raw + .user + .as_ref() + .and_then(|u| trimmed(u.login.as_deref())) + .or_else(|| trimmed(raw.name.as_deref())) + .or_else(|| trimmed(raw.email.as_deref()))?; + Some(Actor { + login, + name: trimmed(raw.name.as_deref()), + avatar_url: trimmed(raw.avatar_url.as_deref()), + is_bot: false, + }) +} + +pub(crate) fn activity_page(data: ActivityData) -> Option { + let pr = data.repository?.pull_request?; + let commits: Vec<&RawActivityCommit> = pr.commits.nodes().map(|n| &n.commit).collect(); + let issue_comments = pr.comments.nodes().map(|comment| Comment { + id: comment.id.clone(), + kind: CommentKind::IssueComment, + author: actor(comment.author.as_ref()), + body: comment.body.clone().unwrap_or_default(), + created_at: comment.created_at, + edited_at: comment.last_edited_at, + url: trimmed(comment.url.as_deref()), + path: None, + review_state: None, + reactions: Vec::new(), + }); + // A bodiless COMMENTED review only holds line comments, read with the threads. + let reviews = pr.reviews.nodes().filter_map(|review| { + let submitted_at = review.submitted_at?; + let state = review.state.as_deref().and_then(ReviewState::from_graphql); + let body = review.body.clone().unwrap_or_default(); + if body.trim().is_empty() && !state.is_some_and(ReviewState::is_verdict) { + return None; + } + Some(Comment { + id: review.id.clone(), + kind: CommentKind::Review, + author: actor(review.author.as_ref()), + body, + created_at: submitted_at, + edited_at: review.last_edited_at, + url: trimmed(review.url.as_deref()), + path: None, + review_state: state, + reactions: Vec::new(), + }) + }); + let mut remarks: Vec = issue_comments.chain(reviews).collect(); + remarks.sort_by_key(|c| c.created_at); + Some(ActivityPage { + non_merge_commit_ids: commits + .iter() + .filter(|c| c.parents.total_count <= 1) + .filter_map(|c| trimmed(c.id.as_deref())) + .collect(), + author: actor(pr.author.as_ref()), + commits: commits + .iter() + .filter_map(|c| { + Some(Commit { + oid: trimmed(c.oid.as_deref())?, + message_headline: c.message_headline.clone(), + committed_date: c.committed_date?, + additions: None, + deletions: None, + authors: c.authors.nodes().filter_map(commit_actor).collect(), + }) + }) + .collect(), + next_comments_cursor: pr.comments.next_cursor(), + next_reviews_cursor: pr.reviews.next_cursor(), + remarks, + }) +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub(crate) struct CommitStatsData { + nodes: Vec>, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase", default)] +struct RawCommitStats { + #[serde(rename = "__typename")] + typename: Option, + oid: Option, + additions: i64, + deletions: i64, +} + +/// Additions and deletions per commit oid. +pub(crate) fn commit_stats(data: CommitStatsData) -> HashMap { + data.nodes + .into_iter() + .flatten() + .filter(|c| c.typename.as_deref() == Some("Commit")) + .filter_map(|c| { + Some(( + trimmed(c.oid.as_deref())?, + (c.additions.max(0) as u64, c.deletions.max(0) as u64), + )) + }) + .collect() +} + +// --- ReviewThreads ----------------------------------------------------------- + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub(crate) struct ThreadsData { + viewer: Option, + repository: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase", default)] +struct RawThreadsRepository { + pull_request: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase", default)] +struct RawThreadsPullRequest { + review_threads: Connection, + viewer_can_update: Option, + viewer_did_author: Option, + author: Option, + reaction_groups: Vec, + comments: Connection, + reviews: Connection, + review_requests: Connection, + latest_reviews: Connection, + review_dismissals: Connection, + commits: Connection>, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase", default)] +struct RawThread { + id: Option, + is_resolved: bool, + is_outdated: bool, + path: Option, + line: Option, + diff_side: Option, + comments: Connection, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawThreadComment { + id: String, + #[serde(default)] + author: Option, + #[serde(default)] + body: String, + created_at: DateTime, + #[serde(default)] + last_edited_at: Option>, + #[serde(default)] + url: Option, + #[serde(default)] + reaction_groups: Vec, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase", default)] +struct RawReactable { + id: Option, + last_edited_at: Option>, + author: Option, + reaction_groups: Vec, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +struct RawLatestReviewAuthor { + author: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase", default)] +struct RawDismissal { + dismissal_message: Option, + review: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +struct RawNodeId { + id: Option, +} + +/// One page of the review-thread read. +#[derive(Debug, Default)] +pub(crate) struct ThreadsPage { + pub threads: Vec, + pub next_cursor: Option, + pub reactions: Vec, + pub reactions_by_id: HashMap>, + pub edited_at_by_id: HashMap>, + pub reviewers: Vec, + pub avatars_by_login: HashMap, + pub bot_logins: HashSet, + pub commits: Vec, + pub dismissals_by_review_id: HashMap, + pub next_dismissal_cursor: Option, +} + +fn thread_comment(comment: &RawThreadComment, viewer: Option<&str>) -> ThreadComment { + ThreadComment { + id: comment.id.clone(), + author: actor(comment.author.as_ref()), + body: comment.body.clone(), + created_at: comment.created_at, + edited_at: comment.last_edited_at, + url: trimmed(comment.url.as_deref()), + reactions: reactions(&comment.reaction_groups, viewer), + } +} + +fn dismissals(connection: &Connection) -> HashMap { + connection + .nodes() + .filter_map(|node| { + let review = trimmed(node.review.as_ref()?.id.as_deref())?; + Some((review, trimmed(node.dismissal_message.as_deref())?)) + }) + .collect() +} + +pub(crate) fn threads_page(data: ThreadsData) -> Option { + let viewer = data.viewer.and_then(|v| trimmed(v.login.as_deref())); + let viewer = viewer.as_deref(); + let pr = data.repository?.pull_request?; + let threads = pr + .review_threads + .nodes() + .filter_map(|thread| { + let comments: Vec<&RawThreadComment> = thread.comments.nodes().collect(); + if comments.is_empty() { + return None; + } + Some(ReviewThread { + id: trimmed(thread.id.as_deref())?, + path: trimmed(thread.path.as_deref())?, + // An outdated thread's line has left the diff. + line: thread.line.filter(|line| *line > 0).map(|line| line as u64), + side: if thread + .diff_side + .as_deref() + .is_some_and(|s| s.eq_ignore_ascii_case("LEFT")) + { + DiffSide::Left + } else { + DiffSide::Right + }, + is_resolved: thread.is_resolved, + is_outdated: thread.is_outdated, + comments: comments.iter().map(|c| thread_comment(c, viewer)).collect(), + comment_count: thread.comments.total_count.unwrap_or(comments.len() as u64), + next_comments_cursor: thread.comments.next_cursor(), + }) + }) + .collect(); + + let requested: Vec<&RawReviewer> = pr + .review_requests + .nodes() + .filter_map(|r| r.requested_reviewer.as_ref()) + .collect(); + let mut avatars_by_login = HashMap::new(); + let mut bot_logins = HashSet::new(); + let mut note = |actor: Option| { + if let Some(actor) = actor { + if let Some(avatar) = actor.avatar_url { + avatars_by_login.insert(actor.login.clone(), avatar); + } + if actor.is_bot { + bot_logins.insert(actor.login); + } + } + }; + note(actor(pr.author.as_ref())); + for node in pr.comments.nodes().chain(pr.reviews.nodes()) { + note(actor(node.author.as_ref())); + } + for reviewer in &requested { + note(reviewer.as_actor()); + } + for review in pr.latest_reviews.nodes() { + note(actor(review.author.as_ref())); + } + for thread in pr.review_threads.nodes() { + for comment in thread.comments.nodes() { + note(actor(comment.author.as_ref())); + } + } + + // Outstanding requests, then whoever already reviewed: each person once. + let mut reviewers: Vec = Vec::new(); + let candidates = requested + .iter() + .map(|r| r.as_actor().or_else(|| r.as_team())) + .chain(pr.latest_reviews.nodes().map(|r| actor(r.author.as_ref()))); + for candidate in candidates.flatten() { + if !reviewers.iter().any(|r| r.login == candidate.login) { + reviewers.push(candidate); + } + } + + let mut reactions_by_id = HashMap::new(); + let mut edited_at_by_id = HashMap::new(); + for node in pr.comments.nodes().chain(pr.reviews.nodes()) { + let Some(id) = trimmed(node.id.as_deref()) else { + continue; + }; + if let Some(edited_at) = node.last_edited_at { + edited_at_by_id.insert(id.clone(), edited_at); + } + let node_reactions = reactions(&node.reaction_groups, viewer); + if !node_reactions.is_empty() { + reactions_by_id.insert(id, node_reactions); + } + } + + let commits = pr + .commits + .nodes() + .filter_map(|node| { + let commit = &node.commit; + Some(Commit { + oid: trimmed(commit.oid.as_deref())?, + message_headline: commit.message_headline.clone(), + committed_date: commit.committed_date?, + additions: None, + deletions: None, + authors: commit + .authors + .nodes() + .filter_map(|author| { + let login = author + .user + .as_ref() + .and_then(|u| trimmed(u.login.as_deref())) + .or_else(|| trimmed(author.name.as_deref()))?; + Some(Actor { + login, + name: trimmed(author.name.as_deref()), + avatar_url: trimmed(author.avatar_url.as_deref()), + is_bot: false, + }) + }) + .collect(), + }) + }) + .collect(); + + Some(ThreadsPage { + threads, + next_cursor: pr.review_threads.next_cursor(), + reactions: reactions(&pr.reaction_groups, viewer), + reactions_by_id, + edited_at_by_id, + reviewers, + avatars_by_login, + bot_logins, + commits, + dismissals_by_review_id: dismissals(&pr.review_dismissals), + next_dismissal_cursor: pr.review_dismissals.next_cursor(), + }) +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub(crate) struct DismissalsData { + repository: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase", default)] +struct RawDismissalsRepository { + pull_request: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase", default)] +struct RawDismissalsPullRequest { + timeline_items: Connection, +} + +pub(crate) fn dismissals_page( + data: DismissalsData, +) -> Option<(HashMap, Option)> { + let items = data.repository?.pull_request?.timeline_items; + Some((dismissals(&items), items.next_cursor())) +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub(crate) struct ThreadCommentsData { + viewer: Option, + repository: Option, + node: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub(crate) struct RawSubjectRepository { + pub pull_request: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub(crate) struct RawNodeIdPublic { + pub id: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase", default)] +struct RawThreadNode { + #[serde(rename = "__typename")] + typename: Option, + pull_request: Option, + comments: Connection, +} + +/// More comments of one thread, and whether the thread belongs to the +/// pull request the caller named. +pub(crate) fn thread_comments_page(data: ThreadCommentsData) -> (bool, ThreadCommentsPage) { + let viewer = data.viewer.and_then(|v| trimmed(v.login.as_deref())); + let expected = data + .repository + .and_then(|r| r.pull_request) + .and_then(|pr| pr.id); + let thread = data + .node + .filter(|node| node.typename.as_deref() == Some("PullRequestReviewThread")); + let actual = thread + .as_ref() + .and_then(|t| t.pull_request.as_ref()) + .and_then(|pr| pr.id.clone()); + let belongs = expected.is_some() && expected == actual; + let page = ThreadCommentsPage { + comments: thread + .as_ref() + .map(|t| { + t.comments + .nodes() + .map(|c| thread_comment(c, viewer.as_deref())) + .collect() + }) + .unwrap_or_default(), + next_cursor: thread.and_then(|t| t.comments.next_cursor()), + }; + (belongs, page) +} + +// --- Combining ------------------------------------------------------------- + +/// The activity read, all pages. +pub(crate) struct ActivityRead { + pub author: Option, + pub comments: Vec, + pub commits: Vec, +} + +/// The review-thread read, all pages. +#[derive(Debug, Default)] +pub(crate) struct ThreadsRead { + pub first: ThreadsPage, + pub threads: Vec, + pub dismissals_by_review_id: HashMap, + /// More threads than were read. + pub threads_truncated: bool, +} + +impl ThreadsRead { + /// What stands in when the thread read failed: nothing, marked as cut. + pub fn unavailable() -> Self { + Self { + threads_truncated: true, + ..Self::default() + } + } + + fn comment_count(&self) -> u64 { + self.threads.iter().map(|t| t.comment_count).sum() + } + + fn comments_truncated(&self) -> bool { + self.threads_truncated + || self + .threads + .iter() + .any(|t| t.next_comments_cursor.is_some()) + } +} + +/// A body that shows nothing once HTML comments are gone, as bots leave. +fn renders_empty(body: &str) -> bool { + let mut rest = body; + let mut visible = String::new(); + while let Some(start) = rest.find("") { + Some(end) => rest = &rest[start + end + 3..], + None => { + rest = &rest[start..]; + break; + } + } + } + visible.push_str(rest); + visible.trim().is_empty() +} + +/// Fills in what one read knows about another's actors: avatars, bots. +fn with_avatar(actor: Option, threads: &ThreadsPage, host: &str) -> Option { + actor.map(|mut actor| { + if threads.bot_logins.contains(&actor.login) { + actor.is_bot = true; + } + if actor.avatar_url.is_none() { + actor.avatar_url = threads + .avatars_by_login + .get(&actor.login) + .cloned() + .or_else(|| login_avatar_url(&actor.login, host)); + } + actor + }) +} + +/// Combines the activity and thread reads into the conversation. +pub(crate) fn assemble(activity: ActivityRead, threads: ThreadsRead, host: &str) -> Activity { + let page = &threads.first; + let comment_count = activity.comments.len() as u64 + threads.comment_count(); + let comments_truncated = threads.comments_truncated(); + let thread_remarks = threads.threads.iter().flat_map(|thread| { + thread.comments.iter().map(|comment| Comment { + id: comment.id.clone(), + kind: CommentKind::ReviewComment, + author: comment.author.clone(), + body: comment.body.clone(), + created_at: comment.created_at, + edited_at: comment.edited_at, + url: comment.url.clone(), + path: Some(thread.path.clone()), + review_state: None, + reactions: comment.reactions.clone(), + }) + }); + let mut comments: Vec = activity + .comments + .into_iter() + .map(|mut comment| { + // Dismissal reasons live on timeline events. + if comment.review_state == Some(ReviewState::Dismissed) + && renders_empty(&comment.body) + && let Some(message) = threads.dismissals_by_review_id.get(&comment.id) + { + comment.body = message.clone(); + } + comment.reactions = page + .reactions_by_id + .get(&comment.id) + .cloned() + .unwrap_or_default(); + comment + }) + .chain(thread_remarks) + .map(|mut comment| { + comment.author = with_avatar(comment.author, page, host); + if let Some(edited_at) = page.edited_at_by_id.get(&comment.id) { + comment.edited_at = Some(*edited_at); + } + comment + }) + .collect(); + comments.sort_by_key(|c| c.created_at); + + // The thread read's commits carry avatars; the activity's carry stats. + let stats: HashMap<&str, (Option, Option)> = activity + .commits + .iter() + .map(|c| (c.oid.as_str(), (c.additions, c.deletions))) + .collect(); + let commits = if page.commits.is_empty() { + &activity.commits + } else { + &page.commits + }; + let commits = commits + .iter() + .map(|commit| { + let (additions, deletions) = + stats.get(commit.oid.as_str()).copied().unwrap_or_default(); + Commit { + additions: additions.or(commit.additions), + deletions: deletions.or(commit.deletions), + authors: commit + .authors + .iter() + .map(|a| with_avatar(Some(a.clone()), page, host).unwrap_or_else(|| a.clone())) + .collect(), + ..commit.clone() + } + }) + .collect(); + + let review_threads = threads + .threads + .iter() + .map(|thread| ReviewThread { + comments: thread + .comments + .iter() + .map(|c| ThreadComment { + author: with_avatar(c.author.clone(), page, host), + ..c.clone() + }) + .collect(), + ..thread.clone() + }) + .collect(); + + Activity { + author: with_avatar(with_fallback_avatar(activity.author, host), page, host), + reviewers: page.reviewers.clone(), + comments, + comment_count, + comments_truncated, + review_threads, + review_threads_truncated: threads.threads_truncated, + commits, + reactions: page.reactions.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{Value, json}; + + fn activity_fixture() -> Value { + serde_json::from_str(include_str!( + "../../tests/fixtures/pull_request_activity.json" + )) + .unwrap() + } + + fn threads_fixture() -> Value { + serde_json::from_str(include_str!("../../tests/fixtures/review_threads.json")).unwrap() + } + + fn activity(data: Value) -> ActivityPage { + activity_page(serde_json::from_value(data).unwrap()).unwrap() + } + + fn threads(data: Value) -> ThreadsPage { + threads_page(serde_json::from_value(data).unwrap()).unwrap() + } + + #[test] + fn merges_reviews_with_comments_in_time_order_and_keeps_a_bodyless_approval() { + let page = activity(activity_fixture()["data"].clone()); + let remarks: Vec<_> = page + .remarks + .iter() + .map(|c| (c.id.as_str(), c.kind, c.review_state)) + .collect(); + assert_eq!( + remarks, + [ + ("IC_1", CommentKind::IssueComment, None), + ( + "PRR_approve", + CommentKind::Review, + Some(ReviewState::Approved) + ), + ("IC_2", CommentKind::IssueComment, None), + ( + "PRR_dismissed", + CommentKind::Review, + Some(ReviewState::Dismissed) + ), + ] + ); + assert_eq!(page.next_comments_cursor.as_deref(), Some("comments-2")); + assert_eq!(page.next_reviews_cursor, None); + } + + #[test] + fn selects_stats_only_for_non_merge_commits_and_reads_unlinked_authors() { + let page = activity(activity_fixture()["data"].clone()); + assert_eq!(page.non_merge_commit_ids, ["C_first"]); + assert_eq!(page.commits.len(), 2); + let authors: Vec<_> = page.commits[0] + .authors + .iter() + .map(|a| a.login.as_str()) + .collect(); + assert_eq!(authors, ["stippi", "Co Author"]); + assert_eq!(page.author.as_ref().unwrap().login, "contributor"); + } + + #[test] + fn reads_commit_stats_of_commits_only() { + let stats = commit_stats( + serde_json::from_value(json!({"nodes": [ + {"__typename": "Commit", "oid": "abc", "additions": 3, "deletions": 1}, + {"__typename": "Blob"}, + null + ]})) + .unwrap(), + ); + assert_eq!(stats.len(), 1); + assert_eq!(stats["abc"], (3, 1)); + } + + #[test] + fn anchors_threads_to_line_and_side_and_leaves_outdated_ones_unanchored() { + let page = threads(threads_fixture()["data"].clone()); + assert_eq!(page.threads.len(), 2); + let first = &page.threads[0]; + assert_eq!(first.path, "src/lib.rs"); + assert_eq!(first.line, Some(12)); + assert_eq!(first.side, DiffSide::Left); + assert!(first.is_resolved); + assert_eq!(first.comments.len(), 2); + assert_eq!(first.comment_count, 12); + assert_eq!( + first.next_comments_cursor.as_deref(), + Some("thread-1-comments") + ); + let second = &page.threads[1]; + assert_eq!(second.line, None); + assert!(second.is_outdated); + assert_eq!(second.side, DiffSide::Right); + assert_eq!(second.next_comments_cursor, None); + assert_eq!(page.next_cursor, None); + } + + #[test] + fn leaves_the_viewer_out_of_reactors_while_counting_them() { + let page = threads(threads_fixture()["data"].clone()); + assert_eq!( + page.reactions, + [Reaction { + content: ReactionContent::ThumbsUp, + count: 14, + actors: vec!["octocat".into()], + viewer_has_reacted: true, + }] + ); + let comment_reactions = &page.reactions_by_id["IC_1"]; + assert_eq!(comment_reactions[0].content, ReactionContent::Heart); + assert_eq!(comment_reactions[0].count, 1); + } + + #[test] + fn lists_reviewers_once_teams_by_org_slug_and_notes_bots() { + let page = threads(threads_fixture()["data"].clone()); + let reviewers: Vec<_> = page.reviewers.iter().map(|r| r.login.as_str()).collect(); + assert_eq!(reviewers, ["octocat", "acme/core", "review-bot"]); + assert!(page.bot_logins.contains("review-bot")); + assert_eq!( + page.dismissals_by_review_id["PRR_dismissed"], + "Outdated after the rewrite" + ); + assert_eq!(page.next_dismissal_cursor, None); + } + + #[test] + fn combines_activity_and_threads_into_one_conversation() { + let activity_page = activity(activity_fixture()["data"].clone()); + let threads_page = threads(threads_fixture()["data"].clone()); + let read = ThreadsRead { + threads: threads_page.threads.clone(), + dismissals_by_review_id: threads_page.dismissals_by_review_id.clone(), + threads_truncated: false, + first: threads_page, + }; + let mut stats = activity_page.commits.clone(); + stats[0].additions = Some(5); + stats[0].deletions = Some(2); + let assembled = assemble( + ActivityRead { + author: activity_page.author, + comments: activity_page.remarks, + commits: stats, + }, + read, + "github.com", + ); + let ids: Vec<_> = assembled.comments.iter().map(|c| c.id.as_str()).collect(); + assert_eq!( + ids, + [ + "IC_1", + "PRRC_1", + "PRR_approve", + "PRRC_2", + "IC_2", + "PRRC_3", + "PRR_dismissed" + ] + ); + let dismissed = assembled.comments.last().unwrap(); + assert_eq!(dismissed.body, "Outdated after the rewrite"); + let first = &assembled.comments[0]; + assert_eq!(first.reactions[0].content, ReactionContent::Heart); + assert!(first.edited_at.is_some()); + // The approval's author gets the avatar only the thread read carried. + let approver = assembled.comments[2].author.as_ref().unwrap(); + assert!(approver.is_bot); + assert_eq!( + approver.avatar_url.as_deref(), + Some("https://avatars.githubusercontent.com/in/9") + ); + assert_eq!(assembled.comment_count, 4 + 12 + 1); + assert!(assembled.comments_truncated); + assert_eq!(assembled.commits[0].additions, Some(5)); + assert_eq!( + assembled.commits[0].authors[0].avatar_url.as_deref(), + Some("https://avatars.githubusercontent.com/u/1?v=4") + ); + } + + #[test] + fn tells_whether_a_thread_belongs_to_the_pull_request() { + let data = |thread_pr: &str| { + serde_json::from_value::(json!({ + "viewer": {"login": "me"}, + "repository": {"pullRequest": {"id": "PR_1"}}, + "node": { + "__typename": "PullRequestReviewThread", + "pullRequest": {"id": thread_pr}, + "comments": { + "pageInfo": {"hasNextPage": false, "endCursor": "x"}, + "nodes": [{"id": "C", "body": "hi", "createdAt": "2026-10-30T10:00:00Z"}] + } + } + })) + .unwrap() + }; + let (belongs, page) = thread_comments_page(data("PR_1")); + assert!(belongs); + assert_eq!(page.comments.len(), 1); + assert_eq!(page.next_cursor, None); + assert!(!thread_comments_page(data("PR_2")).0); + } + + #[test] + fn finds_bodies_that_render_empty() { + assert!(renders_empty(" \n")); + assert!(!renders_empty(" text")); + assert!(!renders_empty("text ", + "state": "DISMISSED", + "submittedAt": "2026-10-30T13:00:00Z", + "lastEditedAt": null, + "url": null, + "author": { "__typename": "User", "login": "octocat" } + }, + { + "id": "PRR_pending", + "body": "Draft thoughts", + "state": "PENDING", + "submittedAt": null, + "lastEditedAt": null, + "url": null, + "author": { "__typename": "User", "login": "me" } + } + ] + } + } + } + } +} diff --git a/crates/github/tests/fixtures/pull_request_core.json b/crates/github/tests/fixtures/pull_request_core.json new file mode 100644 index 000000000..36f5923a0 --- /dev/null +++ b/crates/github/tests/fixtures/pull_request_core.json @@ -0,0 +1,135 @@ +{ + "data": { + "repository": { + "mergeCommitAllowed": false, + "squashMergeAllowed": true, + "rebaseMergeAllowed": true, + "autoMergeAllowed": true, + "viewerPermission": "WRITE", + "pullRequest": { + "number": 291, + "title": "Pull request view", + "url": "https://github.com/stippi/code-assistant/pull/291", + "body": "Adds the pull request view.", + "state": "OPEN", + "isDraft": false, + "mergeable": "MERGEABLE", + "mergeStateStatus": "BLOCKED", + "reviewDecision": "REVIEW_REQUIRED", + "additions": 2210, + "deletions": 140, + "changedFiles": 37, + "createdAt": "2026-10-28T09:12:44Z", + "updatedAt": "2026-10-30T11:02:13Z", + "mergedAt": null, + "closedAt": null, + "headRefName": "feature/pr-view", + "baseRefName": "main", + "headRefOid": "0123456789abcdef0123456789abcdef01234567", + "isCrossRepository": true, + "maintainerCanModify": true, + "headRepositoryOwner": { "__typename": "User", "login": "contributor" }, + "headRepository": { + "nameWithOwner": "contributor/code-assistant", + "viewerPermission": "ADMIN" + }, + "headRef": { "id": "REF_kwDOabc" }, + "author": { + "__typename": "User", + "login": "contributor", + "avatarUrl": "https://avatars.githubusercontent.com/u/42?v=4", + "id": "U_kgDO42", + "name": "A Contributor" + }, + "autoMergeRequest": { "mergeMethod": "SQUASH" }, + "viewerCanUpdate": true, + "viewerDidAuthor": true, + "viewerCanUpdateBranch": true, + "baseRef": { "compare": { "behindBy": 3 } }, + "reviewRequests": { + "nodes": [ + { + "requestedReviewer": { + "__typename": "User", + "login": "octocat", + "name": "The Octocat" + } + }, + { "requestedReviewer": { "__typename": "Bot", "login": "review-bot" } }, + { + "requestedReviewer": { + "__typename": "Team", + "slug": "core", + "combinedSlug": "acme/core", + "teamName": "Core", + "teamAvatarUrl": null + } + } + ] + }, + "labels": { + "nodes": [ + { "name": "enhancement", "color": "a2eeef" }, + { "name": "ui", "color": null } + ] + }, + "commits": { + "nodes": [ + { + "commit": { + "statusCheckRollup": { + "contexts": { + "nodes": [ + { + "__typename": "CheckRun", + "name": "lint", + "status": "COMPLETED", + "conclusion": "SUCCESS", + "startedAt": "2026-10-30T16:00:00Z", + "completedAt": "2026-10-30T16:01:00Z", + "detailsUrl": "https://github.com/stippi/code-assistant/actions/runs/1/job/1", + "isRequired": false, + "checkSuite": { "workflowRun": { "workflow": { "name": "CI" } } } + }, + { + "__typename": "CheckRun", + "name": "test", + "status": "COMPLETED", + "conclusion": "SUCCESS", + "startedAt": "2026-10-30T16:00:00Z", + "completedAt": "2026-10-30T16:09:00Z", + "detailsUrl": "https://github.com/stippi/code-assistant/actions/runs/1/job/2", + "isRequired": true, + "checkSuite": { "workflowRun": { "workflow": { "name": "CI" } } } + }, + { + "__typename": "CheckRun", + "name": "lint", + "status": "COMPLETED", + "conclusion": "FAILURE", + "startedAt": "2026-10-30T18:00:00Z", + "completedAt": "2026-10-30T18:01:00Z", + "detailsUrl": "https://github.com/stippi/code-assistant/actions/runs/2/job/3", + "isRequired": false, + "checkSuite": { "workflowRun": { "workflow": { "name": "CI" } } } + }, + { + "__typename": "StatusContext", + "context": "ci/legacy", + "state": "PENDING", + "targetUrl": "https://ci.example.com/build/7", + "createdAt": "2026-10-30T16:00:00Z", + "description": "Waiting for a runner" + } + ], + "pageInfo": { "hasNextPage": false } + } + } + } + } + ] + } + } + } + } +} diff --git a/crates/github/tests/fixtures/pull_request_search.json b/crates/github/tests/fixtures/pull_request_search.json new file mode 100644 index 000000000..013c156e1 --- /dev/null +++ b/crates/github/tests/fixtures/pull_request_search.json @@ -0,0 +1,74 @@ +{ + "data": { + "search": { + "pageInfo": { "hasNextPage": true, "endCursor": "Y3Vyc29yOjI=" }, + "nodes": [ + { + "number": 290, + "title": "feat(files): show raster images in the Files view", + "url": "https://github.com/stippi/code-assistant/pull/290", + "author": { + "login": "stippi", + "avatarUrl": "https://avatars.githubusercontent.com/u/1234?v=4", + "name": "Stephan Aßmus" + }, + "headRefName": "feat/image-file-renderer", + "baseRefName": "main", + "state": "MERGED", + "isDraft": false, + "mergeable": "UNKNOWN", + "reviewDecision": null, + "latestReviews": { + "nodes": [{ "state": "APPROVED", "author": { "login": "reviewer-bot" } }] + }, + "additions": 120, + "deletions": 8, + "createdAt": "2026-10-28T09:12:44Z", + "updatedAt": "2026-10-29T17:01:02Z", + "mergedAt": "2026-10-29T17:01:00Z", + "repository": { "nameWithOwner": "stippi/code-assistant" }, + "reviewRequests": { "nodes": [] }, + "labels": { "nodes": [{ "name": "enhancement", "color": "a2eeef" }] }, + "commits": { + "nodes": [{ "commit": { "statusCheckRollup": { "state": "SUCCESS" } } }] + } + }, + { + "number": 291, + "title": "Draft: pull request view", + "url": "https://github.com/stippi/code-assistant/pull/291", + "author": { "login": "dependabot", "avatarUrl": null }, + "headRefName": "feature/pr-view", + "baseRefName": "main", + "state": "OPEN", + "isDraft": true, + "mergeable": "MERGEABLE", + "reviewDecision": "REVIEW_REQUIRED", + "latestReviews": { "nodes": [] }, + "additions": 3, + "deletions": 1, + "createdAt": "2026-10-30T08:00:00Z", + "updatedAt": "2026-10-30T09:30:00.123Z", + "mergedAt": null, + "repository": { "nameWithOwner": "stippi/code-assistant" }, + "reviewRequests": { + "nodes": [ + { "requestedReviewer": { "login": "octocat" } }, + { "requestedReviewer": { "slug": "core-team" } }, + { "requestedReviewer": null } + ] + }, + "labels": { "nodes": [] }, + "commits": { + "nodes": [{ "commit": { "statusCheckRollup": { "state": "FAILURE" } } }] + } + }, + { + "title": "A row without its number", + "url": "https://github.com/stippi/code-assistant/pull/1" + }, + {} + ] + } + } +} diff --git a/crates/github/tests/fixtures/review_threads.json b/crates/github/tests/fixtures/review_threads.json new file mode 100644 index 000000000..bdb5294b8 --- /dev/null +++ b/crates/github/tests/fixtures/review_threads.json @@ -0,0 +1,202 @@ +{ + "data": { + "viewer": { "login": "me" }, + "repository": { + "pullRequest": { + "reviewThreads": { + "totalCount": 3, + "pageInfo": { "hasNextPage": false, "endCursor": "threads-3" }, + "nodes": [ + { + "id": "PRRT_1", + "isResolved": true, + "isOutdated": false, + "path": "src/lib.rs", + "line": 12, + "diffSide": "LEFT", + "comments": { + "totalCount": 12, + "pageInfo": { "hasNextPage": true, "endCursor": "thread-1-comments" }, + "nodes": [ + { + "id": "PRRC_1", + "author": { "__typename": "User", "login": "octocat", "avatarUrl": "https://avatars.githubusercontent.com/u/583231?v=4" }, + "body": "Why remove this?", + "createdAt": "2026-10-30T10:30:00Z", + "lastEditedAt": null, + "url": "https://github.com/stippi/code-assistant/pull/291#discussion_r1", + "reactionGroups": [] + }, + { + "id": "PRRC_2", + "author": { "__typename": "User", "login": "contributor", "avatarUrl": "https://avatars.githubusercontent.com/u/42?v=4" }, + "body": "It moved to the client.", + "createdAt": "2026-10-30T11:15:00Z", + "lastEditedAt": "2026-10-30T11:20:00Z", + "url": "https://github.com/stippi/code-assistant/pull/291#discussion_r2", + "reactionGroups": [ + { + "content": "THUMBS_UP", + "viewerHasReacted": false, + "reactors": { "totalCount": 1, "nodes": [{ "login": "octocat" }] } + } + ] + } + ] + } + }, + { + "id": "PRRT_2", + "isResolved": false, + "isOutdated": true, + "path": "src/main.rs", + "line": null, + "diffSide": "RIGHT", + "comments": { + "totalCount": 1, + "pageInfo": { "hasNextPage": false, "endCursor": "c" }, + "nodes": [ + { + "id": "PRRC_3", + "author": { "__typename": "User", "login": "octocat", "avatarUrl": null }, + "body": "Nit: naming.", + "createdAt": "2026-10-30T12:30:00Z", + "lastEditedAt": null, + "url": null, + "reactionGroups": [] + } + ] + } + }, + { + "id": "PRRT_3", + "isResolved": false, + "isOutdated": false, + "path": "src/empty.rs", + "line": 3, + "diffSide": "RIGHT", + "comments": { "totalCount": 0, "pageInfo": { "hasNextPage": false }, "nodes": [] } + } + ] + }, + "viewerCanUpdate": true, + "viewerDidAuthor": false, + "author": { + "__typename": "User", + "login": "contributor", + "avatarUrl": "https://avatars.githubusercontent.com/u/42?v=4" + }, + "reactionGroups": [ + { + "content": "THUMBS_UP", + "viewerHasReacted": true, + "reactors": { "totalCount": 14, "nodes": [{ "login": "octocat" }, { "login": "ME" }] } + }, + { + "content": "SOMETHING_NEW", + "viewerHasReacted": false, + "reactors": { "totalCount": 2, "nodes": [] } + }, + { + "content": "LAUGH", + "viewerHasReacted": false, + "reactors": { "totalCount": 0, "nodes": [] } + } + ], + "comments": { + "nodes": [ + { + "id": "IC_1", + "lastEditedAt": "2026-10-30T10:05:00Z", + "author": { "__typename": "User", "login": "octocat", "avatarUrl": "https://avatars.githubusercontent.com/u/583231?v=4" }, + "reactionGroups": [ + { + "content": "HEART", + "viewerHasReacted": false, + "reactors": { "totalCount": 1, "nodes": [{ "login": "contributor" }] } + } + ] + } + ] + }, + "reviews": { + "nodes": [ + { + "id": "PRR_approve", + "lastEditedAt": null, + "author": { + "__typename": "Bot", + "login": "review-bot", + "avatarUrl": "https://avatars.githubusercontent.com/in/9" + }, + "reactionGroups": [] + } + ] + }, + "reviewRequests": { + "nodes": [ + { + "requestedReviewer": { + "__typename": "User", + "login": "octocat", + "name": "The Octocat", + "avatarUrl": "https://avatars.githubusercontent.com/u/583231?v=4" + } + }, + { + "requestedReviewer": { + "__typename": "Team", + "combinedSlug": "acme/core", + "teamName": "Core", + "teamAvatarUrl": null + } + } + ] + }, + "latestReviews": { + "nodes": [ + { + "state": "APPROVED", + "author": { + "__typename": "Bot", + "login": "review-bot", + "avatarUrl": "https://avatars.githubusercontent.com/in/9" + } + }, + { + "state": "COMMENTED", + "author": { "__typename": "User", "login": "octocat", "avatarUrl": null } + } + ] + }, + "reviewDismissals": { + "pageInfo": { "hasNextPage": false, "endCursor": "d" }, + "nodes": [ + { "dismissalMessage": "Outdated after the rewrite", "review": { "id": "PRR_dismissed" } }, + {} + ] + }, + "commits": { + "nodes": [ + { + "commit": { + "oid": "1111111111111111111111111111111111111111", + "messageHeadline": "Add the pull request view", + "committedDate": "2026-10-28T09:00:00Z", + "authors": { + "nodes": [ + { + "name": "Stephan Aßmus", + "avatarUrl": "https://avatars.githubusercontent.com/u/1?v=4", + "user": { "login": "stippi" } + } + ] + } + } + } + ] + } + } + } + } +} diff --git a/crates/github/tests/live.rs b/crates/github/tests/live.rs new file mode 100644 index 000000000..121a7b59a --- /dev/null +++ b/crates/github/tests/live.rs @@ -0,0 +1,94 @@ +//! Read-only checks against the real GitHub API, with the token `gh` (or +//! `GH_TOKEN`) supplies. Run by hand: +//! `cargo test -p github --test live -- --ignored --nocapture` + +use github::model::{ListQuery, ListState}; +use github::{Client, Priority, RepoRef}; + +fn repo() -> RepoRef { + RepoRef::from_remote_url("https://github.com/stippi/code-assistant.git").unwrap() +} + +#[tokio::test] +#[ignore = "talks to api.github.com"] +async fn reads_a_public_repository() { + let client = Client::new().with_priority(Priority::Interactive); + let repo = repo(); + + let viewer = client.viewer(&repo.host).await.unwrap(); + println!("viewer: {}", viewer.login); + + let page = client + .list( + &repo, + &ListQuery { + state: ListState::All, + limit: 5, + ..ListQuery::default() + }, + ) + .await + .unwrap(); + assert!(!page.entries.is_empty()); + println!( + "listed: {:?}", + page.entries.iter().map(|e| e.number).collect::>() + ); + + let pr = repo.pull_request(page.entries[0].number); + let older = repo.pull_request(290); + let summaries = client.summaries(&[pr.clone(), older.clone()]).await; + assert!(summaries.iter().all(Result::is_ok), "{summaries:?}"); + let stats = client + .list_stats(&[pr.clone(), older.clone()]) + .await + .unwrap(); + assert_eq!(stats.len(), 2); + + let detail = client.detail(&older).await.unwrap(); + println!( + "#{} {:?} checks={} reviewers={:?} permissions={:?}", + detail.number, + detail.state, + detail.checks.len(), + detail.reviewers, + detail.viewer_permissions.actions + ); + let checks = client.checks(&older).await.unwrap(); + assert_eq!(checks.head_sha, detail.head_sha); + // The second poll revalidates with ETags. + client.checks(&older).await.unwrap(); + + let activity = client.activity(&older).await.unwrap(); + println!( + "comments={} threads={} commits={}", + activity.comments.len(), + activity.review_threads.len(), + activity.commits.len() + ); + assert!(!activity.commits.is_empty()); + + let diff = client.diff(&older, None).await.unwrap(); + assert!(diff.patch.starts_with("diff --git")); + let files = client.diff(&older, Some("1")).await.unwrap(); + assert!(files.patch.starts_with("diff --git")); + let sha = &activity.commits[0].oid; + let commit = client.commit_diff(&repo, sha, None).await.unwrap(); + assert!(commit.patch.contains("diff --git")); + let readme = client + .file_contents(&repo, "README.md", detail.head_sha.as_deref().unwrap()) + .await + .unwrap(); + assert!(!readme.is_empty()); + + client.files_viewed(&older).await.unwrap(); + let labels = client.label_candidates(&older).await.unwrap(); + println!("labels: {}", labels.candidates.len()); + let reviewers = client.reviewer_candidates(&older).await.unwrap(); + println!("reviewer candidates: {}", reviewers.candidates.len()); + let heads = client + .pull_requests_for_head(&repo, &detail.head_branch) + .await + .unwrap(); + assert!(heads.iter().any(|h| h.number == 290)); +} diff --git a/crates/github/tests/operations.rs b/crates/github/tests/operations.rs new file mode 100644 index 000000000..f68058a0f --- /dev/null +++ b/crates/github/tests/operations.rs @@ -0,0 +1,964 @@ +//! Reads and writes, through the scripted transport. + +use github::fake::{FakeResponse, FakeTransport}; +use github::model::{ + Action, CheckStatus, DiffSide, EditableCommentKind, FileChange, Involvement, ListQuery, + ListState, MergeMethod, PullRequestState, PullRequestUpdate, ReactionContent, + ReviewCommentDraft, ReviewSubmission, ReviewVerdict, Reviewer, ReviewerKind, UpdateMethod, +}; +use github::transport::Method; +use github::{BranchDeletionRefusal, Error, FileUnavailableReason, PullRequestRef, RepoRef}; +use serde_json::{Value, json}; + +fn repo() -> RepoRef { + RepoRef::new("github.com", "stippi", "code-assistant") +} + +fn pr() -> PullRequestRef { + repo().pull_request(291) +} + +fn fixture(name: &str) -> Value { + let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR")); + let text = std::fs::read_to_string(path).unwrap(); + serde_json::from_str::(&text).unwrap()["data"].clone() +} + +const HEAD: &str = "0123456789abcdef0123456789abcdef01234567"; + +// --- Detail ------------------------------------------------------------------ + +#[tokio::test] +async fn reads_the_detail_with_the_variables_the_document_needs() { + let fake = FakeTransport::new(); + fake.on_graphql("PullRequestCore") + .data(fixture("pull_request_core.json")); + let detail = fake.client().detail(&pr()).await.unwrap(); + assert_eq!(detail.number, 291); + assert_eq!(detail.checks.len(), 3); + let variables = fake.requests()[0].variables.clone().unwrap(); + assert_eq!( + variables, + json!({ + "owner": "stippi", "name": "code-assistant", "number": 291, + "headRef": "refs/pull/291/head", "required": true, "requestedReviewers": 100 + }) + ); +} + +#[tokio::test] +async fn reads_the_detail_again_without_reviewers_when_a_team_is_refused() { + let fake = FakeTransport::new(); + fake.on_graphql("PullRequestCore") + .with_variables(json!({"requestedReviewers": 100})) + .respond(FakeResponse::errors(json!([{ + "type": "FORBIDDEN", + "path": ["repository", "pullRequest", "reviewRequests", "nodes", 2, "requestedReviewer"], + "message": "Resource not accessible by integration" + }]))); + fake.on_graphql("PullRequestCore") + .with_variables(json!({"requestedReviewers": 0})) + .data(fixture("pull_request_core.json")); + let detail = fake.client().detail(&pr()).await.unwrap(); + assert_eq!(detail.number, 291); + assert_eq!(fake.operations(), ["PullRequestCore", "PullRequestCore"]); +} + +#[tokio::test] +async fn walks_every_check_context_past_the_first_hundred() { + let fake = FakeTransport::new(); + let mut core = fixture("pull_request_core.json"); + core["repository"]["pullRequest"]["commits"]["nodes"][0]["commit"]["statusCheckRollup"]["contexts"] + ["pageInfo"]["hasNextPage"] = true.into(); + fake.on_graphql("PullRequestCore").data(core); + let page = |contexts: Value, next: Option<&str>| { + json!({"repository": {"pullRequest": { + "headRefOid": HEAD, + "commits": {"nodes": [{"commit": {"statusCheckRollup": {"contexts": { + "nodes": contexts, + "pageInfo": {"hasNextPage": next.is_some(), "endCursor": next} + }}}}]} + }}}) + }; + fake.on_graphql("PullRequestCheckContexts") + .with_variables(json!({"after": null})) + .data(page( + json!([{"name": "build", "status": "COMPLETED", "conclusion": "SUCCESS"}]), + Some("p2"), + )); + fake.on_graphql("PullRequestCheckContexts") + .with_variables(json!({"after": "p2"})) + .data(page( + json!([{"name": "deploy", "status": "IN_PROGRESS"}]), + None, + )); + let detail = fake.client().detail(&pr()).await.unwrap(); + let checks: Vec<_> = detail + .checks + .iter() + .map(|c| (c.name.as_str(), c.status)) + .collect(); + assert_eq!( + checks, + [ + ("build", CheckStatus::Success), + ("deploy", CheckStatus::Pending) + ] + ); + assert!(!detail.checks_truncated); +} + +#[tokio::test] +async fn fails_when_the_head_moves_while_checks_are_walked() { + let fake = FakeTransport::new(); + let mut core = fixture("pull_request_core.json"); + core["repository"]["pullRequest"]["commits"]["nodes"][0]["commit"]["statusCheckRollup"]["contexts"] + ["pageInfo"]["hasNextPage"] = true.into(); + fake.on_graphql("PullRequestCore").data(core); + fake.on_graphql("PullRequestCheckContexts") + .data(json!({"repository": {"pullRequest": { + "headRefOid": "ffffffffffffffffffffffffffffffffffffffff", + "commits": {"nodes": []} + }}})); + let error = fake.client().detail(&pr()).await.unwrap_err(); + assert!(matches!(error, Error::Decode { .. }), "{error:?}"); +} + +// --- Checks ------------------------------------------------------------------ + +fn script_checks_endpoints(fake: &FakeTransport) { + let root = "repos/stippi/code-assistant"; + fake.on_rest(Method::Get, &format!("{root}/pulls/291")) + .once() + .respond( + FakeResponse::json(200, json!({"head": {"sha": HEAD}})).header("etag", "\"pull-1\""), + ); + fake.on_rest(Method::Get, &format!("{root}/pulls/291")) + .respond(FakeResponse::status(304)); + let runs = format!("{root}/commits/{HEAD}/check-runs?filter=all&per_page=100&page=1"); + fake.on_rest(Method::Get, &runs).once().respond( + FakeResponse::json(200, json!({"total_count": 1, "check_runs": []})) + .header("etag", "\"runs-1\""), + ); + fake.on_rest(Method::Get, &runs) + .once() + .respond(FakeResponse::status(304)); + fake.on_rest(Method::Get, &runs).respond( + FakeResponse::json(200, json!({"total_count": 2, "check_runs": []})) + .header("etag", "\"runs-2\""), + ); + let status = format!("{root}/commits/{HEAD}/status?per_page=100&page=1"); + fake.on_rest(Method::Get, &status).once().respond( + FakeResponse::json(200, json!({"state": "success"})).header("etag", "\"status-1\""), + ); + fake.on_rest(Method::Get, &status) + .respond(FakeResponse::status(304)); +} + +#[tokio::test] +async fn polls_checks_with_etags_and_reads_in_full_only_when_something_changed() { + let fake = FakeTransport::new(); + script_checks_endpoints(&fake); + fake.on_graphql("PullRequestCore") + .data(fixture("pull_request_core.json")); + let client = fake.client(); + + let first = client.checks(&pr()).await.unwrap(); + assert_eq!(first.state, PullRequestState::Open); + assert_eq!(first.checks.len(), 3); + assert_eq!(fake.operations(), ["PullRequestCore"]); + + // Nothing changed: three free 304s, no full read. + let before = fake.requests().len(); + let second = client.checks(&pr()).await.unwrap(); + assert_eq!(second, first); + let polled = &fake.requests()[before..]; + assert_eq!(polled.len(), 3); + assert!(polled.iter().all(|r| r.operation.is_none())); + assert!( + polled + .iter() + .any(|r| r.headers.get("if-none-match") == Some("\"runs-1\"")) + ); + + // A new check run: read in full again. + client.checks(&pr()).await.unwrap(); + assert_eq!(fake.operations(), ["PullRequestCore", "PullRequestCore"]); +} + +#[tokio::test] +async fn reads_checks_in_full_where_the_host_lacks_the_endpoints() { + let fake = FakeTransport::new(); + fake.on_rest(Method::Get, "repos/stippi/code-assistant/pulls/291") + .respond(FakeResponse::status(404)); + fake.on_graphql("PullRequestCore") + .data(fixture("pull_request_core.json")); + let client = fake.client(); + client.checks(&pr()).await.unwrap(); + client.checks(&pr()).await.unwrap(); + let rest: Vec<_> = fake + .requests() + .into_iter() + .filter(|r| r.operation.is_none()) + .collect(); + assert_eq!(rest.len(), 1, "the endpoint is not asked again"); + assert_eq!(fake.operations().len(), 2); +} + +// --- Activity ---------------------------------------------------------------- + +#[tokio::test] +async fn reads_the_conversation_paging_comments_and_reviews_separately() { + let fake = FakeTransport::new(); + fake.on_graphql("PullRequestActivity") + .with_variables(json!({"head": true})) + .data(fixture("pull_request_activity.json")); + fake.on_graphql("PullRequestActivity") + .with_variables(json!({ + "head": false, "withComments": true, "commentsAfter": "comments-2", "withReviews": false + })) + .data(json!({"repository": {"pullRequest": {"comments": { + "pageInfo": {"hasNextPage": false, "endCursor": "comments-3"}, + "nodes": [{"id": "IC_3", "body": "Late", "createdAt": "2026-10-30T14:00:00Z"}] + }}}})); + fake.on_graphql("PullRequestCommitStats") + .with_variables(json!({"ids": ["C_first"]})) + .data(json!({"nodes": [{"__typename": "Commit", "oid": "1111111111111111111111111111111111111111", "additions": 5, "deletions": 2}]})); + fake.on_graphql("ReviewThreads") + .data(fixture("review_threads.json")); + + let activity = fake.client().activity(&pr()).await.unwrap(); + let ids: Vec<_> = activity.comments.iter().map(|c| c.id.as_str()).collect(); + assert_eq!( + ids, + [ + "IC_1", + "PRRC_1", + "PRR_approve", + "PRRC_2", + "IC_2", + "PRRC_3", + "PRR_dismissed", + "IC_3" + ] + ); + assert_eq!(activity.commits[0].additions, Some(5)); + assert_eq!(activity.review_threads.len(), 2); + assert!(!activity.review_threads_truncated); + assert_eq!(activity.reactions[0].count, 14); + let mut operations = fake.operations(); + operations.sort(); + assert_eq!( + operations, + [ + "PullRequestActivity", + "PullRequestActivity", + "PullRequestCommitStats", + "ReviewThreads" + ] + ); +} + +#[tokio::test] +async fn keeps_the_conversation_when_the_thread_read_fails() { + let fake = FakeTransport::new(); + let mut activity = fixture("pull_request_activity.json"); + activity["repository"]["pullRequest"]["comments"]["pageInfo"]["hasNextPage"] = false.into(); + fake.on_graphql("PullRequestActivity").data(activity); + fake.on_graphql("PullRequestCommitStats") + .respond(FakeResponse::status(502)); + fake.on_graphql("ReviewThreads") + .respond(FakeResponse::status(502)); + let activity = fake.client().activity(&pr()).await.unwrap(); + assert_eq!(activity.comments.len(), 4); + assert!(activity.review_threads.is_empty()); + assert!(activity.review_threads_truncated); + assert_eq!(activity.commits[0].additions, None); +} + +#[tokio::test] +async fn follows_review_threads_and_dismissals_past_their_first_page() { + let fake = FakeTransport::new(); + let mut activity = fixture("pull_request_activity.json"); + activity["repository"]["pullRequest"]["comments"]["pageInfo"]["hasNextPage"] = false.into(); + fake.on_graphql("PullRequestActivity").data(activity); + fake.on_graphql("PullRequestCommitStats") + .data(json!({"nodes": []})); + let mut first = fixture("review_threads.json"); + first["repository"]["pullRequest"]["reviewThreads"]["pageInfo"] = + json!({"hasNextPage": true, "endCursor": "threads-2"}); + first["repository"]["pullRequest"]["reviewDismissals"]["pageInfo"] = + json!({"hasNextPage": true, "endCursor": "dismissals-2"}); + fake.on_graphql("ReviewThreads") + .with_variables(json!({"after": null})) + .data(first); + fake.on_graphql("ReviewThreads") + .with_variables(json!({"after": "threads-2"})) + .data(json!({"viewer": {"login": "me"}, "repository": {"pullRequest": {"reviewThreads": { + "pageInfo": {"hasNextPage": false}, + "nodes": [{"id": "PRRT_9", "path": "README.md", "line": 1, "diffSide": "RIGHT", + "comments": {"totalCount": 1, "nodes": [{"id": "PRRC_9", "body": "typo", "createdAt": "2026-10-30T15:00:00Z"}]}}] + }}}})); + fake.on_graphql("ReviewDismissals") + .with_variables(json!({"after": "dismissals-2"})) + .data(json!({"repository": {"pullRequest": {"timelineItems": { + "pageInfo": {"hasNextPage": false}, + "nodes": [{"dismissalMessage": "Stale", "review": {"id": "PRR_other"}}] + }}}})); + let activity = fake.client().activity(&pr()).await.unwrap(); + assert_eq!(activity.review_threads.len(), 3); + assert_eq!(activity.review_threads[2].id, "PRRT_9"); + assert!(fake.operations().contains(&"ReviewDismissals".to_string())); +} + +#[tokio::test] +async fn refuses_thread_comments_of_another_pull_request() { + let fake = FakeTransport::new(); + fake.on_graphql("ReviewThreadComments").data(json!({ + "viewer": {"login": "me"}, + "repository": {"pullRequest": {"id": "PR_291"}}, + "node": {"__typename": "PullRequestReviewThread", "pullRequest": {"id": "PR_7"}, + "comments": {"pageInfo": {"hasNextPage": false}, "nodes": []}} + })); + let error = fake + .client() + .thread_comments(&pr(), "PRRT_x", "cursor") + .await + .unwrap_err(); + assert!(matches!(error, Error::SubjectScope)); +} + +// --- Diffs and files --------------------------------------------------------- + +fn files_page(count: usize) -> Value { + Value::Array( + (0..count) + .map(|i| json!({"filename": format!("f{i}.rs"), "status": "modified", "patch": "@@ -1 +1 @@\n-a\n+b"})) + .collect(), + ) +} + +#[tokio::test] +async fn reads_the_whole_diff_at_once() { + let fake = FakeTransport::new(); + fake.on_rest(Method::Get, "repos/stippi/code-assistant/pulls/291") + .respond(FakeResponse::text(200, "diff --git a/x b/x\n")); + let page = fake.client().diff(&pr(), None).await.unwrap(); + assert_eq!(page.patch, "diff --git a/x b/x\n"); + assert_eq!(page.next_cursor, None); + assert_eq!( + fake.requests()[0].headers.get("accept"), + Some("application/vnd.github.diff") + ); +} + +#[tokio::test] +async fn pages_the_files_api_when_github_refuses_the_whole_diff() { + let fake = FakeTransport::new(); + let files = "repos/stippi/code-assistant/pulls/291/files"; + fake.on_rest(Method::Get, "repos/stippi/code-assistant/pulls/291") + .respond(FakeResponse::json( + 406, + json!({"message": "Sorry, the diff exceeded the maximum number of files (300)."}), + )); + fake.on_rest(Method::Get, &format!("{files}?per_page=100&page=1")) + .json(files_page(100)); + fake.on_rest(Method::Get, &format!("{files}?per_page=100&page=2")) + .json(files_page(3)); + let client = fake.client(); + let first = client.diff(&pr(), None).await.unwrap(); + assert_eq!(first.patch.matches("diff --git").count(), 100); + assert_eq!(first.next_cursor.as_deref(), Some("2")); + let second = client + .diff(&pr(), first.next_cursor.as_deref()) + .await + .unwrap(); + assert_eq!(second.patch.matches("diff --git").count(), 3); + assert_eq!(second.next_cursor, None); + assert!(matches!( + client.diff(&pr(), Some("../../x")).await.unwrap_err(), + Error::Invalid(_) + )); +} + +#[tokio::test] +async fn reads_a_commits_patch_from_its_files() { + let fake = FakeTransport::new(); + let sha = "abcdef1234567"; + fake.on_rest( + Method::Get, + &format!("repos/stippi/code-assistant/commits/{sha}?per_page=100&page=1"), + ) + .json(json!({"sha": sha, "files": [{"filename": "a.rs", "status": "added", "patch": "@@ -0,0 +1 @@\n+x"}]})); + let page = fake.client().commit_diff(&repo(), sha, None).await.unwrap(); + assert!( + page.patch + .starts_with("diff --git a/a.rs b/a.rs\nnew file mode 100644\n") + ); + assert!(matches!( + fake.client() + .commit_diff(&repo(), "main", None) + .await + .unwrap_err(), + Error::Invalid(_) + )); +} + +#[tokio::test] +async fn reads_both_sides_of_a_changed_file_and_refuses_binaries() { + let fake = FakeTransport::new(); + let base = "1111111111111111111111111111111111111111"; + fake.on_rest(Method::Get, "repos/stippi/code-assistant/pulls/291") + .json(json!({"base": {"sha": base}, "head": {"sha": HEAD}})); + fake.on_rest( + Method::Get, + &format!("repos/stippi/code-assistant/contents/src/old%20name.rs?ref={base}"), + ) + .respond(FakeResponse::text(200, "old")); + fake.on_rest( + Method::Get, + &format!("repos/stippi/code-assistant/contents/src/new.rs?ref={HEAD}"), + ) + .respond(FakeResponse::text(200, "new")); + fake.on_rest( + Method::Get, + &format!("repos/stippi/code-assistant/contents/logo.png?ref={HEAD}"), + ) + .respond(FakeResponse::text(200, "\u{0}PNG")); + let client = fake.client(); + let contents = client + .diff_file_contents( + &pr(), + None, + FileChange::Renamed, + "src/old name.rs", + "src/new.rs", + ) + .await + .unwrap(); + assert_eq!( + (contents.old.as_str(), contents.new.as_str()), + ("old", "new") + ); + let raw = fake + .requests() + .into_iter() + .find(|r| r.path.contains("contents/src/new.rs")) + .unwrap(); + assert_eq!( + raw.headers.get("accept"), + Some("application/vnd.github.raw+json") + ); + let error = client + .file_contents(&repo(), "logo.png", HEAD) + .await + .unwrap_err(); + assert!(matches!( + error, + Error::FileUnavailable { + reason: FileUnavailableReason::Binary, + .. + } + )); +} + +#[tokio::test] +async fn refuses_a_file_over_one_megabyte() { + let fake = FakeTransport::new(); + fake.on_rest( + Method::Get, + &format!("repos/stippi/code-assistant/contents/big.txt?ref={HEAD}"), + ) + .respond(FakeResponse::text(200, &"x".repeat(1024 * 1024 + 1))); + let error = fake + .client() + .file_contents(&repo(), "big.txt", HEAD) + .await + .unwrap_err(); + assert!(matches!( + error, + Error::FileUnavailable { + reason: FileUnavailableReason::Oversized, + .. + } + )); +} + +#[tokio::test] +async fn marks_files_viewed_aliased_a_hundred_at_a_time_with_a_cached_node_id() { + let fake = FakeTransport::new(); + fake.on_graphql("PullRequestNodeId") + .data(json!({"repository": {"pullRequest": {"id": "PR_291"}}})); + fake.on_graphql("SetFileViewed").data(json!({})); + let client = fake.client(); + let files: Vec<(String, bool)> = (0..150).map(|i| (format!("f{i}.rs"), i % 2 == 0)).collect(); + client.set_files_viewed(&pr(), &files).await.unwrap(); + client.set_files_viewed(&pr(), &files[..1]).await.unwrap(); + assert_eq!( + fake.operations(), + [ + "PullRequestNodeId", + "SetFileViewed", + "SetFileViewed", + "SetFileViewed" + ] + ); + let first = &fake.requests()[1]; + let variables = first.variables.as_ref().unwrap(); + assert_eq!(variables["path_99"], "f99.rs"); + assert_eq!(variables["viewed_1"], false); + assert_eq!(variables["pullRequestId_0"], "PR_291"); + let query = first.query.as_deref().unwrap(); + assert!(query.contains( + "a1_markFileAsViewed: markFileAsViewed(input: { pullRequestId: $pullRequestId_1, path: $path_1 }) @include(if: $viewed_1)" + )); +} + +// --- Lists ------------------------------------------------------------------- + +#[tokio::test] +async fn lists_through_search_with_an_opaque_cursor() { + let fake = FakeTransport::new(); + fake.on_graphql("PullRequestSearch") + .data(fixture("pull_request_search.json")); + let client = fake.client(); + let query = ListQuery { + state: ListState::All, + involvement: Involvement::Authored, + text: None, + limit: 2, + cursor: None, + }; + let page = client.list(&repo(), &query).await.unwrap(); + assert_eq!(page.entries.len(), 2); + assert_eq!(page.next_cursor.as_deref(), Some("s:Y3Vyc29yOjI=")); + let variables = fake.requests()[0].variables.clone().unwrap(); + assert_eq!( + variables["q"], + "is:pr author:@me sort:updated-desc repo:stippi/code-assistant" + ); + assert_eq!(variables["first"], 2); + + let next = ListQuery { + cursor: page.next_cursor, + ..query + }; + client.list(&repo(), &next).await.unwrap(); + assert_eq!( + fake.requests()[1].variables.as_ref().unwrap()["after"], + "Y3Vyc29yOjI=" + ); +} + +#[tokio::test] +async fn lists_a_repository_search_does_not_cover_without_search() { + let fake = FakeTransport::new(); + fake.on_graphql("PullRequestSearch") + .data(json!({"search": {"pageInfo": {"hasNextPage": false}, "nodes": []}})); + fake.on_rest(Method::Get, "user") + .json(json!({"id": 1, "login": "contributor"})); + let mut rows = fixture("pull_request_search.json")["search"].clone(); + rows["nodes"][0]["author"]["login"] = "contributor".into(); + fake.on_graphql("PullRequestList") + .data(json!({"repository": {"pullRequests": rows}})); + let query = ListQuery { + state: ListState::Open, + involvement: Involvement::Authored, + text: None, + limit: 1, + cursor: None, + }; + let page = fake.client().list(&repo(), &query).await.unwrap(); + assert_eq!(page.entries.len(), 1); + assert_eq!( + page.entries[0].author.as_ref().unwrap().login, + "contributor" + ); + assert_eq!(page.next_cursor.as_deref(), Some("l:Y3Vyc29yOjI=")); + let list = fake + .requests() + .into_iter() + .find(|r| r.operation.as_deref() == Some("PullRequestList")) + .unwrap(); + assert_eq!(list.variables.unwrap()["states"], json!(["OPEN"])); +} + +#[tokio::test] +async fn finds_pull_requests_by_head_branch() { + let fake = FakeTransport::new(); + fake.on_graphql("PullRequestsByHead") + .with_variables(json!({"head": "feature/pr-view"})) + .data(json!({"repository": {"pullRequests": {"nodes": [{ + "number": 291, "title": "Pull request view", "url": "https://github.com/stippi/code-assistant/pull/291", + "baseRefName": "main", "headRefName": "feature/pr-view", "headRefOid": HEAD, + "state": "MERGED", "isDraft": false, "mergedAt": "2026-10-30T10:00:00Z", "closedAt": "2026-10-30T10:00:00Z", + "updatedAt": "2026-10-30T10:00:00Z", "isCrossRepository": false, + "headRepository": {"name": "code-assistant", "nameWithOwner": "stippi/code-assistant"}, + "headRepositoryOwner": {"login": "stippi"} + }]}}})); + let found = fake + .client() + .pull_requests_for_head(&repo(), "feature/pr-view") + .await + .unwrap(); + assert_eq!(found[0].number, 291); + assert_eq!(found[0].state, PullRequestState::Merged); + assert_eq!(found[0].head_sha.as_deref(), Some(HEAD)); +} + +// --- Writes ------------------------------------------------------------------ + +#[tokio::test] +async fn submits_a_review_in_one_rest_request() { + let fake = FakeTransport::new(); + fake.on_rest( + Method::Post, + "repos/stippi/code-assistant/pulls/291/reviews", + ) + .json(json!({"id": 1})); + fake.client() + .submit_review( + &pr(), + &ReviewSubmission { + verdict: ReviewVerdict::Approve, + body: "LGTM".into(), + comments: vec![ReviewCommentDraft { + path: "src/lib.rs".into(), + line: 10, + side: DiffSide::Right, + start_line: Some(10), + start_side: Some(DiffSide::Right), + body: "nice".into(), + }], + }, + ) + .await + .unwrap(); + assert_eq!( + fake.requests()[0].body, + Some(json!({ + "event": "APPROVE", + "body": "LGTM", + "comments": [{"path": "src/lib.rs", "line": 10, "side": "RIGHT", "body": "nice"}] + })) + ); +} + +#[tokio::test] +async fn edits_a_comment_only_after_checking_it_belongs_to_the_pull_request() { + let fake = FakeTransport::new(); + fake.on_graphql("PullRequestSubject") + .with_variables(json!({"subjectId": "IC_mine"})) + .data(json!({"repository": {"pullRequest": {"id": "PR_291"}}, + "node": {"__typename": "IssueComment", "id": "IC_mine", "pullRequest": {"id": "PR_291"}}})); + fake.on_graphql("PullRequestSubject") + .with_variables(json!({"subjectId": "PRRC_other"})) + .data(json!({"repository": {"pullRequest": {"id": "PR_291"}}, + "node": {"__typename": "PullRequestReviewComment", "id": "PRRC_other", "reviewCommentPullRequest": {"id": "PR_7"}}})); + fake.on_graphql("UpdateIssueComment") + .data(json!({"updateIssueComment": {"issueComment": {"id": "IC_mine"}}})); + let client = fake.client(); + client + .update_comment(&pr(), "IC_mine", EditableCommentKind::IssueComment, "fixed") + .await + .unwrap(); + let error = client + .update_comment(&pr(), "PRRC_other", EditableCommentKind::ReviewComment, "x") + .await + .unwrap_err(); + assert!(matches!(error, Error::SubjectScope)); + assert_eq!( + fake.operations(), + [ + "PullRequestSubject", + "UpdateIssueComment", + "PullRequestSubject" + ] + ); +} + +#[tokio::test] +async fn reacts_to_the_pull_request_itself_by_its_node_id() { + let fake = FakeTransport::new(); + fake.on_graphql("PullRequestNodeId") + .data(json!({"repository": {"pullRequest": {"id": "PR_291"}}})); + fake.on_graphql("RemoveReaction").data(json!({})); + fake.client() + .set_reaction(&pr(), None, ReactionContent::ThumbsDown, false) + .await + .unwrap(); + assert_eq!( + fake.requests()[1].variables, + Some(json!({"subjectId": "PR_291", "content": "THUMBS_DOWN"})) + ); +} + +#[tokio::test] +async fn updates_only_the_fields_given() { + let fake = FakeTransport::new(); + fake.on_graphql("PullRequestNodeId") + .data(json!({"repository": {"pullRequest": {"id": "PR_291"}}})); + fake.on_graphql("UpdatePullRequest").data(json!({})); + fake.client() + .update_pull_request( + &pr(), + &PullRequestUpdate { + title: Some("New title".into()), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!( + fake.requests()[1].variables, + Some(json!({"pullRequestId": "PR_291", "title": "New title"})) + ); +} + +#[tokio::test] +async fn requests_reviewers_and_removes_labels_over_rest() { + let fake = FakeTransport::new(); + fake.on_rest( + Method::Delete, + "repos/stippi/code-assistant/pulls/291/requested_reviewers", + ) + .json(json!({})); + fake.on_rest( + Method::Delete, + "repos/stippi/code-assistant/issues/291/labels/needs%20review", + ) + .json(json!([])); + let client = fake.client(); + client + .set_reviewers_requested( + &pr(), + &[Reviewer { + id: "core".into(), + kind: ReviewerKind::Team, + }], + false, + ) + .await + .unwrap(); + client + .set_labels(&pr(), &["needs review".into()], false) + .await + .unwrap(); + assert_eq!( + fake.requests()[0].body, + Some(json!({"reviewers": [], "team_reviewers": ["core"]})) + ); +} + +// --- Actions ----------------------------------------------------------------- + +fn action_state(queue: bool, status: &str, behind_by: u64) -> Value { + json!({"repository": {"pullRequest": { + "id": "PR_291", "headRefOid": HEAD, "isMergeQueueEnabled": queue, + "mergeStateStatus": status, "baseRef": {"compare": {"behindBy": behind_by}} + }}}) +} + +#[tokio::test] +async fn arms_auto_merge_while_requirements_are_pending() { + let fake = FakeTransport::new(); + fake.on_graphql("PullRequestActionState") + .data(action_state(false, "BLOCKED", 0)); + fake.on_graphql("EnablePullRequestAutoMerge") + .data(json!({})); + fake.client() + .run_action(&pr(), Action::EnableAutoMerge(MergeMethod::Squash)) + .await + .unwrap(); + assert_eq!( + fake.requests()[1].variables, + Some(json!({"input": {"pullRequestId": "PR_291", "mergeMethod": "SQUASH"}})) + ); +} + +#[tokio::test] +async fn merges_at_once_when_nothing_is_pending_and_through_a_merge_queue_otherwise() { + let fake = FakeTransport::new(); + fake.on_graphql("PullRequestActionState") + .once() + .data(action_state(false, "CLEAN", 0)); + fake.on_graphql("PullRequestActionState") + .data(action_state(true, "CLEAN", 0)); + fake.on_graphql("MergePullRequest").data(json!({})); + fake.on_graphql("EnablePullRequestAutoMerge") + .data(json!({})); + let client = fake.client(); + client + .run_action(&pr(), Action::EnableAutoMerge(MergeMethod::Merge)) + .await + .unwrap(); + client + .run_action(&pr(), Action::Merge(MergeMethod::Rebase)) + .await + .unwrap(); + assert_eq!( + fake.operations(), + [ + "PullRequestActionState", + "MergePullRequest", + "PullRequestActionState", + "EnablePullRequestAutoMerge" + ] + ); +} + +#[tokio::test] +async fn updates_a_branch_only_when_behind_and_against_the_head_it_saw() { + let fake = FakeTransport::new(); + fake.on_graphql("PullRequestActionState") + .once() + .data(action_state(false, "BEHIND", 0)); + fake.on_graphql("PullRequestActionState") + .data(action_state(false, "BEHIND", 2)); + fake.on_graphql("UpdatePullRequestBranch").data(json!({})); + let client = fake.client(); + client + .run_action(&pr(), Action::UpdateBranch(UpdateMethod::Rebase)) + .await + .unwrap(); + client + .run_action(&pr(), Action::UpdateBranch(UpdateMethod::Rebase)) + .await + .unwrap(); + assert_eq!( + fake.operations(), + [ + "PullRequestActionState", + "PullRequestActionState", + "UpdatePullRequestBranch" + ] + ); + assert_eq!( + fake.requests()[2].variables, + Some(json!({"pullRequestId": "PR_291", "expectedHeadOid": HEAD, "updateMethod": "REBASE"})) + ); +} + +#[tokio::test] +async fn closes_through_the_pull_requests_node_id() { + let fake = FakeTransport::new(); + fake.on_graphql("PullRequestNodeId") + .data(json!({"repository": {"pullRequest": {"id": "PR_291"}}})); + fake.on_graphql("ClosePullRequest").data(json!({})); + fake.client() + .run_action(&pr(), Action::Close) + .await + .unwrap(); + assert_eq!( + fake.requests()[1].variables, + Some(json!({"pullRequestId": "PR_291"})) + ); +} + +fn branch_to_delete(state: &str, head: &str, base: &str) -> Value { + json!({"repository": {"pullRequest": { + "state": state, "headRefName": head, "baseRefName": base, "headRefOid": HEAD, + "headRepository": {"id": "R_fork"} + }}}) +} + +fn source_branch(default: &str, oid: Option<&str>) -> Value { + json!({"node": { + "__typename": "Repository", + "defaultBranchRef": {"name": default}, + "ref": oid.map(|oid| json!({"target": {"oid": oid}})) + }}) +} + +#[tokio::test] +async fn deletes_a_merged_branch_only_while_it_points_at_the_head() { + let fake = FakeTransport::new(); + fake.on_graphql("PullRequestBranchToDelete") + .data(branch_to_delete("MERGED", "feature/pr-view", "main")); + fake.on_graphql("PullRequestSourceBranch") + .once() + .data(source_branch("main", Some(HEAD))); + fake.on_graphql("PullRequestSourceBranch") + .once() + .data(source_branch( + "main", + Some("ffffffffffffffffffffffffffffffffffffffff"), + )); + fake.on_graphql("PullRequestSourceBranch") + .data(source_branch("main", None)); + fake.on_graphql("DeletePullRequestBranch").data(json!({})); + let client = fake.client(); + + client + .run_action(&pr(), Action::DeleteBranch) + .await + .unwrap(); + let delete = fake + .requests() + .into_iter() + .find(|r| r.operation.as_deref() == Some("DeletePullRequestBranch")) + .unwrap(); + assert_eq!( + delete.variables, + Some(json!({"input": {"repositoryId": "R_fork", "refUpdates": [{ + "name": "refs/heads/feature/pr-view", + "beforeOid": HEAD, + "afterOid": "0000000000000000000000000000000000000000" + }]}})) + ); + + let moved = client + .run_action(&pr(), Action::DeleteBranch) + .await + .unwrap_err(); + assert!(matches!( + moved, + Error::BranchDeletionRefused(BranchDeletionRefusal::HeadChanged) + )); + + // Already gone: nothing to do. + client + .run_action(&pr(), Action::DeleteBranch) + .await + .unwrap(); + let deletes = fake + .operations() + .iter() + .filter(|o| *o == "DeletePullRequestBranch") + .count(); + assert_eq!(deletes, 1); +} + +#[tokio::test] +async fn refuses_to_delete_the_branch_of_an_open_pull_request_or_a_default_branch() { + let fake = FakeTransport::new(); + fake.on_graphql("PullRequestBranchToDelete") + .once() + .data(branch_to_delete("OPEN", "feature/pr-view", "main")); + fake.on_graphql("PullRequestBranchToDelete") + .data(branch_to_delete("CLOSED", "develop", "main")); + fake.on_graphql("PullRequestSourceBranch") + .data(source_branch("develop", Some(HEAD))); + let client = fake.client(); + let open = client + .run_action(&pr(), Action::DeleteBranch) + .await + .unwrap_err(); + assert!(matches!( + open, + Error::BranchDeletionRefused(BranchDeletionRefusal::OpenPullRequest) + )); + let default = client + .run_action(&pr(), Action::DeleteBranch) + .await + .unwrap_err(); + assert!(matches!( + default, + Error::BranchDeletionRefused(BranchDeletionRefusal::UnsafeBranch) + )); + assert!( + !fake + .operations() + .contains(&"DeletePullRequestBranch".to_string()) + ); +} diff --git a/crates/ui_acp/src/ui.rs b/crates/ui_acp/src/ui.rs index b9f95f0a0..bf8ed6242 100644 --- a/crates/ui_acp/src/ui.rs +++ b/crates/ui_acp/src/ui.rs @@ -1029,6 +1029,9 @@ impl UserInterface for ACPUserUI { UiEvent::VoiceStatusChanged { .. } | UiEvent::VoiceTranscript { .. } => { // Voice mode is GPUI-specific. } + UiEvent::PullRequestChanged { .. } | UiEvent::PullRequestListChanged { .. } => { + // The pull request view is GPUI-specific. + } } Ok(()) } diff --git a/crates/ui_gpui/Cargo.toml b/crates/ui_gpui/Cargo.toml index ba142a13a..54808d43c 100644 --- a/crates/ui_gpui/Cargo.toml +++ b/crates/ui_gpui/Cargo.toml @@ -13,6 +13,7 @@ code_assistant_core = { path = "../code_assistant_core", default-features = fals agent_core = { path = "../agent_core" } command_executor = { path = "../command_executor" } git = { path = "../git" } +github = { path = "../github" } llm = { path = "../llm" } sandbox = { path = "../sandbox" } terminal = { path = "../terminal" } @@ -25,6 +26,9 @@ web = { path = "../web", optional = true } # shared::frame_profile reads gpui's frame timings through. gpui-kit = { version = "0.7.3", features = ["tree-sitter-languages", "profiler"] } rust-embed = { version = "8.9", features = ["include-exclude"] } +# GPUI's HTTP client, so `img(url)` loads remote images (avatars). Pinned to +# the gpui-pre snapshot gpui-kit uses. +reqwest_client = { package = "gpui-pre-reqwest-client", version = "=0.3.9" } tokio = { version = "1.48", features = ["full"] } futures = "0.3" @@ -55,5 +59,6 @@ shlex = "1.3" [dev-dependencies] code_assistant_core = { path = "../code_assistant_core", default-features = false, features = ["test-utils"] } +github = { path = "../github", features = ["test-utils"] } gpui-kit = { version = "0.7.3", features = ["test-support"] } tempfile = "3.23" diff --git a/crates/ui_gpui/assets/icons/arrow_up_down.svg b/crates/ui_gpui/assets/icons/arrow_up_down.svg new file mode 100644 index 000000000..c8d7f2fe0 --- /dev/null +++ b/crates/ui_gpui/assets/icons/arrow_up_down.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/crates/ui_gpui/assets/icons/calendar_arrow_down.svg b/crates/ui_gpui/assets/icons/calendar_arrow_down.svg new file mode 100644 index 000000000..a65ba4b82 --- /dev/null +++ b/crates/ui_gpui/assets/icons/calendar_arrow_down.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/crates/ui_gpui/assets/icons/calendar_arrow_up.svg b/crates/ui_gpui/assets/icons/calendar_arrow_up.svg new file mode 100644 index 000000000..ab5b38e0f --- /dev/null +++ b/crates/ui_gpui/assets/icons/calendar_arrow_up.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/crates/ui_gpui/assets/icons/chevrons_down_up.svg b/crates/ui_gpui/assets/icons/chevrons_down_up.svg new file mode 100644 index 000000000..1bdb362fe --- /dev/null +++ b/crates/ui_gpui/assets/icons/chevrons_down_up.svg @@ -0,0 +1 @@ + diff --git a/crates/ui_gpui/assets/icons/circle.svg b/crates/ui_gpui/assets/icons/circle.svg new file mode 100644 index 000000000..1717bb4ba --- /dev/null +++ b/crates/ui_gpui/assets/icons/circle.svg @@ -0,0 +1,13 @@ + + + diff --git a/crates/ui_gpui/assets/icons/circle_arrow_right.svg b/crates/ui_gpui/assets/icons/circle_arrow_right.svg new file mode 100644 index 000000000..1337285b3 --- /dev/null +++ b/crates/ui_gpui/assets/icons/circle_arrow_right.svg @@ -0,0 +1 @@ + diff --git a/crates/ui_gpui/assets/icons/circle_check.svg b/crates/ui_gpui/assets/icons/circle_check.svg new file mode 100644 index 000000000..14e45c58c --- /dev/null +++ b/crates/ui_gpui/assets/icons/circle_check.svg @@ -0,0 +1,14 @@ + + + + diff --git a/crates/ui_gpui/assets/icons/circle_dashed.svg b/crates/ui_gpui/assets/icons/circle_dashed.svg new file mode 100644 index 000000000..4ae53eed6 --- /dev/null +++ b/crates/ui_gpui/assets/icons/circle_dashed.svg @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/crates/ui_gpui/assets/icons/circle_dot.svg b/crates/ui_gpui/assets/icons/circle_dot.svg new file mode 100644 index 000000000..880029dbd --- /dev/null +++ b/crates/ui_gpui/assets/icons/circle_dot.svg @@ -0,0 +1,14 @@ + + + + diff --git a/crates/ui_gpui/assets/icons/circle_minus.svg b/crates/ui_gpui/assets/icons/circle_minus.svg new file mode 100644 index 000000000..2d15f5699 --- /dev/null +++ b/crates/ui_gpui/assets/icons/circle_minus.svg @@ -0,0 +1 @@ + diff --git a/crates/ui_gpui/assets/icons/circle_plus.svg b/crates/ui_gpui/assets/icons/circle_plus.svg new file mode 100644 index 000000000..af27fdb00 --- /dev/null +++ b/crates/ui_gpui/assets/icons/circle_plus.svg @@ -0,0 +1 @@ + diff --git a/crates/ui_gpui/assets/icons/circle_slash.svg b/crates/ui_gpui/assets/icons/circle_slash.svg new file mode 100644 index 000000000..506d18a5c --- /dev/null +++ b/crates/ui_gpui/assets/icons/circle_slash.svg @@ -0,0 +1,14 @@ + + + + diff --git a/crates/ui_gpui/assets/icons/circle_x.svg b/crates/ui_gpui/assets/icons/circle_x.svg new file mode 100644 index 000000000..e210b2d10 --- /dev/null +++ b/crates/ui_gpui/assets/icons/circle_x.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/crates/ui_gpui/assets/icons/clock.svg b/crates/ui_gpui/assets/icons/clock.svg new file mode 100644 index 000000000..b6ae2834e --- /dev/null +++ b/crates/ui_gpui/assets/icons/clock.svg @@ -0,0 +1,14 @@ + + + + diff --git a/crates/ui_gpui/assets/icons/external_link.svg b/crates/ui_gpui/assets/icons/external_link.svg new file mode 100644 index 000000000..a73a9ba28 --- /dev/null +++ b/crates/ui_gpui/assets/icons/external_link.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/crates/ui_gpui/assets/icons/eye.svg b/crates/ui_gpui/assets/icons/eye.svg new file mode 100644 index 000000000..e5e78706b --- /dev/null +++ b/crates/ui_gpui/assets/icons/eye.svg @@ -0,0 +1,14 @@ + + + + diff --git a/crates/ui_gpui/assets/icons/eye_off.svg b/crates/ui_gpui/assets/icons/eye_off.svg new file mode 100644 index 000000000..0083aaa87 --- /dev/null +++ b/crates/ui_gpui/assets/icons/eye_off.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/crates/ui_gpui/assets/icons/file_diff.svg b/crates/ui_gpui/assets/icons/file_diff.svg new file mode 100644 index 000000000..759e6b7cb --- /dev/null +++ b/crates/ui_gpui/assets/icons/file_diff.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/crates/ui_gpui/assets/icons/flask_conical_off.svg b/crates/ui_gpui/assets/icons/flask_conical_off.svg new file mode 100644 index 000000000..738168221 --- /dev/null +++ b/crates/ui_gpui/assets/icons/flask_conical_off.svg @@ -0,0 +1 @@ + diff --git a/crates/ui_gpui/assets/icons/git_commit_horizontal.svg b/crates/ui_gpui/assets/icons/git_commit_horizontal.svg new file mode 100644 index 000000000..058a7d1a8 --- /dev/null +++ b/crates/ui_gpui/assets/icons/git_commit_horizontal.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/crates/ui_gpui/assets/icons/layers.svg b/crates/ui_gpui/assets/icons/layers.svg new file mode 100644 index 000000000..a3b69ae31 --- /dev/null +++ b/crates/ui_gpui/assets/icons/layers.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/crates/ui_gpui/assets/icons/list_checks.svg b/crates/ui_gpui/assets/icons/list_checks.svg new file mode 100644 index 000000000..602734bab --- /dev/null +++ b/crates/ui_gpui/assets/icons/list_checks.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/crates/ui_gpui/assets/icons/list_filter.svg b/crates/ui_gpui/assets/icons/list_filter.svg new file mode 100644 index 000000000..a0c195e17 --- /dev/null +++ b/crates/ui_gpui/assets/icons/list_filter.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/crates/ui_gpui/assets/icons/loader_circle.svg b/crates/ui_gpui/assets/icons/loader_circle.svg new file mode 100644 index 000000000..cc065ca41 --- /dev/null +++ b/crates/ui_gpui/assets/icons/loader_circle.svg @@ -0,0 +1,13 @@ + + + diff --git a/crates/ui_gpui/assets/icons/maximize_2.svg b/crates/ui_gpui/assets/icons/maximize_2.svg new file mode 100644 index 000000000..790d25383 --- /dev/null +++ b/crates/ui_gpui/assets/icons/maximize_2.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/crates/ui_gpui/assets/icons/message_square.svg b/crates/ui_gpui/assets/icons/message_square.svg new file mode 100644 index 000000000..5044bc7ef --- /dev/null +++ b/crates/ui_gpui/assets/icons/message_square.svg @@ -0,0 +1,13 @@ + + + diff --git a/crates/ui_gpui/assets/icons/minimize_2.svg b/crates/ui_gpui/assets/icons/minimize_2.svg new file mode 100644 index 000000000..f15d72cd4 --- /dev/null +++ b/crates/ui_gpui/assets/icons/minimize_2.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/crates/ui_gpui/assets/icons/pen_line.svg b/crates/ui_gpui/assets/icons/pen_line.svg new file mode 100644 index 000000000..8b22d50c4 --- /dev/null +++ b/crates/ui_gpui/assets/icons/pen_line.svg @@ -0,0 +1,14 @@ + + + + diff --git a/crates/ui_gpui/assets/icons/smile_plus.svg b/crates/ui_gpui/assets/icons/smile_plus.svg new file mode 100644 index 000000000..47bbf6b0f --- /dev/null +++ b/crates/ui_gpui/assets/icons/smile_plus.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/crates/ui_gpui/assets/icons/tag.svg b/crates/ui_gpui/assets/icons/tag.svg new file mode 100644 index 000000000..7a073e9f3 --- /dev/null +++ b/crates/ui_gpui/assets/icons/tag.svg @@ -0,0 +1,14 @@ + + + + diff --git a/crates/ui_gpui/assets/icons/triangle_alert.svg b/crates/ui_gpui/assets/icons/triangle_alert.svg new file mode 100644 index 000000000..3232fb062 --- /dev/null +++ b/crates/ui_gpui/assets/icons/triangle_alert.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/crates/ui_gpui/assets/icons/user_check.svg b/crates/ui_gpui/assets/icons/user_check.svg new file mode 100644 index 000000000..40bd479a1 --- /dev/null +++ b/crates/ui_gpui/assets/icons/user_check.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/crates/ui_gpui/assets/icons/user_lock.svg b/crates/ui_gpui/assets/icons/user_lock.svg new file mode 100644 index 000000000..05536786e --- /dev/null +++ b/crates/ui_gpui/assets/icons/user_lock.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/crates/ui_gpui/assets/icons/user_plus.svg b/crates/ui_gpui/assets/icons/user_plus.svg new file mode 100644 index 000000000..935a08c05 --- /dev/null +++ b/crates/ui_gpui/assets/icons/user_plus.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/crates/ui_gpui/assets/icons/user_round.svg b/crates/ui_gpui/assets/icons/user_round.svg new file mode 100644 index 000000000..5f27e339c --- /dev/null +++ b/crates/ui_gpui/assets/icons/user_round.svg @@ -0,0 +1,14 @@ + + + + diff --git a/crates/ui_gpui/assets/icons/user_round_x.svg b/crates/ui_gpui/assets/icons/user_round_x.svg new file mode 100644 index 000000000..8805db6a7 --- /dev/null +++ b/crates/ui_gpui/assets/icons/user_round_x.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/crates/ui_gpui/assets/icons/users.svg b/crates/ui_gpui/assets/icons/users.svg new file mode 100644 index 000000000..e31958f87 --- /dev/null +++ b/crates/ui_gpui/assets/icons/users.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/crates/ui_gpui/src/app/commands.rs b/crates/ui_gpui/src/app/commands.rs index 55cf1cdb8..296a9adaf 100644 --- a/crates/ui_gpui/src/app/commands.rs +++ b/crates/ui_gpui/src/app/commands.rs @@ -6,6 +6,7 @@ //! applied to the shared state and pushed into the UI event queue, which is //! the single ingestion point for display updates on the foreground thread. +use crate::diff::prepare::PreparedFileDiff; use code_assistant_core::persistence::{DraftAttachment, NodeId}; use code_assistant_core::session::service::{AddProjectOutcome, SessionService}; use std::future::Future; @@ -899,7 +900,8 @@ impl Gpui { }); } - /// Fetch the diff for a single file selected in the Review panel. + /// Fetch the diff for a single file selected in the Review panel; its + /// line styles are computed for `theme`. pub(crate) fn cmd_get_review_file_diff( &self, session_id: String, @@ -907,6 +909,7 @@ impl Gpui { mode: code_assistant_core::session::ReviewMode, base: Option, file: git::ChangedFile, + theme: Arc, ) { let Some(service) = self.session_service() else { return; @@ -918,13 +921,13 @@ impl Gpui { let result = service .get_review_file_diff(session_id.clone(), repo_root, mode, base, file) .await; - // Hunks are computed HERE, on the background executor — the UI - // thread only ever renders prepared hunks (never runs a diff). + // Hunks and line styles are computed HERE, on the background + // executor — the UI thread only ever renders prepared hunks. let prepared = match &result { - Ok(diff) => crate::PreparedReviewDiff::from_content(&path, diff), + Ok(diff) => PreparedFileDiff::from_content(&path, diff, Some(&theme)), // An empty prepared diff still completes the view's one-at-a- // time request pipeline; the error itself is surfaced below. - Err(_) => crate::PreparedReviewDiff::from_content( + Err(_) => PreparedFileDiff::from_content( &path, &git::FileDiffContent { old_text: None, @@ -932,6 +935,7 @@ impl Gpui { is_binary: false, too_large: false, }, + None, ), }; if let Err(e) = &result { diff --git a/crates/ui_gpui/src/app/event_loop.rs b/crates/ui_gpui/src/app/event_loop.rs index 05c3c2d54..5286f4733 100644 --- a/crates/ui_gpui/src/app/event_loop.rs +++ b/crates/ui_gpui/src/app/event_loop.rs @@ -9,6 +9,7 @@ use code_assistant_core::ui::{DisplayFragment, UiEvent}; use std::collections::HashMap; use super::super::blocks::{MessageContainer, MessageRole, WakeupLink}; +use crate::main_screen::right_panel::pull_request; use gpui_kit::Entity; use tracing::{debug, trace, warn}; @@ -997,6 +998,20 @@ impl Gpui { cx.refresh(); } + // The pull request views read again what changed. + UiEvent::PullRequestChanged { pr, part } => cx.update(|cx| { + pull_request::bus::report( + pull_request::bus::PullRequestEvent::Changed { pr, part }, + cx, + ) + }), + UiEvent::PullRequestListChanged { repo } => cx.update(|cx| { + pull_request::bus::report( + pull_request::bus::PullRequestEvent::ListChanged { repo }, + cx, + ) + }), + UiEvent::RefreshCurrentSession { session_id } => { // Another process modified the session file on disk. // Use incremental refresh which diffs the active path and only diff --git a/crates/ui_gpui/src/blocks/mod.rs b/crates/ui_gpui/src/blocks/mod.rs index a5cc9b30f..1c8eb9f3d 100644 --- a/crates/ui_gpui/src/blocks/mod.rs +++ b/crates/ui_gpui/src/blocks/mod.rs @@ -11,9 +11,9 @@ use gpui_kit::component::text::{SelectionFormat, TextView, TextViewState}; use gpui_kit::prelude::*; use gpui_kit::{App, ClipboardItem, Context, Entity, FocusHandle, Pixels, SharedString, Task, px}; +use crate::diff::syntax::language_for_path; use crate::shared::collapse_animation::CollapseAnimator; use crate::tool_cards::diff_prepare::{DiffInput, PreparedDiff, SYNC_DIFF_MAX_BYTES}; -use crate::tool_cards::diff_syntax::language_for_path; use std::cell::Cell; use std::rc::Rc; diff --git a/crates/ui_gpui/src/comments/mod.rs b/crates/ui_gpui/src/comments/mod.rs index f628dea97..550c7f0dc 100644 --- a/crates/ui_gpui/src/comments/mod.rs +++ b/crates/ui_gpui/src/comments/mod.rs @@ -12,6 +12,7 @@ pub mod editor; use code_assistant_core::line_comments::LineComment; +use code_assistant_core::persistence::DraftAttachment; use gpui_kit::component::{ActiveTheme, Icon, Sizable, Size}; use gpui_kit::{ AnyElement, App, Entity, EventEmitter, Global, Pixels, Point, SharedString, Window, anchored, @@ -24,6 +25,9 @@ pub enum CommentChange { /// Add (`id` 0) or replace a comment. Upsert(LineComment), Remove(u64), + /// Add context that is no comment on lines (a pull request's review + /// comment or failing check, handed to the agent) as an attachment. + Attach(DraftAttachment), } /// Comment changes from anywhere in the UI. diff --git a/crates/ui_gpui/src/diff/chunks.rs b/crates/ui_gpui/src/diff/chunks.rs new file mode 100644 index 000000000..de254c6b3 --- /dev/null +++ b/crates/ui_gpui/src/diff/chunks.rs @@ -0,0 +1,209 @@ +//! Hunks cut into chunks: the items of a virtualized diff list. + +use super::model::DiffHunk; +use similar::ChangeTag; + +/// A slice of one hunk, small enough to be a cheap item of a virtualized +/// list. `old_start` / `new_start` are the old- and new-file line numbers at +/// the slice's first line. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiffChunk { + pub hunk: usize, + pub lines: std::ops::Range, + /// Flat index of the chunk's first line, counting all lines of all hunks + /// in order — the index line selection and break points use. + pub flat_start: usize, + pub old_start: usize, + pub new_start: usize, +} + +impl DiffChunk { + /// Whether this chunk opens a hunk other than the first — the place for + /// the "⋯" separator. + pub fn starts_later_hunk(&self) -> bool { + self.hunk > 0 && self.lines.start == 0 + } +} + +/// Hunks cut into list-item-sized chunks, plus the gutter widths (in digits) +/// all chunks of the file share. +#[derive(Debug, Clone, Default)] +pub struct ChunkedHunks { + pub chunks: Vec, + pub old_width: usize, + pub new_width: usize, +} + +/// Cut `hunks` into chunks of at most `max_lines` lines. Small hunks stay +/// whole; a big one (a newly added file is a single hunk) is split so a list +/// only builds the chunks in view. A chunk also ends after every flat line +/// index in `breaks`, so the caller can put rows of its own (a review +/// thread) directly below that line. +pub fn chunk_hunks(hunks: &[DiffHunk], max_lines: usize, breaks: &[usize]) -> ChunkedHunks { + let max_lines = max_lines.max(1); + let mut breaks = breaks.to_vec(); + breaks.sort_unstable(); + let mut breaks = breaks.into_iter().peekable(); + let mut chunks = Vec::new(); + let (mut max_old, mut max_new) = (1, 1); + let mut flat = 0; + for (hunk_ix, hunk) in hunks.iter().enumerate() { + let mut old_line = hunk.old_start; + let mut new_line = hunk.new_start; + let mut start = 0; + while start < hunk.lines.len() { + let mut end = (start + max_lines).min(hunk.lines.len()); + while breaks.next_if(|&b| b < flat + start).is_some() {} + if let Some(&b) = breaks.peek() + && b < flat + end + { + end = b - flat + 1; + } + chunks.push(DiffChunk { + hunk: hunk_ix, + lines: start..end, + flat_start: flat + start, + old_start: old_line, + new_start: new_line, + }); + let lines = &hunk.lines[start..end]; + old_line += lines.iter().filter(|l| l.tag != ChangeTag::Insert).count(); + new_line += lines.iter().filter(|l| l.tag != ChangeTag::Delete).count(); + start = end; + } + flat += hunk.lines.len(); + max_old = max_old.max(old_line); + max_new = max_new.max(new_line); + } + ChunkedHunks { + chunks, + old_width: max_old.to_string().len(), + new_width: max_new.to_string().len(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::diff::model::DiffLine; + + fn hunk_of(old_start: usize, new_start: usize, tags: &[ChangeTag]) -> DiffHunk { + DiffHunk { + old_start, + new_start, + header: None, + lines: tags.iter().map(|&tag| DiffLine::plain(tag, "x")).collect(), + } + } + + /// `(hunk, lines, flat_start)` of each chunk. + fn shape(chunked: &ChunkedHunks) -> Vec<(usize, std::ops::Range, usize)> { + chunked + .chunks + .iter() + .map(|c| (c.hunk, c.lines.clone(), c.flat_start)) + .collect() + } + + #[test] + fn chunk_hunks_keeps_small_hunks_whole() { + use ChangeTag::*; + let hunks = vec![ + hunk_of(1, 1, &[Equal, Delete, Insert, Equal]), + hunk_of(97, 98, &[Equal, Insert, Insert]), + ]; + let chunked = chunk_hunks(&hunks, 40, &[]); + assert_eq!( + chunked.chunks, + vec![ + DiffChunk { + hunk: 0, + lines: 0..4, + flat_start: 0, + old_start: 1, + new_start: 1 + }, + DiffChunk { + hunk: 1, + lines: 0..3, + flat_start: 4, + old_start: 97, + new_start: 98 + }, + ] + ); + // Last new-file line is 98 + 3 = 101 → three digits; old: 97 + 1. + assert_eq!((chunked.old_width, chunked.new_width), (2, 3)); + assert!(!chunked.chunks[0].starts_later_hunk()); + assert!(chunked.chunks[1].starts_later_hunk()); + } + + #[test] + fn chunk_hunks_splits_large_hunks_with_continuous_numbering() { + use ChangeTag::*; + // 2 deletes first: they advance only the old-file numbering. + let mut tags = vec![Delete, Delete]; + tags.extend(std::iter::repeat_n(Insert, 8)); + let chunked = chunk_hunks(&[hunk_of(20, 10, &tags)], 4, &[]); + assert_eq!( + chunked.chunks, + vec![ + DiffChunk { + hunk: 0, + lines: 0..4, + flat_start: 0, + old_start: 20, + new_start: 10 + }, + DiffChunk { + hunk: 0, + lines: 4..8, + flat_start: 4, + old_start: 22, + new_start: 12 + }, + DiffChunk { + hunk: 0, + lines: 8..10, + flat_start: 8, + old_start: 22, + new_start: 16 + }, + ] + ); + assert!(chunked.chunks.iter().all(|c| !c.starts_later_hunk())); + } + + #[test] + fn chunk_hunks_of_nothing_is_empty() { + let chunked = chunk_hunks(&[], 40, &[]); + assert!(chunked.chunks.is_empty()); + } + + #[test] + fn chunks_end_after_break_points() { + use ChangeTag::*; + let hunks = vec![ + hunk_of(1, 1, &[Equal; 10]), + hunk_of(50, 50, &[Delete, Insert, Equal]), + ]; + // Unsorted, a duplicate, one at a hunk's end (no extra cut) and one + // past the end (ignored). + let chunked = chunk_hunks(&hunks, 4, &[6, 1, 1, 9, 11, 99]); + assert_eq!( + shape(&chunked), + [ + (0, 0..2, 0), + (0, 2..6, 2), + (0, 6..7, 6), + (0, 7..10, 7), + (1, 0..2, 10), + (1, 2..3, 12), + ] + ); + // Numbering continues across the extra cuts. + assert_eq!(chunked.chunks[2].new_start, 7); + assert_eq!(chunked.chunks[5].old_start, 51); + assert_eq!(chunked.chunks[5].new_start, 51); + } +} diff --git a/crates/ui_gpui/src/diff/mod.rs b/crates/ui_gpui/src/diff/mod.rs new file mode 100644 index 000000000..7c2ea8025 --- /dev/null +++ b/crates/ui_gpui/src/diff/mod.rs @@ -0,0 +1,17 @@ +//! Diffs as the Review view and the chat's diff cards show them. +//! +//! - [`model`]: rows ([`model::DiffLine`]) in hunks, from a line diff of two +//! texts or from a parsed patch, with word-level emphasis +//! - [`chunks`]: hunks cut into the items of a virtualized list +//! - [`syntax`]: tree-sitter highlighting of both sides +//! - [`prepare`]: [`prepare::PreparedFileDiff`], all of the above for one +//! file, built off the UI thread +//! - [`render`] and [`rows`]: rows as one painted element, with a gutter of +//! new or old and new line numbers + +pub mod chunks; +pub mod model; +pub mod prepare; +pub mod render; +pub mod rows; +pub mod syntax; diff --git a/crates/ui_gpui/src/diff/model.rs b/crates/ui_gpui/src/diff/model.rs new file mode 100644 index 000000000..fe47b1fbd --- /dev/null +++ b/crates/ui_gpui/src/diff/model.rs @@ -0,0 +1,561 @@ +//! Diff rows and hunks: computed from two texts (a line diff, then a word +//! diff of small replace blocks) or taken from a parsed patch. + +use gpui_kit::SharedString; +use similar::{ChangeTag, TextDiff}; + +/// Normalize text for diff display. +/// +/// LLMs frequently emit a spurious leading `\n` at the start of `old_text` or +/// `new_text` JSON string values, and the two sides are not always consistent. +/// Additionally, format-on-save only updates `new_text` (the replace side) while +/// `old_text` keeps the raw LLM value, which can introduce trailing-newline +/// mismatches. +/// +/// We strip one leading `\n` (if present) so both sides start at real content, +/// then ensure both end with exactly one `\n` so `TextDiff` with +/// `newline_terminated(true)` treats the last line consistently. Interior blank +/// lines (intentional insertions) are preserved. +pub fn normalize_for_diff(text: &str) -> String { + let trimmed = text.strip_prefix('\n').unwrap_or(text); + let trimmed = trimmed.strip_suffix('\n').unwrap_or(trimmed); + format!("{trimmed}\n") +} + +/// One line of a computed unified diff. `text` is a [`SharedString`] so cached +/// diffs can be re-rendered every frame with cheap clones. `emphasis` marks +/// the byte ranges within `text` that changed *within* the line (word diff); +/// they get a stronger background on top of the row color. +#[derive(Debug, Clone)] +pub struct DiffLine { + pub tag: ChangeTag, + pub text: SharedString, + pub emphasis: Vec>, +} + +impl DiffLine { + /// A row without emphasis; trailing whitespace (and the line ending) is + /// dropped. + pub fn plain(tag: ChangeTag, text: &str) -> Self { + Self { + tag, + text: text.trim_end().to_string().into(), + emphasis: Vec::new(), + } + } +} + +/// Replace blocks with more lines than this (per side) skip the word-level +/// diff — pairing words across big rewrites produces noise, not signal, and +/// the word diff's cost grows with the block. Same cap as Zed. +const MAX_WORD_DIFF_LINES: usize = 8; + +/// Unchanged text between two emphasis ranges with at most this many +/// alphanumeric characters (a space, punctuation, "a", "the", "in") is not +/// worth breaking the emphasis for — the ranges merge across it. +const MAX_MERGED_ANCHOR_CHARS: usize = 3; + +/// A line with more emphasis ranges than this (after merging) is a rewrite, +/// not an edit: highlighting would fragment the whole paragraph. The block +/// then shows as plain changes. Counting ranges rather than characters keeps +/// one long insertion, however large, emphasized. +const MAX_EMPHASIS_SPANS: usize = 6; + +/// Byte ranges (per side) that a word-level diff of one replace block marks +/// as changed, relative to each side's concatenated block text. Runs our own +/// word diff instead of `similar`'s inline changes: those refuse blocks below +/// a similarity ratio, which drops exactly the case of a few sentences +/// inserted into a short paragraph. +fn word_diff_ranges( + old: &str, + new: &str, +) -> (Vec>, Vec>) { + let diff = TextDiff::configure() + .algorithm(similar::Algorithm::Patience) + .diff_unicode_words(old, new); + let bytes = |slices: &[&str]| slices.iter().map(|s| s.len()).sum::(); + let (mut old_ranges, mut new_ranges) = (Vec::new(), Vec::new()); + let (mut old_pos, mut new_pos) = (0, 0); + for op in diff.ops() { + let old_len = bytes(&diff.old_slices()[op.old_range()]); + let new_len = bytes(&diff.new_slices()[op.new_range()]); + if op.tag() != similar::DiffTag::Equal { + if old_len > 0 { + old_ranges.push(old_pos..old_pos + old_len); + } + if new_len > 0 { + new_ranges.push(new_pos..new_pos + new_len); + } + } + old_pos += old_len; + new_pos += new_len; + } + (old_ranges, new_ranges) +} + +/// Build the [`DiffLine`]s of one side of a replace block from its +/// newline-terminated lines and the block-relative changed ranges. Each +/// line's ranges are clipped to the trimmed line and merged across small +/// anchors. +fn emphasized_lines( + tag: ChangeTag, + lines: &[String], + ranges: &[std::ops::Range], + out: &mut Vec, +) { + let mut line_start = 0; + for line in lines { + let line_end = line_start + line.len(); + let text = line.trim_end(); + let mut emphasis: Vec> = ranges + .iter() + .filter(|r| r.start < line_end && r.end > line_start) + .map(|r| { + let start = r.start.max(line_start) - line_start; + let end = (r.end.min(line_end) - line_start).min(text.len()); + start..end + }) + .filter(|r| r.start < r.end) + .collect(); + emphasis.dedup_by(|next, prev| { + let anchor = &text[prev.end..next.start]; + if anchor.chars().filter(|c| c.is_alphanumeric()).count() <= MAX_MERGED_ANCHOR_CHARS { + prev.end = next.end; + true + } else { + false + } + }); + out.push(DiffLine { + tag, + text: text.to_string().into(), + emphasis, + }); + line_start = line_end; + } +} + +/// The rows of one change block — `old` lines removed, `new` lines added in +/// their place — appended to `out`, deletions first. A small block with both +/// sides gets word-level emphasis. Lines may or may not end in a newline. +pub fn push_replace_block(old: &[&str], new: &[&str], out: &mut Vec) { + if old.is_empty() || new.is_empty() || old.len().max(new.len()) > MAX_WORD_DIFF_LINES { + out.extend(old.iter().map(|l| DiffLine::plain(ChangeTag::Delete, l))); + out.extend(new.iter().map(|l| DiffLine::plain(ChangeTag::Insert, l))); + return; + } + let terminated = |lines: &[&str]| -> Vec { + lines + .iter() + .map(|l| format!("{}\n", l.strip_suffix('\n').unwrap_or(l))) + .collect() + }; + let (old, new) = (terminated(old), terminated(new)); + let (old_ranges, new_ranges) = word_diff_ranges(&old.concat(), &new.concat()); + let start = out.len(); + emphasized_lines(ChangeTag::Delete, &old, &old_ranges, out); + emphasized_lines(ChangeTag::Insert, &new, &new_ranges, out); + // The word diff pairs both sides of the block, so the rewrite verdict + // covers both: emphasis on one side with none on the other would + // suggest a deletion without a counterpart. + let rewritten = out[start..] + .iter() + .any(|l| l.emphasis.len() > MAX_EMPHASIS_SPANS); + if rewritten { + for line in &mut out[start..] { + line.emphasis.clear(); + } + } +} + +/// Expand one diff op into [`DiffLine`]s. +fn collect_change_lines<'a>( + diff: &'a TextDiff<'a, 'a, 'a, str>, + op: &similar::DiffOp, + out: &mut Vec, +) { + if op.tag() == similar::DiffTag::Replace { + push_replace_block( + &diff.old_slices()[op.old_range()], + &diff.new_slices()[op.new_range()], + out, + ); + } else { + for change in diff.iter_changes(op) { + out.push(DiffLine::plain(change.tag(), change.value())); + } + } +} + +/// Run the line diff (the expensive part: normalization + Myers diff + per-line +/// allocations). Callers that render on every frame should call this once per +/// content change and cache the result. +pub fn compute_diff_lines(old_text: &str, new_text: &str) -> Vec { + let old_norm = normalize_for_diff(old_text); + let new_norm = normalize_for_diff(new_text); + + let diff = TextDiff::configure() + .newline_terminated(true) + .diff_lines(&old_norm, &new_norm); + + let mut lines = Vec::new(); + for op in diff.ops() { + collect_change_lines(&diff, op, &mut lines); + } + lines +} + +/// One hunk of a unified diff: a run of changed lines plus surrounding +/// context, positioned at `old_start` / `new_start` (1-based) in the old and +/// new file. +#[derive(Debug, Clone)] +pub struct DiffHunk { + pub old_start: usize, + pub new_start: usize, + /// The section text of a patch's `@@` header (usually the enclosing + /// function); computed diffs have none. + pub header: Option, + pub lines: Vec, +} + +/// Like [`compute_diff_lines`], but grouped into hunks with `context` lines +/// of surrounding context (à la `git diff`) — unchanged stretches between +/// hunks are dropped entirely, which keeps the element count proportional to +/// the *changed* lines instead of the file size. +pub fn compute_diff_hunks(old_text: &str, new_text: &str, context: usize) -> Vec { + let old_norm = normalize_for_diff(old_text); + let new_norm = normalize_for_diff(new_text); + + let diff = TextDiff::configure() + .newline_terminated(true) + .diff_lines(&old_norm, &new_norm); + + diff.grouped_ops(context) + .iter() + .map(|ops| { + let mut lines = Vec::new(); + for op in ops { + collect_change_lines(&diff, op, &mut lines); + } + DiffHunk { + old_start: ops.first().map(|op| op.old_range().start + 1).unwrap_or(1), + new_start: ops.first().map(|op| op.new_range().start + 1).unwrap_or(1), + header: None, + lines, + } + }) + .collect() +} + +/// A whole file as one one-sided hunk (pure add or pure delete). No diff +/// computation — diffing against an empty side would only produce a phantom +/// deleted/inserted blank line (`normalize_for_diff` maps "" to "\n"). +pub fn single_sided_hunk(text: &str, tag: ChangeTag) -> Vec { + let lines: Vec = normalize_for_diff(text) + .lines() + .map(|l| DiffLine::plain(tag, l)) + .collect(); + if lines.is_empty() { + return Vec::new(); + } + vec![DiffHunk { + old_start: 1, + new_start: 1, + header: None, + lines, + }] +} + +/// The hunks of a parsed patch. Each run of removed lines followed by added +/// lines is a change block and gets word emphasis like a computed diff. +pub fn hunks_from_patch(hunks: &[git::PatchHunk]) -> Vec { + use git::PatchLineKind; + hunks + .iter() + .map(|hunk| { + let mut lines = Vec::with_capacity(hunk.lines.len()); + let (mut removed, mut added): (Vec<&str>, Vec<&str>) = (Vec::new(), Vec::new()); + for line in &hunk.lines { + match line.kind { + PatchLineKind::Added => added.push(&line.text), + PatchLineKind::Removed if added.is_empty() => removed.push(&line.text), + kind => { + push_replace_block(&removed, &added, &mut lines); + (removed, added) = (Vec::new(), Vec::new()); + match kind { + PatchLineKind::Removed => removed.push(&line.text), + _ => lines.push(DiffLine::plain(ChangeTag::Equal, &line.text)), + } + } + } + } + push_replace_block(&removed, &added, &mut lines); + DiffHunk { + old_start: hunk.old_start, + new_start: hunk.new_start, + header: (!hunk.section.is_empty()).then(|| hunk.section.clone().into()), + lines, + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compute_diff_hunks_groups_changes_with_context() { + let old: String = (1..=20).map(|i| format!("line {i}\n")).collect(); + let mut new_lines: Vec = (1..=20).map(|i| format!("line {i}\n")).collect(); + new_lines[2] = "changed 3\n".into(); + new_lines[15] = "changed 16\n".into(); + let new: String = new_lines.concat(); + + let hunks = compute_diff_hunks(&old, &new, 3); + assert_eq!(hunks.len(), 2, "two distant changes → two hunks"); + assert_eq!((hunks[0].old_start, hunks[0].new_start), (1, 1)); + assert_eq!((hunks[1].old_start, hunks[1].new_start), (13, 13)); + for hunk in &hunks { + let count = |tag| hunk.lines.iter().filter(|l| l.tag == tag).count(); + assert_eq!((count(ChangeTag::Delete), count(ChangeTag::Insert)), (1, 1)); + assert!( + count(ChangeTag::Equal) <= 6, + "at most 3 context lines per side" + ); + assert!(hunk.header.is_none()); + } + } + + #[test] + fn compute_diff_lines_marks_word_level_changes() { + let lines = compute_diff_lines("fn foo(alpha: u32) {}\n", "fn foo(beta: u32) {}\n"); + let del = lines.iter().find(|l| l.tag == ChangeTag::Delete).unwrap(); + let ins = lines.iter().find(|l| l.tag == ChangeTag::Insert).unwrap(); + + // The changed identifier is emphasized — not the whole line. + assert_eq!(del.emphasis.len(), 1); + assert_eq!(&del.text[del.emphasis[0].clone()], "alpha"); + assert_eq!(ins.emphasis.len(), 1); + assert_eq!(&ins.text[ins.emphasis[0].clone()], "beta"); + + // Unchanged context lines carry no emphasis. + assert!( + lines + .iter() + .filter(|l| l.tag == ChangeTag::Equal) + .all(|l| l.emphasis.is_empty()) + ); + } + + #[test] + fn word_diff_merges_ranges_split_only_by_whitespace() { + let lines = compute_diff_lines("keep foo bar keep\n", "keep qux quux keep\n"); + let ins = lines.iter().find(|l| l.tag == ChangeTag::Insert).unwrap(); + // "qux" and "quux" are separate word tokens with an unchanged space + // between them; visually that is one change. + assert_eq!(ins.emphasis.len(), 1); + assert_eq!(&ins.text[ins.emphasis[0].clone()], "qux quux"); + } + + #[test] + fn word_diff_kept_for_small_edit_in_long_paragraph() { + // A long prose paragraph (one line) with a single changed word is + // exactly where word emphasis helps most — length must not disable it. + let filler = "the data center deployment ".repeat(25); + let old = format!("{filler}serving a jurisdiction.\n"); + let new = format!("{filler}serving one or more jurisdictions.\n"); + assert!(old.len() > 512); + let lines = compute_diff_lines(&old, &new); + let ins = lines.iter().find(|l| l.tag == ChangeTag::Insert).unwrap(); + assert_eq!(ins.emphasis.len(), 1); + assert_eq!( + &ins.text[ins.emphasis[0].clone()], + "one or more jurisdictions" + ); + } + + #[test] + fn word_diff_keeps_both_sides_of_a_partially_rewritten_paragraph() { + // Deleted and inserted passages both get emphasis; small anchors + // ("service", "in") do not fragment it. The counterpart of a + // highlighted deletion must be visible on the inserted side. + let lines = compute_diff_lines( + "- **Main Tenant** — AI Core's top-level tenant, mapped one-to-one to a service instance in a BTP subaccount.\n", + "- **Main Tenant** — AI Core's top-level tenant, identified by the BTP subaccount / zone ID. Multiple AI Core service instances in one subaccount reference the same Main Tenant and Resource Groups.\n", + ); + let del = lines.iter().find(|l| l.tag == ChangeTag::Delete).unwrap(); + let ins = lines.iter().find(|l| l.tag == ChangeTag::Insert).unwrap(); + assert!(!del.emphasis.is_empty()); + assert!(!ins.emphasis.is_empty()); + assert!(ins.emphasis.len() <= 3, "{:?}", ins.emphasis); + assert!(&ins.text[ins.emphasis[0].clone()].starts_with("identified by the BTP subaccount")); + } + + #[test] + fn word_diff_keeps_long_insertion_into_short_paragraph() { + // Two short sentences become five: the insertion dwarfs the original, + // yet it is one contiguous change and must be emphasized as such. + let lines = compute_diff_lines( + "Traces stay local. Keys stay local.\n", + "Traces stay local. Every trace is written by the consumer that received it, and that consumer runs in the same jurisdiction as the deployment. Retention follows the jurisdiction's own schedule rather than a global default. Keys stay local.\n", + ); + let ins = lines.iter().find(|l| l.tag == ChangeTag::Insert).unwrap(); + assert_eq!(ins.emphasis.len(), 1, "{:?}", ins.emphasis); + assert!(&ins.text[ins.emphasis[0].clone()].starts_with("Every trace")); + } + + #[test] + fn word_diff_dropped_for_fragmented_rewrite() { + // Every other word changed, with real words as anchors in between: + // more emphasis spans than a reader can follow, so both sides fall + // back to plain changes. + let lines = compute_diff_lines( + "alpha keep1 beta keep2 gamma keep3 delta keep4 epsilon keep5 zeta keep6 eta keep7 theta\n", + "one keep1 two keep2 three keep3 four keep4 five keep5 six keep6 seven keep7 eight\n", + ); + assert!( + lines + .iter() + .filter(|l| l.tag != ChangeTag::Equal) + .all(|l| l.emphasis.is_empty()) + ); + } + + #[test] + fn replace_block_with_unterminated_lines_gets_emphasis() { + let mut out = Vec::new(); + push_replace_block(&["let a = 1;", "keep"], &["let a = 2;", "keep"], &mut out); + let tags: Vec<_> = out.iter().map(|l| l.tag).collect(); + use ChangeTag::*; + assert_eq!(tags, [Delete, Delete, Insert, Insert]); + assert_eq!(&out[0].text[out[0].emphasis[0].clone()], "1"); + assert_eq!(&out[2].text[out[2].emphasis[0].clone()], "2"); + assert!(out[1].emphasis.is_empty()); + + // One-sided and oversized blocks stay plain. + let mut out = Vec::new(); + push_replace_block(&[], &["new"], &mut out); + assert_eq!(out.len(), 1); + assert!(out[0].emphasis.is_empty()); + let many = vec!["x = 1"; MAX_WORD_DIFF_LINES + 1]; + let mut out = Vec::new(); + push_replace_block(&many, &["x = 2"], &mut out); + assert!(out.iter().all(|l| l.emphasis.is_empty())); + } + + #[test] + fn single_sided_hunk_is_one_pure_hunk() { + let hunks = single_sided_hunk("a\nb\nc\n", ChangeTag::Insert); + assert_eq!(hunks.len(), 1); + assert_eq!(hunks[0].new_start, 1); + assert!(hunks[0].lines.iter().all(|l| l.tag == ChangeTag::Insert)); + assert_eq!(hunks[0].lines.len(), 3); + } + + #[test] + fn patch_hunks_keep_positions_header_and_order() { + let patch = git::parse_hunks( + "@@ -10,5 +10,5 @@ fn main() {\n ctx\n-let a = 1;\n+let a = 2;\n ctx\n+added\n-removed\n tail \n\ + @@ -40 +40,2 @@\n x\n+y\n", + ); + let hunks = hunks_from_patch(&patch); + assert_eq!(hunks.len(), 2); + assert_eq!((hunks[0].old_start, hunks[0].new_start), (10, 10)); + assert_eq!(hunks[0].header.as_deref(), Some("fn main() {")); + assert_eq!(hunks[1].header, None); + use ChangeTag::*; + let tags: Vec<_> = hunks[0].lines.iter().map(|l| l.tag).collect(); + // An added line followed by a removed one keeps the patch's order. + assert_eq!(tags, [Equal, Delete, Insert, Equal, Insert, Delete, Equal]); + let texts: Vec<_> = hunks[0].lines.iter().map(|l| l.text.as_ref()).collect(); + assert_eq!(texts[6], "tail"); + // The change block is word-diffed. + assert_eq!( + &hunks[0].lines[2].text[hunks[0].lines[2].emphasis[0].clone()], + "2" + ); + // "+added" then "-removed" are not one block: no emphasis. + assert!(hunks[0].lines[4].emphasis.is_empty()); + } + + /// Helper: compute diff tags after the same normalization the line diff uses. + fn diff_tags(old: &str, new: &str) -> Vec<(ChangeTag, String)> { + let old_norm = normalize_for_diff(old); + let new_norm = normalize_for_diff(new); + let diff = TextDiff::configure() + .newline_terminated(true) + .diff_lines(&old_norm, &new_norm); + diff.iter_all_changes() + .map(|c| (c.tag(), c.value().trim_end().to_string())) + .collect() + } + + #[test] + fn test_diff_no_spurious_leading_green_line() { + // LLMs sometimes emit new_text with a leading \n that old_text lacks. + // The normalization should prevent a spurious "added empty line" at top. + let tags = diff_tags( + "/// comment\n#[derive(Debug)]", + "\n/// comment\n#[derive(Debug)]\npub new_field: u32,", + ); + // First line should be Equal, not Insert + assert_eq!( + tags[0].0, + ChangeTag::Equal, + "first line should be equal, got {:?}", + tags + ); + assert_eq!(tags[0].1, "/// comment"); + // The actually new line should be Insert + assert!( + tags.iter() + .any(|(t, l)| *t == ChangeTag::Insert && l == "pub new_field: u32,") + ); + } + + #[test] + fn test_diff_trailing_newline_mismatch_no_spurious_change() { + // old has no trailing \n, new does — should not produce a spurious change + let tags = diff_tags("line1\nline2", "line1\nline2\n"); + assert!( + tags.iter().all(|(t, _)| *t == ChangeTag::Equal), + "trailing newline mismatch should not produce changes: {:?}", + tags + ); + } + + #[test] + fn test_diff_normal_addition() { + let tags = diff_tags("line1\nline2\n", "line1\nline2\nnew_line\n"); + assert_eq!(tags.len(), 3); + assert_eq!(tags[0], (ChangeTag::Equal, "line1".to_string())); + assert_eq!(tags[1], (ChangeTag::Equal, "line2".to_string())); + assert_eq!(tags[2], (ChangeTag::Insert, "new_line".to_string())); + } + + #[test] + fn test_diff_intentional_blank_line_insertion_preserved() { + // Adding a blank line between two functions is a real change that must be shown. + let tags = diff_tags("fn a() {}\nfn b() {}", "fn a() {}\n\nfn b() {}"); + let inserts: Vec<_> = tags + .iter() + .filter(|(t, _)| *t == ChangeTag::Insert) + .collect(); + assert_eq!(inserts.len(), 1, "should insert one blank line: {:?}", tags); + assert_eq!(inserts[0].1, "", "the inserted line should be blank"); + } + + #[test] + fn test_diff_leading_newline_mismatch_both_directions() { + // old has leading \n, new doesn't — should not produce spurious change + let tags = diff_tags("\nline1\nline2", "line1\nline2\nnew"); + assert_eq!( + tags[0].0, + ChangeTag::Equal, + "first line should be equal: {:?}", + tags + ); + assert_eq!(tags[0].1, "line1"); + } +} diff --git a/crates/ui_gpui/src/diff/prepare.rs b/crates/ui_gpui/src/diff/prepare.rs new file mode 100644 index 000000000..e5e574524 --- /dev/null +++ b/crates/ui_gpui/src/diff/prepare.rs @@ -0,0 +1,309 @@ +//! A file's diff, made ready to render. + +use super::chunks::{ChunkedHunks, chunk_hunks}; +use super::model::{ + DiffHunk, compute_diff_hunks, hunks_from_patch, normalize_for_diff, single_sided_hunk, +}; +use super::syntax::DiffSyntax; +use gpui_kit::component::highlighter::HighlightTheme; +use similar::ChangeTag; +use std::sync::Arc; + +/// Context lines around each computed hunk (matches `git diff`'s default). +const HUNK_CONTEXT_LINES: usize = 3; + +/// Upper bound for the lines of one list item. A hunk above it (a newly +/// added file is a single hunk) is split, so a virtualized list only builds +/// what is in view. +const CHUNK_MAX_LINES: usize = 40; + +/// A file diff reduced to renderable hunks. The expensive parts (line diff, +/// syntax parse, line styles) run once on a background thread — the UI only +/// builds elements from the prepared hunks, so element counts scale with +/// changed lines and the UI thread never runs a Myers diff. `chunked` cuts +/// the hunks into the items of a virtualized list. +#[derive(Debug, Clone)] +pub struct PreparedFileDiff { + pub is_binary: bool, + pub too_large: bool, + pub hunks: Vec, + pub chunked: ChunkedHunks, + /// Both sides parsed for syntax highlighting; `None` without a grammar. + pub syntax: Option>, + pub additions: usize, + pub deletions: usize, +} + +impl PreparedFileDiff { + /// From the two whole sides of a local diff: hunks with context lines, + /// both files parsed for highlighting. With `theme`, the line styles are + /// computed right away too. CPU-heavy — call on a background thread. + pub fn from_content( + path: &str, + diff: &git::FileDiffContent, + theme: Option<&HighlightTheme>, + ) -> Self { + if diff.is_binary || diff.too_large { + return Self::placeholder(diff.is_binary, diff.too_large); + } + let old = diff.old_text.as_deref().filter(|t| !t.is_empty()); + let new = diff.new_text.as_deref().filter(|t| !t.is_empty()); + let hunks = match (old, new) { + (None, None) => Vec::new(), + (Some(old), Some(new)) => compute_diff_hunks(old, new, HUNK_CONTEXT_LINES), + // Pure add / pure delete: the whole file is the hunk. + (None, Some(new)) => single_sided_hunk(new, ChangeTag::Insert), + (Some(old), None) => single_sided_hunk(old, ChangeTag::Delete), + }; + Self::from_hunks(hunks, theme, |_| { + // The same normalization the line diff applies, so lines agree. + DiffSyntax::parse( + path, + old.map(normalize_for_diff).as_deref(), + new.map(normalize_for_diff).as_deref(), + ) + }) + } + + /// From one file of a parsed patch, which has only the hunks: each + /// hunk's sides are parsed on their own. With `theme`, the line styles + /// are computed right away too. CPU-heavy — call on a background thread. + pub fn from_patch(file: &git::FilePatch, theme: Option<&HighlightTheme>) -> Self { + if file.is_binary { + return Self::placeholder(true, false); + } + Self::from_hunks(hunks_from_patch(&file.hunks), theme, |hunks| { + DiffSyntax::parse_hunks(&file.path, hunks) + }) + } + + /// [`Self::from_patch`] without the syntax: cheap enough to show the + /// rows at once; [`highlight_patch`] adds it later. + pub fn from_patch_plain(file: &git::FilePatch) -> Self { + if file.is_binary { + return Self::placeholder(true, false); + } + Self::from_hunks(hunks_from_patch(&file.hunks), None, |_| None) + } + + fn placeholder(is_binary: bool, too_large: bool) -> Self { + Self { + is_binary, + too_large, + hunks: Vec::new(), + chunked: ChunkedHunks::default(), + syntax: None, + additions: 0, + deletions: 0, + } + } + + fn from_hunks( + hunks: Vec, + theme: Option<&HighlightTheme>, + parse: impl FnOnce(&[DiffHunk]) -> Option, + ) -> Self { + let count = |tag| { + hunks + .iter() + .flat_map(|h| &h.lines) + .filter(|l| l.tag == tag) + .count() + }; + // Nothing to show means nothing to parse. + let syntax = (!hunks.is_empty()).then(|| parse(&hunks)).flatten(); + if let (Some(syntax), Some(theme)) = (&syntax, theme) { + syntax.prime(theme); + } + Self { + is_binary: false, + too_large: false, + chunked: chunk_hunks(&hunks, CHUNK_MAX_LINES, &[]), + syntax: syntax.map(Arc::new), + additions: count(ChangeTag::Insert), + deletions: count(ChangeTag::Delete), + hunks, + } + } + + /// Show the rows highlighted with `syntax` (see [`highlight_patch`]). + pub fn set_syntax(&mut self, syntax: Option>) { + self.syntax = syntax; + } + + /// Cut the hunks into chunks again, so one ends after each flat line + /// index in `breaks` (see [`chunk_hunks`]). + pub fn rechunk(&mut self, breaks: &[usize]) { + self.chunked = chunk_hunks(&self.hunks, CHUNK_MAX_LINES, breaks); + } + + /// One row's change tag and its 1-based line numbers in the old and new + /// file, for every row of the hunks in render order (flat line index). + /// A row missing on one side has the number of that side's next line. + pub fn row_numbers(&self) -> Vec<(ChangeTag, usize, usize)> { + let mut rows = Vec::new(); + for hunk in &self.hunks { + let (mut old, mut new) = (hunk.old_start, hunk.new_start); + for line in &hunk.lines { + rows.push((line.tag, old, new)); + if line.tag != ChangeTag::Insert { + old += 1; + } + if line.tag != ChangeTag::Delete { + new += 1; + } + } + } + rows + } +} + +/// The syntax of a patch file's hunks (prepared by +/// [`PreparedFileDiff::from_patch_plain`]), with the line styles for +/// `theme` computed. CPU-heavy — call on a background thread. +pub fn highlight_patch( + path: &str, + hunks: &[DiffHunk], + theme: &HighlightTheme, +) -> Option> { + let syntax = DiffSyntax::parse_hunks(path, hunks)?; + syntax.prime(theme); + Some(Arc::new(syntax)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PATCH: &str = "\ +diff --git a/src/a.rs b/src/a.rs +index 1..2 100644 +--- a/src/a.rs ++++ b/src/a.rs +@@ -10,3 +10,4 @@ impl Foo { + fn keep() {} +- fn old() {} ++ fn new() {} ++ fn more() {} + fn tail() {} +@@ -80,2 +81,1 @@ +-fn gone() {} + fn done() {} +"; + + #[test] + fn from_patch_keeps_positions_and_counts() { + let files = git::parse_patch(PATCH); + let theme = HighlightTheme::default_dark(); + let prepared = PreparedFileDiff::from_patch(&files[0], Some(&theme)); + assert_eq!(prepared.hunks.len(), 2); + assert_eq!(prepared.hunks[0].header.as_deref(), Some("impl Foo {")); + assert_eq!( + (prepared.hunks[1].old_start, prepared.hunks[1].new_start), + (80, 81) + ); + assert_eq!((prepared.additions, prepared.deletions), (2, 2)); + assert_eq!(prepared.chunked.chunks.len(), 2); + assert_eq!(prepared.chunked.chunks[1].flat_start, 5); + let syntax = prepared.syntax.as_ref().expect("rust has a grammar"); + assert_eq!(syntax.origin(), crate::diff::syntax::SyntaxOrigin::PerHunk); + // Hunk 1's first new line is `fn done`, line 1 of its own text. + let styles = syntax.line_styles(ChangeTag::Equal, 1, 1, "fn done() {}", &theme); + assert!(!styles.is_empty()); + } + + #[test] + fn a_plain_patch_diff_is_highlighted_later() { + let files = git::parse_patch(PATCH); + let theme = HighlightTheme::default_dark(); + let mut prepared = PreparedFileDiff::from_patch_plain(&files[0]); + assert!(prepared.syntax.is_none()); + assert_eq!((prepared.additions, prepared.deletions), (2, 2)); + let syntax = highlight_patch(&files[0].path, &prepared.hunks, &theme); + prepared.set_syntax(syntax); + let syntax = prepared.syntax.as_ref().expect("rust has a grammar"); + assert!( + !syntax + .line_styles(ChangeTag::Equal, 1, 1, "fn done() {}", &theme) + .is_empty() + ); + assert!(highlight_patch("notes.unknown", &prepared.hunks, &theme).is_none()); + } + + #[test] + fn binary_patches_and_contents_are_placeholders() { + let files = git::parse_patch( + "diff --git a/a.png b/a.png\nBinary files a/a.png and b/a.png differ\n", + ); + let prepared = PreparedFileDiff::from_patch(&files[0], None); + assert!(prepared.is_binary); + assert!(prepared.hunks.is_empty() && prepared.syntax.is_none()); + + let prepared = PreparedFileDiff::from_content( + "a.rs", + &git::FileDiffContent { + old_text: None, + new_text: None, + is_binary: false, + too_large: true, + }, + None, + ); + assert!(prepared.too_large); + assert!(prepared.chunked.chunks.is_empty()); + } + + #[test] + fn from_content_parses_whole_files() { + let theme = HighlightTheme::default_dark(); + let prepared = PreparedFileDiff::from_content( + "a.rs", + &git::FileDiffContent { + old_text: Some("fn a() {}\nfn b() {}\n".into()), + new_text: Some("fn a() {}\nfn c() {}\n".into()), + is_binary: false, + too_large: false, + }, + Some(&theme), + ); + assert_eq!((prepared.additions, prepared.deletions), (1, 1)); + let syntax = prepared.syntax.as_ref().unwrap(); + assert_eq!( + syntax.origin(), + crate::diff::syntax::SyntaxOrigin::WholeFile + ); + // Whole-file line numbers: `fn c` is new line 2. + assert!( + !syntax + .line_styles(ChangeTag::Insert, 0, 2, "fn c() {}", &theme) + .is_empty() + ); + } + + #[test] + fn rechunk_ends_chunks_after_break_points() { + let files = git::parse_patch(PATCH); + let mut prepared = PreparedFileDiff::from_patch(&files[0], None); + prepared.rechunk(&[1]); + let starts: Vec<_> = prepared + .chunked + .chunks + .iter() + .map(|c| c.flat_start) + .collect(); + assert_eq!(starts, [0, 2, 5]); + } + + #[test] + fn row_numbers_count_each_side() { + let files = git::parse_patch(PATCH); + let prepared = PreparedFileDiff::from_patch(&files[0], None); + let numbers = prepared.row_numbers(); + assert_eq!(numbers.len(), 7); + assert_eq!(numbers[1], (ChangeTag::Delete, 11, 11)); + assert_eq!(numbers[3], (ChangeTag::Insert, 12, 12)); + assert_eq!(numbers[4], (ChangeTag::Equal, 12, 13)); + assert_eq!(numbers[5], (ChangeTag::Delete, 80, 81)); + assert_eq!(numbers[6], (ChangeTag::Equal, 81, 81)); + } +} diff --git a/crates/ui_gpui/src/diff/render.rs b/crates/ui_gpui/src/diff/render.rs new file mode 100644 index 000000000..928aa9f5a --- /dev/null +++ b/crates/ui_gpui/src/diff/render.rs @@ -0,0 +1,476 @@ +//! Diff rows as elements: line numbers, syntax colors and word emphasis +//! turned into one [`DiffRows`] element per chunk or snippet, plus the +//! colors diff views share. + +use super::chunks::DiffChunk; +use super::model::DiffLine; +use super::prepare::PreparedFileDiff; +use super::rows::{DiffRow, DiffRows, RowGeometry, RowSelection}; +use super::syntax::{DiffSyntax, SyntaxOrigin}; +use gpui_kit::component::theme::Theme; +use gpui_kit::{ + AnyElement, Element, Hsla, ParentElement, Pixels, SharedString, Styled, div, px, rems, +}; +use similar::ChangeTag; + +/// Which line numbers the gutter shows. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GutterNumbers { + /// New-file numbers; deleted rows leave the gutter blank. + New, + /// Old- and new-file numbers side by side, GitHub-style. + OldAndNew, +} + +/// Render one chunk of a prepared file diff with real line numbers, +/// preceded by a slim separator where a later hunk begins (or any hunk +/// with a header, which the separator then shows). With the diff's syntax, +/// rows are highlighted. `selection` makes the rows selectable. +pub(crate) fn render_diff_chunk( + diff: &PreparedFileDiff, + chunk: &DiffChunk, + numbers: GutterNumbers, + theme: &Theme, + rem_size: Pixels, + selection: Option, +) -> AnyElement { + let rows = render_diff_chunk_rows(diff, chunk, numbers, theme, rem_size, selection); + let Some(hunk) = diff.hunks.get(chunk.hunk) else { + return rows; + }; + let header = (chunk.lines.start == 0) + .then_some(hunk.header.as_ref()) + .flatten(); + if !chunk.starts_later_hunk() && header.is_none() { + return rows; + } + let (_, ctx_color) = unchanged_row_colors(theme); + let separator = div().w_full().flex().text_color(ctx_color.opacity(0.5)); + let separator = match header { + Some(header) => separator + .px_3() + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .child(SharedString::from(format!("⋯ {header}"))), + None => separator.justify_center().child("⋯"), + }; + div() + .flex() + .flex_col() + .child(separator) + .child(rows) + .into_any() +} + +/// The rows of one chunk as [`render_diff_chunk`] draws them, without the +/// separator: for views that draw hunk boundaries themselves. +pub(crate) fn render_diff_chunk_rows( + diff: &PreparedFileDiff, + chunk: &DiffChunk, + numbers: GutterNumbers, + theme: &Theme, + rem_size: Pixels, + selection: Option, +) -> AnyElement { + let Some(hunk) = diff.hunks.get(chunk.hunk) else { + return div().into_any(); + }; + let Some(lines) = hunk.lines.get(chunk.lines.clone()) else { + return div().into_any(); + }; + let syntax = diff.syntax.as_deref().map(|syntax| match syntax.origin() { + SyntaxOrigin::WholeFile => RowSyntax { + syntax, + segment: 0, + start: LineCounter { + old: chunk.old_start, + new: chunk.new_start, + }, + }, + // The chunk's offset into its hunk, where the hunk's texts start. + SyntaxOrigin::PerHunk => RowSyntax { + syntax, + segment: chunk.hunk, + start: LineCounter { + old: chunk.old_start.saturating_sub(hunk.old_start) + 1, + new: chunk.new_start.saturating_sub(hunk.new_start) + 1, + }, + }, + }); + let gutter = Gutter { + numbers, + start: LineCounter { + old: chunk.old_start, + new: chunk.new_start, + }, + old_width: diff.chunked.old_width, + new_width: diff.chunked.new_width, + }; + render_diff_rows(lines, theme, Some(gutter), rem_size, syntax, selection) +} + +/// Build the element tree for already-computed diff lines of a snippet. +/// `start_line` numbers the gutter (new-file numbers); `syntax` was parsed +/// from the snippet alone, so its lines count from 1. +pub(crate) fn render_diff_lines( + diff_lines: &[DiffLine], + theme: &Theme, + start_line: Option, + rem_size: Pixels, + syntax: Option<&DiffSyntax>, + selection: Option, +) -> AnyElement { + let gutter = start_line.map(|start| { + let new_count = diff_lines + .iter() + .filter(|l| l.tag != ChangeTag::Delete) + .count(); + Gutter { + numbers: GutterNumbers::New, + start: LineCounter { old: 1, new: start }, + old_width: 0, + new_width: (start + new_count).to_string().len(), + } + }); + render_diff_rows( + diff_lines, + theme, + gutter, + rem_size, + syntax.map(|syntax| RowSyntax { + syntax, + segment: 0, + start: LineCounter::default(), + }), + selection, + ) +} + +/// 1-based line numbers on both sides of a diff while walking its rows. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct LineCounter { + old: usize, + new: usize, +} + +impl Default for LineCounter { + fn default() -> Self { + Self { old: 1, new: 1 } + } +} + +impl LineCounter { + /// The old and new line number of a row with `tag`, where it has one + /// (deletions only in the old file, insertions only in the new); then + /// steps past the row. + fn step(&mut self, tag: ChangeTag) -> (Option, Option) { + let old = (tag != ChangeTag::Insert).then_some(self.old); + let new = (tag != ChangeTag::Delete).then_some(self.new); + self.old += old.is_some() as usize; + self.new += new.is_some() as usize; + (old, new) + } + + /// The line number of a row with `tag` in its own side (the old file for + /// deletions, the new file otherwise); then steps past the row. + fn advance(&mut self, tag: ChangeTag) -> usize { + match self.step(tag) { + (_, Some(new)) => new, + (old, None) => old.unwrap_or_default(), + } + } +} + +/// Syntax source for [`render_diff_rows`]: the parsed text (`segment`) and +/// the line numbers of the first row in it. Those are independent of the +/// gutter's numbers: a tool card parses just its snippet but numbers the +/// gutter by the file, a patch parses each hunk on its own. +struct RowSyntax<'a> { + syntax: &'a DiffSyntax, + segment: usize, + start: LineCounter, +} + +/// The line-number column of [`render_diff_rows`]: numbers from `start`, in +/// columns of the given widths (digits). +struct Gutter { + numbers: GutterNumbers, + start: LineCounter, + old_width: usize, + new_width: usize, +} + +impl Gutter { + /// Width of the gutter text, in characters. + fn chars(&self) -> usize { + match self.numbers { + GutterNumbers::New => self.new_width, + GutterNumbers::OldAndNew => self.old_width + 1 + self.new_width, + } + } + + /// The gutter text of a row with these numbers, padded to [`Self::chars`]. + fn text(&self, old: Option, new: Option) -> String { + let column = |n: Option, width: usize| match n { + Some(n) => format!("{n:>width$}"), + None => " ".repeat(width), + }; + match self.numbers { + GutterNumbers::New => column(new, self.new_width), + GutterNumbers::OldAndNew => format!( + "{} {}", + column(old, self.old_width), + column(new, self.new_width) + ), + } + } +} + +/// Shared row builder: the rows with the `gutter` (when given), as one +/// [`DiffRows`] element. +fn render_diff_rows( + diff_lines: &[DiffLine], + theme: &Theme, + gutter: Option, + rem_size: Pixels, + syntax: Option, + selection: Option, +) -> AnyElement { + let mut gutter_lines = gutter.as_ref().map(|g| g.start).unwrap_or_default(); + let mut syntax_lines = syntax.as_ref().map(|s| s.start).unwrap_or_default(); + + // Gutter width: compute in rems (~0.5rem per character + 0.75rem + // padding), then convert to rounded pixels so it aligns to the pixel grid. + let gutter_px = gutter.as_ref().map_or(px(0.), |gutter| { + rems(gutter.chars() as f32 * 0.5 + 0.75) + .to_pixels(rem_size) + .round() + }); + let geometry = RowGeometry { + gutter_width: gutter_px, + gutter_left: rems(0.375).to_pixels(rem_size), + // Without a gutter the text keeps the card's horizontal padding. + content_left: rems(if gutter.is_some() { 0.25 } else { 0.75 }).to_pixels(rem_size), + content_right: rems(0.75).to_pixels(rem_size), + }; + + let rows = diff_lines + .iter() + .map(|dl| { + let (row_bg, gutter_fg) = row_colors(dl.tag, theme); + let (old_num, new_num) = gutter_lines.step(dl.tag); + let syntax_line = syntax_lines.advance(dl.tag); + + let gutter = gutter.as_ref().map(|gutter| { + ( + SharedString::from(gutter.text(old_num, new_num)), + gutter_fg.opacity(0.5), + ) + }); + + // Word-level changes get a stronger background via text-run + // highlights, which wrap with the text. Syntax colors come + // first, the word emphasis layers on top. + let syntax_styles = syntax.as_ref().map_or_else(Vec::new, |s| { + s.syntax.line_styles( + dl.tag, + s.segment, + syntax_line, + &dl.text, + &theme.highlight_theme, + ) + }); + let highlights = if dl.emphasis.is_empty() && syntax_styles.is_empty() { + Vec::new() + } else { + let word_bg = word_emphasis_bg(dl.tag, theme); + let emphasis = dl.emphasis.iter().map(|range| { + ( + range.clone(), + gpui_kit::HighlightStyle { + background_color: Some(word_bg), + ..Default::default() + }, + ) + }); + gpui_kit::combine_highlights(syntax_styles, emphasis).collect() + }; + DiffRow { + text: dl.text.clone(), + highlights, + background: row_bg, + edge: row_edge(dl.tag, theme), + color: row_text_color(dl.tag, syntax.is_some(), theme), + gutter, + } + }) + .collect(); + + let element = DiffRows::new(rows, geometry); + match selection { + Some(selection) => element.selectable(selection).into_any(), + None => element.into_any(), + } +} + +// --------------------------------------------------------------------------- +// Theme colors +// --------------------------------------------------------------------------- + +fn rgb_color(r: u8, g: u8, b: u8) -> Hsla { + rgba_color(r, g, b, 0xFF) +} + +fn rgba_color(r: u8, g: u8, b: u8, a: u8) -> Hsla { + gpui_kit::Rgba { + r: r as f32 / 255.0, + g: g as f32 / 255.0, + b: b as f32 / 255.0, + a: a as f32 / 255.0, + } + .into() +} + +/// Background of a diff body, behind the rows. +pub(crate) fn diff_body_bg(theme: &Theme) -> Hsla { + if theme.is_dark() { + gpui_kit::hsla(0.0, 0.0, 0.08, 1.0) + } else { + gpui_kit::hsla(0.0, 0.0, 0.97, 1.0) + } +} + +pub(crate) fn deleted_row_colors(theme: &Theme) -> (Option, Hsla) { + if theme.is_dark() { + ( + Some(rgba_color(0x80, 0x20, 0x20, 0x60)), + rgb_color(0xFF, 0xBB, 0xBB), + ) + } else { + ( + Some(rgba_color(0xDD, 0x55, 0x55, 0x30)), + rgb_color(0x88, 0x00, 0x00), + ) + } +} + +pub(crate) fn added_row_colors(theme: &Theme) -> (Option, Hsla) { + if theme.is_dark() { + ( + Some(rgba_color(0x20, 0x60, 0x20, 0x60)), + rgb_color(0xBB, 0xFF, 0xBB), + ) + } else { + ( + Some(rgba_color(0x33, 0xAA, 0x33, 0x25)), + rgb_color(0x00, 0x66, 0x00), + ) + } +} + +fn unchanged_row_colors(theme: &Theme) -> (Option, Hsla) { + if theme.is_dark() { + (None, rgba_color(0xFF, 0xFF, 0xFF, 0x99)) + } else { + (None, rgba_color(0x33, 0x33, 0x33, 0x99)) + } +} + +fn row_colors(tag: ChangeTag, theme: &Theme) -> (Option, Hsla) { + match tag { + ChangeTag::Equal => unchanged_row_colors(theme), + ChangeTag::Delete => deleted_row_colors(theme), + ChangeTag::Insert => added_row_colors(theme), + } +} + +/// The bar at the left edge of added and removed rows. +fn row_edge(tag: ChangeTag, theme: &Theme) -> Option { + match tag { + ChangeTag::Insert => Some(theme.success.opacity(0.7)), + ChangeTag::Delete => Some(theme.danger.opacity(0.7)), + ChangeTag::Equal => None, + } +} + +/// Text color of a row. Syntax highlighted rows use the syntax theme's neutral +/// foreground, since red/green tinted text fights with the syntax colors; the +/// row background still tells the change. +fn row_text_color(tag: ChangeTag, highlighted: bool, theme: &Theme) -> Hsla { + highlighted + .then_some(theme.highlight_theme.style.editor_foreground) + .flatten() + .unwrap_or_else(|| row_colors(tag, theme).1) +} + +/// Background for word-level (intra-line) changes: a stronger tint layered on +/// top of the row's add/delete background. +pub(crate) fn word_emphasis_bg(tag: ChangeTag, theme: &Theme) -> Hsla { + match (tag, theme.is_dark()) { + (ChangeTag::Delete, true) => rgba_color(0xC0, 0x38, 0x38, 0x70), + (ChangeTag::Delete, false) => rgba_color(0xE0, 0x60, 0x60, 0x60), + (ChangeTag::Insert, true) => rgba_color(0x38, 0xA0, 0x38, 0x70), + (ChangeTag::Insert, false) => rgba_color(0x40, 0xB8, 0x40, 0x50), + (ChangeTag::Equal, _) => gpui_kit::transparent_black(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn line_counter_numbers_each_row_in_its_own_side() { + let mut lines = LineCounter { old: 10, new: 20 }; + let numbers: Vec = [ + ChangeTag::Equal, + ChangeTag::Delete, + ChangeTag::Delete, + ChangeTag::Insert, + ChangeTag::Equal, + ] + .into_iter() + .map(|tag| lines.advance(tag)) + .collect(); + assert_eq!(numbers, [20, 11, 12, 21, 22]); + assert_eq!(lines, LineCounter { old: 14, new: 23 }); + } + + #[test] + fn gutter_shows_new_or_both_numbers() { + let gutter = |numbers| Gutter { + numbers, + start: LineCounter::default(), + old_width: 2, + new_width: 3, + }; + let new = gutter(GutterNumbers::New); + assert_eq!(new.chars(), 3); + assert_eq!(new.text(Some(9), Some(10)), " 10"); + assert_eq!(new.text(Some(9), None), " "); + + let both = gutter(GutterNumbers::OldAndNew); + assert_eq!(both.chars(), 6); + assert_eq!(both.text(Some(9), Some(10)), " 9 10"); + assert_eq!(both.text(Some(9), None), " 9 "); + assert_eq!(both.text(None, Some(100)), " 100"); + } + + #[test] + fn highlighted_rows_use_the_neutral_syntax_foreground() { + use gpui_kit::component::theme::ThemeColor; + let mut theme = Theme::from(&*ThemeColor::light()); + theme.highlight_theme = crate::shared::theme::syntax_theme(theme.mode); + let foreground = theme.highlight_theme.style.editor_foreground.unwrap(); + + for tag in [ChangeTag::Equal, ChangeTag::Delete, ChangeTag::Insert] { + assert_eq!(row_text_color(tag, true, &theme), foreground); + } + // Without syntax the rows keep their tinted text. + assert_eq!( + row_text_color(ChangeTag::Insert, false, &theme), + added_row_colors(&theme).1 + ); + } +} diff --git a/crates/ui_gpui/src/tool_cards/diff_rows.rs b/crates/ui_gpui/src/diff/rows.rs similarity index 98% rename from crates/ui_gpui/src/tool_cards/diff_rows.rs rename to crates/ui_gpui/src/diff/rows.rs index 655120938..b82771aff 100644 --- a/crates/ui_gpui/src/tool_cards/diff_rows.rs +++ b/crates/ui_gpui/src/diff/rows.rs @@ -20,6 +20,8 @@ pub(crate) struct DiffRow { /// Sorted, non-overlapping, within `text`. pub highlights: Vec<(Range, HighlightStyle)>, pub background: Option, + /// A thin bar at the left edge (added / removed rows). + pub edge: Option, pub color: Hsla, /// Line number text (already padded to the gutter width) and its color. pub gutter: Option<(SharedString, Hsla)>, @@ -282,6 +284,12 @@ fn paint_rows( background, )); } + if let Some(edge) = row.edge { + window.paint_quad(fill( + Bounds::new(point(bounds.origin.x, y), size(px(2.), row_layout.height)), + edge, + )); + } // Selection sits above the add/delete tint but below the glyphs, so // the selected text stays readable. if let Some(sel) = selection @@ -551,6 +559,7 @@ mod tests { text: SharedString::from(text.to_string()), highlights: Vec::new(), background: Some(red()), + edge: None, color: white(), gutter: Some(("12".into(), white())), }) diff --git a/crates/ui_gpui/src/diff/syntax.rs b/crates/ui_gpui/src/diff/syntax.rs new file mode 100644 index 000000000..fb7b05e53 --- /dev/null +++ b/crates/ui_gpui/src/diff/syntax.rs @@ -0,0 +1,712 @@ +//! Syntax highlighting for diff rows. Both sides of a diff are parsed once +//! (tree-sitter, via gpui-component's [`SyntaxHighlighter`]) on a background +//! thread, and the styles of every line are computed once per theme, ideally +//! right there ([`DiffSyntax::prime`]): a tree-sitter query per row and frame +//! showed up as up to 7% of a frame's draw time. Rendering then only slices +//! the cached line styles. +//! +//! A side is either one text (a whole file, or a tool card's snippet) or, +//! for a patch, which has no whole files, one text per hunk +//! ([`SyntaxOrigin`]). Hunks are parsed on their own, so a construct cut off +//! at one hunk's end cannot color the next, but share one highlighter per +//! side: building one compiles the grammar's queries, the costly part (a +//! tenth of a second per Rust side in a debug build). So once a side's +//! styles are all cached, its highlighter goes back to a per-thread pool +//! for the next diff parsed there ([`take_highlighter`]). + +use super::model::DiffHunk; +use gpui_kit::HighlightStyle; +use gpui_kit::component::Rope; +use gpui_kit::component::ThemeMode; +use gpui_kit::component::highlighter::{ + HighlightTheme, Language, LanguageRegistry, SyntaxHighlighter, +}; +use similar::ChangeTag; +use std::cell::RefCell; +use std::collections::HashMap; +use std::ops::Range; +use std::sync::Mutex; + +/// Highlighters kept per language and thread, at most this many each. +const POOLED_PER_LANGUAGE: usize = 4; + +thread_local! { + static POOL: RefCell>> = + RefCell::new(HashMap::new()); +} + +/// A highlighter for `language`: a pooled one, else a new one. +fn take_highlighter(language: &'static str) -> SyntaxHighlighter { + POOL.with(|pool| pool.borrow_mut().get_mut(language)?.pop()) + .unwrap_or_else(|| SyntaxHighlighter::new(language)) +} + +/// Keep `highlighter` for the next side of `language` parsed on this thread. +fn return_highlighter(language: &'static str, highlighter: SyntaxHighlighter) { + POOL.with(|pool| { + let mut pool = pool.borrow_mut(); + let pooled = pool.entry(language).or_default(); + if pooled.len() < POOLED_PER_LANGUAGE { + pooled.push(highlighter); + } + }); +} + +/// Styles of one line, as ranges relative to the line's start. +type LineStyles = Vec<(Range, HighlightStyle)>; + +/// Identifies the theme a [`StyleCache`] was computed for. The address alone +/// could be reused by a later theme, so name and appearance are compared too. +#[derive(Debug, Clone, PartialEq, Eq)] +struct ThemeKey { + address: usize, + name: String, + appearance: ThemeMode, +} + +impl ThemeKey { + fn of(theme: &HighlightTheme) -> Self { + Self { + address: theme as *const HighlightTheme as usize, + name: theme.name.clone(), + appearance: theme.appearance, + } + } +} + +/// The styles of every line of one side, per segment, for one theme. +struct StyleCache { + theme: ThemeKey, + segments: Vec>, +} + +/// The grammar name for a file, if gpui-component ships one for it. +pub fn language_for_path(path: &str) -> Option<&'static str> { + let file_name = path.rsplit('/').next().unwrap_or(path); + let name = match file_name { + "Makefile" | "makefile" | "GNUmakefile" => "make", + "CMakeLists.txt" => "cmake", + "Cargo.lock" => "toml", + _ => match file_name.rsplit_once('.')?.1 { + "rs" => "rust", + "py" => "python", + "js" | "mjs" | "cjs" | "jsx" => "javascript", + "ts" | "mts" | "cts" => "typescript", + "tsx" => "tsx", + "c" | "h" => "c", + "cc" | "cpp" | "cxx" | "hpp" | "hh" | "hxx" => "cpp", + "cs" => "csharp", + "sh" | "bash" | "zsh" => "bash", + "md" | "mdx" => "markdown", + "yml" | "yaml" => "yaml", + "htm" | "html" => "html", + "css" | "scss" => "css", + "kt" | "kts" => "kotlin", + "rb" => "ruby", + "ex" | "exs" => "elixir", + "json" | "jsonc" => "json", + "go" => "go", + "java" => "java", + "lua" => "lua", + "php" => "php", + "proto" => "proto", + "scala" => "scala", + "sql" => "sql", + "svelte" => "svelte", + "swift" => "swift", + "toml" => "toml", + "zig" => "zig", + "cmake" => "cmake", + "graphql" | "gql" => "graphql", + _ => return None, + }, + }; + // Grammars are feature-gated in gpui-component; only claim what is built in. + (Language::from_str(name) != Language::Plain).then_some(name) +} + +/// Fence tags LLMs commonly write that gpui-component does not know, mapped to +/// a grammar that highlights them well enough. +const LANGUAGE_ALIASES: &[(&str, &str)] = &[ + ("shell", "bash"), + ("zsh", "bash"), + ("console", "bash"), + ("shellscript", "bash"), + ("dockerfile", "bash"), + ("jsx", "javascript"), + ("mjs", "javascript"), + ("golang", "go"), + ("h", "c"), + ("hpp", "cpp"), + ("cc", "cpp"), + ("cxx", "cpp"), + ("py3", "python"), + ("python3", "python"), + ("htm", "html"), + ("json5", "json"), +]; + +/// Make Markdown code blocks tagged with one of [`LANGUAGE_ALIASES`] highlight. +pub fn register_language_aliases() { + let registry = LanguageRegistry::singleton(); + for (alias, language) in LANGUAGE_ALIASES { + if let Some(config) = registry.language(language) { + registry.register(alias, &config); + } + } +} + +/// One text of a side: the whole side, or one hunk's lines of it. +/// `line_starts[n]` is the byte offset of line `n + 1`. +struct Segment { + text: String, + line_starts: Vec, +} + +impl Segment { + fn new(text: String) -> Self { + let line_starts = std::iter::once(0) + .chain(text.match_indices('\n').map(|(ix, _)| ix + 1)) + .collect(); + Self { text, line_starts } + } + + fn line_end(&self, ix: usize) -> usize { + self.line_starts + .get(ix + 1) + .map_or(self.text.len(), |next| next - 1) + } + + /// One query over the whole text, cut into lines. Styles that are the + /// default are left out, like empty ranges. `highlighter` must hold this + /// segment's tree. + fn style_lines( + &self, + highlighter: &SyntaxHighlighter, + theme: &HighlightTheme, + ) -> Vec { + let mut lines: Vec = vec![Vec::new(); self.line_starts.len()]; + let mut line_ix = 0; + for (range, style) in highlighter.styles(&(0..self.text.len()), theme) { + if style == HighlightStyle::default() || range.is_empty() { + continue; + } + while line_ix + 1 < self.line_starts.len() + && self.line_starts[line_ix + 1] <= range.start + { + line_ix += 1; + } + // A style can span lines (block comments, strings): clip it to each. + let mut ix = line_ix; + while ix < self.line_starts.len() && self.line_starts[ix] < range.end { + let start = self.line_starts[ix]; + let clipped = range.start.max(start)..range.end.min(self.line_end(ix)); + if clipped.start < clipped.end { + lines[ix].push((clipped.start - start..clipped.end - start, style)); + } + ix += 1; + } + } + lines + } + + /// The styles of `line_no` queried on their own, as they were computed + /// before the cache; the cache has to give the same answer. + #[cfg(test)] + fn line_styles_uncached( + &self, + highlighter: &SyntaxHighlighter, + line_no: usize, + theme: &HighlightTheme, + ) -> LineStyles { + let range = self.line_starts[line_no - 1]..self.line_end(line_no - 1); + highlighter + .styles(&range, theme) + .into_iter() + .filter_map(|(r, style)| { + let r = r.start.max(range.start) - range.start..r.end.min(range.end) - range.start; + (r.start < r.end && style != HighlightStyle::default()).then_some((r, style)) + }) + .collect() + } +} + +/// The parser of one side and the styles it gave, under one lock: styling +/// another segment means parsing it first. +struct SideState { + language: &'static str, + /// Held until every segment's styles are cached. + highlighter: Option, + /// The segment the highlighter's tree is for. + parsed: Option, + /// Filled by [`SyntaxSide::prime`] or on the first query; replaced when + /// the theme changes. + cache: Option, +} + +impl SideState { + /// The styles of every segment; the highlighter goes back to the pool. + fn style_all(&mut self, segments: &[Segment], theme: &HighlightTheme) -> StyleCache { + let mut highlighter = self + .highlighter + .take() + .unwrap_or_else(|| take_highlighter(self.language)); + let segments = segments + .iter() + .enumerate() + .map(|(ix, segment)| { + if self.parsed != Some(ix) { + highlighter.update(None, &Rope::from_str(&segment.text), None); + self.parsed = Some(ix); + } + segment.style_lines(&highlighter, theme) + }) + .collect(); + self.parsed = None; + return_highlighter(self.language, highlighter); + StyleCache { + theme: ThemeKey::of(theme), + segments, + } + } +} + +/// One parsed side of a diff. +struct SyntaxSide { + segments: Vec, + state: Mutex, +} + +impl SyntaxSide { + /// Parses the first segment right away; the others are parsed when + /// their styles are computed. + fn parse(language: &'static str, texts: Vec) -> Self { + let segments: Vec = texts.into_iter().map(Segment::new).collect(); + let mut highlighter = take_highlighter(language); + let parsed = segments.first().map(|first| { + highlighter.update(None, &Rope::from_str(&first.text), None); + 0 + }); + Self { + segments, + state: Mutex::new(SideState { + language, + highlighter: Some(highlighter), + parsed, + cache: None, + }), + } + } + + /// Compute and keep the styles of every line for `theme`. + fn prime(&self, theme: &HighlightTheme) { + let mut state = self.state.lock().unwrap(); + let cache = state.style_all(&self.segments, theme); + state.cache = Some(cache); + } + + fn line_styles( + &self, + segment: usize, + line_no: usize, + line: &str, + theme: &HighlightTheme, + ) -> LineStyles { + let Some(rest) = self.segments.get(segment).and_then(|s| { + let start = *s.line_starts.get(line_no.checked_sub(1)?)?; + Some(&s.text[start..]) + }) else { + return Vec::new(); + }; + // Diff rows and parsed text split lines independently; only style a + // row that really is this line, so ranges always fit its text. + if line.is_empty() || !rest.starts_with(line) { + return Vec::new(); + } + let key = ThemeKey::of(theme); + let mut state = self.state.lock().unwrap(); + if state.cache.as_ref().is_none_or(|cache| cache.theme != key) { + let cache = state.style_all(&self.segments, theme); + state.cache = Some(cache); + } + let Some(cache) = &state.cache else { + return Vec::new(); + }; + cache.segments[segment][line_no - 1] + .iter() + .filter(|(r, _)| r.start < line.len()) + .map(|(r, style)| (r.start..r.end.min(line.len()), *style)) + .collect() + } +} + +/// What a [`DiffSyntax`] parsed, which decides where a row's line is. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SyntaxOrigin { + /// One text per side, numbered like the file (or the snippet) the rows + /// show. + WholeFile, + /// One text per hunk and side: rows count from their hunk's first line. + PerHunk, +} + +/// Parsed old and new side of one file's diff. +pub struct DiffSyntax { + old: Option, + new: Option, + origin: SyntaxOrigin, +} + +impl std::fmt::Debug for DiffSyntax { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DiffSyntax") + .field("origin", &self.origin) + .finish_non_exhaustive() + } +} + +impl DiffSyntax { + /// Parse both sides of `path`'s diff, as given: they must split into the + /// same lines as the rows. `None` when there is no grammar for the file. + /// CPU-heavy for large files — call on a background thread. + pub fn parse(path: &str, old: Option<&str>, new: Option<&str>) -> Option { + let language = language_for_path(path)?; + let side = + |text: Option<&str>| text.map(|t| SyntaxSide::parse(language, vec![t.to_string()])); + Some(Self { + old: side(old), + new: side(new), + origin: SyntaxOrigin::WholeFile, + }) + } + + /// Parse each hunk's old and new lines on their own, for diffs that only + /// have the hunks (a patch). A side no hunk has lines on is left out. + pub fn parse_hunks(path: &str, hunks: &[DiffHunk]) -> Option { + let language = language_for_path(path)?; + let side = |skip: similar::ChangeTag| { + let texts: Vec = hunks + .iter() + .map(|hunk| { + hunk.lines + .iter() + .filter(|l| l.tag != skip) + .map(|l| format!("{}\n", l.text)) + .collect() + }) + .collect(); + texts + .iter() + .any(|t| !t.is_empty()) + .then(|| SyntaxSide::parse(language, texts)) + }; + Some(Self { + old: side(ChangeTag::Insert), + new: side(ChangeTag::Delete), + origin: SyntaxOrigin::PerHunk, + }) + } + + pub fn origin(&self) -> SyntaxOrigin { + self.origin + } + + /// Compute the styles of every line of both sides for `theme` now, so + /// the first frames do not have to. Call on the background thread that + /// parsed. + pub fn prime(&self, theme: &HighlightTheme) { + for side in [&self.old, &self.new].into_iter().flatten() { + side.prime(theme); + } + } + + /// Syntax styles for one diff row, as ranges into `line`. `segment` is + /// the hunk for [`SyntaxOrigin::PerHunk`], else 0; `line_no` is 1-based + /// in the row's own side (the old text for deletions, the new text + /// otherwise) of that segment. Cheap after the first call per theme. + pub fn line_styles( + &self, + tag: ChangeTag, + segment: usize, + line_no: usize, + line: &str, + theme: &HighlightTheme, + ) -> Vec<(Range, HighlightStyle)> { + let side = match tag { + ChangeTag::Delete => &self.old, + ChangeTag::Equal | ChangeTag::Insert => &self.new, + }; + side.as_ref() + .map(|side| side.line_styles(segment, line_no, line, theme)) + .unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn language_is_detected_from_extension_and_file_name() { + assert_eq!(language_for_path("crates/ui/src/lib.rs"), Some("rust")); + assert_eq!(language_for_path("Cargo.lock"), Some("toml")); + assert_eq!(language_for_path("build/Makefile"), Some("make")); + assert_eq!(language_for_path("notes.txt"), None); + assert_eq!(language_for_path("LICENSE"), None); + } + + #[test] + fn common_fence_tags_resolve_to_a_grammar() { + register_language_aliases(); + let registry = LanguageRegistry::singleton(); + for (tag, language) in [ + ("shell", "bash"), + ("zsh", "bash"), + ("console", "bash"), + ("jsx", "javascript"), + ("golang", "go"), + ("h", "c"), + ("hpp", "cpp"), + ("dockerfile", "bash"), + // Built into gpui-component already. + ("rs", "rust"), + ("py", "python"), + ("yml", "yaml"), + ] { + let config = registry.language(tag); + assert_eq!( + config.as_ref().map(|c| c.name.as_ref()), + Some(language), + "fence tag {tag}" + ); + assert!(config.unwrap().has_grammar()); + } + assert!(registry.language("no-such-language").is_none()); + } + + #[test] + fn no_grammar_means_no_syntax() { + assert!(DiffSyntax::parse("notes.txt", Some("a"), Some("b")).is_none()); + } + + #[test] + fn line_styles_are_relative_to_the_row_and_pick_the_right_side() { + let old = "fn old_name() {}\n"; + let new = "// intro\nfn new_name() {}\n"; + let syntax = DiffSyntax::parse("a.rs", Some(old), Some(new)).unwrap(); + let theme = HighlightTheme::default_dark(); + + let keyword_at_start = + |styles: &[(Range, HighlightStyle)]| styles.iter().any(|(r, _)| *r == (0..2)); + + // New line 2 is the `fn`; on the old side it is line 1. + let inserted = syntax.line_styles(ChangeTag::Insert, 0, 2, "fn new_name() {}", &theme); + assert!(keyword_at_start(&inserted), "{inserted:?}"); + let deleted = syntax.line_styles(ChangeTag::Delete, 0, 1, "fn old_name() {}", &theme); + assert!(keyword_at_start(&deleted), "{deleted:?}"); + + for (range, _) in inserted.iter().chain(&deleted) { + assert!(range.end <= "fn new_name() {}".len()); + } + } + + #[test] + fn rows_that_do_not_match_the_parsed_line_get_no_styles() { + let syntax = DiffSyntax::parse("a.rs", None, Some("fn main() {}\n")).unwrap(); + let theme = HighlightTheme::default_dark(); + assert!( + syntax + .line_styles(ChangeTag::Insert, 0, 1, "let x = 1;", &theme) + .is_empty() + ); + assert!( + syntax + .line_styles(ChangeTag::Insert, 0, 9, "fn main() {}", &theme) + .is_empty() + ); + assert!( + syntax + .line_styles(ChangeTag::Insert, 0, 0, "fn main() {}", &theme) + .is_empty() + ); + // No old side was parsed. + assert!( + syntax + .line_styles(ChangeTag::Delete, 0, 1, "fn main() {}", &theme) + .is_empty() + ); + } + + #[test] + fn a_styled_side_hands_its_highlighter_on() { + let pooled = || POOL.with(|pool| pool.borrow().get("rust").map_or(0, Vec::len)); + let theme = HighlightTheme::default_dark(); + let first = SyntaxSide::parse("rust", vec!["fn a() {}\n".into(), "struct B;\n".into()]); + let before = pooled(); + first.prime(&theme); + assert_eq!(pooled(), before + 1, "back in the pool once styled"); + let second = SyntaxSide::parse("rust", vec!["fn c() {}\n".into()]); + assert_eq!(pooled(), before, "taken by the next side"); + // Styles stay right for a highlighter that parsed another text, and + // a new theme parses again with a pooled one. + second.prime(&theme); + assert!(!second.line_styles(0, 1, "fn c() {}", &theme).is_empty()); + let light = HighlightTheme::default_light(); + assert!(!first.line_styles(1, 1, "struct B;", &light).is_empty()); + assert!(!first.line_styles(0, 1, "fn a() {}", &light).is_empty()); + } + + #[test] + fn cached_line_styles_match_per_line_queries() { + let text = + "/* a comment\n over two lines */\nfn main() {\n let s = \"multi\nline\";\n}\n"; + let side = SyntaxSide::parse("rust", vec![text.to_string()]); + let theme = HighlightTheme::default_dark(); + let state = side.state.lock().unwrap(); + let highlighter = state.highlighter.as_ref().unwrap(); + let segment = &side.segments[0]; + let cached = segment.style_lines(highlighter, &theme); + assert_eq!(cached.len(), segment.line_starts.len()); + for line_no in 1..=segment.line_starts.len() { + assert_eq!( + cached[line_no - 1], + segment.line_styles_uncached(highlighter, line_no, &theme), + "line {line_no}" + ); + } + // The comment's style reaches the second line, relative to that line. + assert_eq!(cached[1].first().map(|(r, _)| r.start), Some(0)); + } + + #[test] + fn line_styles_are_computed_once_per_theme() { + let syntax = DiffSyntax::parse("a.rs", Some("fn a() {}\n"), Some("fn b() {}\n")).unwrap(); + let dark = HighlightTheme::default_dark(); + let light = HighlightTheme::default_light(); + assert!( + syntax + .new + .as_ref() + .unwrap() + .state + .lock() + .unwrap() + .cache + .is_none() + ); + + syntax.prime(&dark); + let cached_theme = |side: &SyntaxSide| { + side.state + .lock() + .unwrap() + .cache + .as_ref() + .map(|c| c.theme.clone()) + }; + assert_eq!( + cached_theme(syntax.new.as_ref().unwrap()), + Some(ThemeKey::of(&dark)) + ); + assert_eq!( + cached_theme(syntax.old.as_ref().unwrap()), + Some(ThemeKey::of(&dark)) + ); + + // Same theme: served from the cache; another theme replaces it. + assert!( + !syntax + .line_styles(ChangeTag::Insert, 0, 1, "fn b() {}", &dark) + .is_empty() + ); + assert_eq!( + cached_theme(syntax.new.as_ref().unwrap()), + Some(ThemeKey::of(&dark)) + ); + assert!( + !syntax + .line_styles(ChangeTag::Insert, 0, 1, "fn b() {}", &light) + .is_empty() + ); + assert_eq!( + cached_theme(syntax.new.as_ref().unwrap()), + Some(ThemeKey::of(&light)) + ); + } + + fn patch_hunks(patch: &str) -> Vec { + crate::diff::model::hunks_from_patch(&git::parse_hunks(patch)) + } + + #[test] + fn hunks_are_parsed_on_their_own() { + // The first hunk ends inside a block comment; the second hunk's code + // must not be colored as comment. + let hunks = patch_hunks( + "@@ -1,2 +1,2 @@\n-fn a() {}\n+fn b() {}\n /* open\n\ + @@ -40 +40,2 @@\n fn c() {}\n+fn d() {}\n", + ); + let syntax = DiffSyntax::parse_hunks("a.rs", &hunks).unwrap(); + assert_eq!(syntax.origin(), SyntaxOrigin::PerHunk); + let theme = HighlightTheme::default_dark(); + syntax.prime(&theme); + let keyword_at_start = + |styles: Vec<(Range, HighlightStyle)>| styles.iter().any(|(r, _)| *r == (0..2)); + let comment = syntax.line_styles(ChangeTag::Equal, 0, 2, "/* open", &theme); + let fn_c = syntax.line_styles(ChangeTag::Equal, 1, 1, "fn c() {}", &theme); + assert_ne!(comment.first().map(|s| s.1), fn_c.first().map(|s| s.1)); + assert!(keyword_at_start(fn_c)); + assert!(keyword_at_start(syntax.line_styles( + ChangeTag::Insert, + 1, + 2, + "fn d() {}", + &theme + ))); + assert!(keyword_at_start(syntax.line_styles( + ChangeTag::Delete, + 0, + 1, + "fn a() {}", + &theme + ))); + // Old line 2 of hunk 1 does not exist (it has one old line). + assert!( + syntax + .line_styles(ChangeTag::Delete, 1, 2, "fn d() {}", &theme) + .is_empty() + ); + + // Another theme re-parses each hunk for its styles. + let light = HighlightTheme::default_light(); + assert!(keyword_at_start(syntax.line_styles( + ChangeTag::Insert, + 0, + 1, + "fn b() {}", + &light + ))); + assert!(keyword_at_start(syntax.line_styles( + ChangeTag::Insert, + 1, + 2, + "fn d() {}", + &light + ))); + } + + #[test] + fn a_side_without_lines_is_not_parsed() { + let hunks = patch_hunks("@@ -0,0 +1,2 @@\n+fn a() {}\n+fn b() {}\n"); + let syntax = DiffSyntax::parse_hunks("a.rs", &hunks).unwrap(); + assert!(syntax.old.is_none()); + assert!(syntax.new.is_some()); + assert!(DiffSyntax::parse_hunks("notes.txt", &hunks).is_none()); + } + + #[test] + fn diff_syntax_can_cross_threads() { + fn assert_send_sync() {} + assert_send_sync::(); + } +} diff --git a/crates/ui_gpui/src/input/mod.rs b/crates/ui_gpui/src/input/mod.rs index 3d739639a..b2ac0b15a 100644 --- a/crates/ui_gpui/src/input/mod.rs +++ b/crates/ui_gpui/src/input/mod.rs @@ -570,6 +570,17 @@ impl InputArea { } } + /// Add an attachment to the draft (context handed over from elsewhere). + pub fn add_attachment(&mut self, attachment: DraftAttachment, cx: &mut Context) { + if self.is_read_only() { + return; + } + self.attachments.push(attachment); + self.rebuild_attachment_views(cx); + self.emit_content_changed(cx); + cx.notify(); + } + /// Remove an attachment by index fn remove_attachment(&mut self, index: usize, cx: &mut Context) { if index < self.attachments.len() { diff --git a/crates/ui_gpui/src/lib.rs b/crates/ui_gpui/src/lib.rs index f980d545c..8c7188bcb 100644 --- a/crates/ui_gpui/src/lib.rs +++ b/crates/ui_gpui/src/lib.rs @@ -3,6 +3,7 @@ pub mod shared; mod app; pub mod blocks; pub mod comments; +pub mod diff; pub mod input; pub mod main_screen; pub mod messages; @@ -121,92 +122,12 @@ pub struct RepoReviewData { } /// Latest loaded diff for a single file in the Review panel, already prepared -/// for rendering (see [`PreparedReviewDiff`]). +/// for rendering. #[derive(Debug, Clone)] pub struct ReviewDiff { pub repo_root: std::path::PathBuf, pub path: String, - pub prepared: PreparedReviewDiff, -} - -/// Context lines around each review diff hunk (matches `git diff`'s default). -const REVIEW_HUNK_CONTEXT_LINES: usize = 3; - -/// Upper bound for the lines of one review list item. A hunk above it (a -/// newly added file is a single hunk) is split, so the virtualized list only -/// builds what is in view. -const REVIEW_CHUNK_MAX_LINES: usize = 40; - -/// A file diff reduced to renderable hunks. The expensive line diff runs once -/// on a background thread (in the command layer) — the UI only builds elements -/// from the prepared hunks, so element counts scale with changed lines and the -/// UI thread never runs a Myers diff. `chunked` cuts the hunks into the items -/// the Review panel's virtualized list renders. -#[derive(Debug, Clone)] -pub struct PreparedReviewDiff { - pub is_binary: bool, - pub too_large: bool, - pub hunks: Vec, - pub chunked: tool_cards::diff_card::ChunkedHunks, - /// Both sides parsed for syntax highlighting; `None` without a grammar. - pub syntax: Option>, - pub additions: usize, - pub deletions: usize, -} - -impl PreparedReviewDiff { - /// Compute hunks (changed lines + context) from raw diff content. - /// CPU-heavy for large files — call on a background thread. - pub fn from_content(path: &str, diff: &git::FileDiffContent) -> Self { - if diff.is_binary || diff.too_large { - return Self { - is_binary: diff.is_binary, - too_large: diff.too_large, - hunks: Vec::new(), - chunked: Default::default(), - syntax: None, - additions: 0, - deletions: 0, - }; - } - let old = diff.old_text.as_deref().filter(|t| !t.is_empty()); - let new = diff.new_text.as_deref().filter(|t| !t.is_empty()); - let hunks = match (old, new) { - (None, None) => Vec::new(), - (Some(old), Some(new)) => { - tool_cards::diff_card::compute_diff_hunks(old, new, REVIEW_HUNK_CONTEXT_LINES) - } - // Pure add / pure delete: the whole file is the hunk. - (None, Some(new)) => { - tool_cards::diff_card::single_sided_hunk(new, similar::ChangeTag::Insert) - } - (Some(old), None) => { - tool_cards::diff_card::single_sided_hunk(old, similar::ChangeTag::Delete) - } - }; - let mut additions = 0; - let mut deletions = 0; - for line in hunks.iter().flat_map(|h| h.lines.iter()) { - match line.tag { - similar::ChangeTag::Insert => additions += 1, - similar::ChangeTag::Delete => deletions += 1, - similar::ChangeTag::Equal => {} - } - } - Self { - is_binary: false, - too_large: false, - chunked: tool_cards::diff_card::chunk_hunks(&hunks, REVIEW_CHUNK_MAX_LINES), - // Nothing to show means nothing to parse. - syntax: (!hunks.is_empty()) - .then(|| tool_cards::diff_syntax::DiffSyntax::parse(path, old, new)) - .flatten() - .map(Arc::new), - hunks, - additions, - deletions, - } - } + pub prepared: diff::prepare::PreparedFileDiff, } // Our main UI struct that implements the UserInterface trait @@ -742,6 +663,8 @@ impl Gpui { app.run(move |cx| { gpui_clone.install(cx); + // Remote images (`img(url)`, e.g. GitHub avatars) load through it. + cx.set_http_client(std::sync::Arc::new(reqwest_client::ReqwestClient::new())); // Opt-in frame profiling (see shared::frame_profile). let frame_profile_mode = shared::frame_profile::init_from_env(); @@ -776,7 +699,7 @@ impl Gpui { gpui_kit::component::init(cx); // Apply our custom theme colors (restoring saved mode) shared::theme::init_themes(cx, saved_theme_mode); - tool_cards::diff_syntax::register_language_aliases(); + diff::syntax::register_language_aliases(); // Restore saved font scale { diff --git a/crates/ui_gpui/src/main_screen/mod.rs b/crates/ui_gpui/src/main_screen/mod.rs index 77e7bfc69..da698dedf 100644 --- a/crates/ui_gpui/src/main_screen/mod.rs +++ b/crates/ui_gpui/src/main_screen/mod.rs @@ -322,6 +322,9 @@ impl MainScreen { input.upsert_comment(comment.clone(), cx) } crate::comments::CommentChange::Remove(id) => input.remove_comment(*id, cx), + crate::comments::CommentChange::Attach(attachment) => { + input.add_attachment(attachment.clone(), cx) + } }); } diff --git a/crates/ui_gpui/src/main_screen/right_panel/file_viewer.rs b/crates/ui_gpui/src/main_screen/right_panel/file_viewer.rs index 427fbd549..be028e4e6 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/file_viewer.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/file_viewer.rs @@ -25,7 +25,7 @@ use super::file_renderers::{self, RendererHandle}; use crate::Gpui; use crate::comments::editor::{CommentEditor, CommentEditorEvent}; use crate::comments::{AnchorTracker, CommentChange, floating, selection_pill}; -use crate::tool_cards::diff_syntax::language_for_path; +use crate::diff::syntax::language_for_path; use code_assistant_core::line_comments::{self, LineComment}; use code_assistant_core::session::FileContent; use gpui_kit::component::button::{Button, ButtonVariants}; diff --git a/crates/ui_gpui/src/main_screen/right_panel/line_selection.rs b/crates/ui_gpui/src/main_screen/right_panel/line_selection.rs index cfbb68922..10160bfb6 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/line_selection.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/line_selection.rs @@ -1,10 +1,10 @@ -//! Whole-line selection over [`DiffRows`](crate::tool_cards::diff_rows::DiffRows) +//! Whole-line selection over [`DiffRows`](crate::diff::rows::DiffRows) //! chunks, shared by the right panel's views. A view keeps one //! [`LineSelection`] and builds each chunk's [`RowSelection`] with //! [`row_selection`]; the element reports pointer positions as flat line //! indices, which `key` ties to one file. A selection never spans files. -use crate::tool_cards::diff_rows::{EndCallback, LineCallback, RowSelection}; +use crate::diff::rows::{EndCallback, LineCallback, RowSelection}; use gpui_kit::{App, Context, FocusHandle, Hsla, Window}; use std::ops::Range; diff --git a/crates/ui_gpui/src/main_screen/right_panel/mod.rs b/crates/ui_gpui/src/main_screen/right_panel/mod.rs index 36ce048f0..0a86199aa 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/mod.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/mod.rs @@ -1,10 +1,11 @@ //! The right sidebar's views. //! //! The sidebar hosts the [`review_view::ReviewView`], the project's files -//! ([`files_view::FilesView`]) and, with the `browser-panel` feature, the -//! agent's browser ([`browser::BrowserPanel`]). The title bar switches -//! between them ([`RightPanelView::ALL`]). The shown view is remembered per -//! session. +//! ([`files_view::FilesView`]), the session's pull request +//! ([`pull_request::PullRequestPanel`]) and, with the `browser-panel` +//! feature, the agent's browser ([`browser::BrowserPanel`]). The title bar +//! switches between them ([`RightPanelView::ALL`]). The shown view is +//! remembered per session. #[cfg(feature = "browser-panel")] mod browser; @@ -13,6 +14,7 @@ mod file_renderers; mod file_viewer; pub mod files_view; mod line_selection; +pub mod pull_request; mod review_rows; pub mod review_view; @@ -20,6 +22,7 @@ pub use crate::comments::CommentChange; use code_assistant_core::line_comments::LineComment; use files_view::FilesView; use gpui_kit::{Context, Entity, FocusHandle, Focusable, Render, Window, div, prelude::*}; +use pull_request::PullRequestPanel; use review_view::ReviewView; /// Which view the right panel is currently showing. @@ -27,6 +30,7 @@ use review_view::ReviewView; pub enum RightPanelView { Review, Files, + PullRequest, #[cfg(feature = "browser-panel")] Browser, } @@ -36,6 +40,7 @@ impl RightPanelView { pub const ALL: &[(RightPanelView, &'static str)] = &[ (RightPanelView::Review, "Review"), (RightPanelView::Files, "Files"), + (RightPanelView::PullRequest, "PR"), #[cfg(feature = "browser-panel")] (RightPanelView::Browser, "Browser"), ]; @@ -45,6 +50,7 @@ impl RightPanelView { match self { RightPanelView::Review => "review", RightPanelView::Files => "files", + RightPanelView::PullRequest => "pull-request", #[cfg(feature = "browser-panel")] RightPanelView::Browser => "browser", } @@ -55,6 +61,7 @@ impl RightPanelView { pub fn from_str(s: &str) -> Self { match s { "files" => RightPanelView::Files, + "pull-request" => RightPanelView::PullRequest, #[cfg(feature = "browser-panel")] "browser" => RightPanelView::Browser, _ => RightPanelView::Review, @@ -66,6 +73,7 @@ pub struct RightPanel { active_view: RightPanelView, review_view: Entity, files_view: Entity, + pull_request: Entity, #[cfg(feature = "browser-panel")] browser: Entity, session_id: Option, @@ -83,6 +91,7 @@ impl RightPanel { pub fn new(window: &mut Window, cx: &mut Context) -> Self { let review_view = cx.new(|cx| ReviewView::new(window, cx)); let files_view = cx.new(|cx| FilesView::new(window, cx)); + let pull_request = cx.new(|cx| PullRequestPanel::new(window, cx)); let comment_subscriptions = vec![ cx.subscribe(&review_view, |_, _, change: &CommentChange, cx| { cx.emit(change.clone()) @@ -97,6 +106,7 @@ impl RightPanel { active_view: RightPanelView::Review, review_view, files_view, + pull_request, #[cfg(feature = "browser-panel")] _browser_events: cx.subscribe(&browser, |this, _, event, cx| match event { // The agent started browsing: show it. @@ -166,7 +176,7 @@ impl RightPanel { store.set_right_panel_view(session_id, view.as_str()) }); } - self.sync_files_session(cx); + self.sync_view_sessions(cx); cx.notify(); } @@ -185,27 +195,37 @@ impl RightPanel { self.browser.update(cx, |browser, cx| { browser.set_session(session_id.clone(), cx) }); - self.sync_files_session(cx); + self.sync_view_sessions(cx); self.review_view .update(cx, |v, cx| v.set_session(session_id, cx)); cx.notify(); } - /// The Files view follows the session only while it is shown, so a - /// hidden one neither lists nor watches. - fn sync_files_session(&mut self, cx: &mut Context) { - let session_id = (self.active_view == RightPanelView::Files) - .then(|| self.session_id.clone()) - .flatten(); + /// The Files and Pull request views follow the session only while + /// shown, so a hidden one neither lists, watches nor polls. + fn sync_view_sessions(&mut self, cx: &mut Context) { + let shown = |view| { + (self.active_view == view) + .then(|| self.session_id.clone()) + .flatten() + }; + let (files, pull_request) = ( + shown(RightPanelView::Files), + shown(RightPanelView::PullRequest), + ); self.files_view - .update(cx, |files, cx| files.set_session(session_id, cx)); + .update(cx, |view, cx| view.set_session(files, cx)); + self.pull_request + .update(cx, |view, cx| view.set_session(pull_request, cx)); } /// Re-request data for the active view. pub fn reload(&mut self, cx: &mut Context) { self.review_view.update(cx, |v, cx| v.reload(cx)); - if self.active_view == RightPanelView::Files { - self.files_view.update(cx, |v, cx| v.reload(cx)); + match self.active_view { + RightPanelView::Files => self.files_view.update(cx, |v, cx| v.reload(cx)), + RightPanelView::PullRequest => self.pull_request.update(cx, |v, cx| v.reload(cx)), + _ => {} } } } @@ -221,6 +241,7 @@ impl Render for RightPanel { let body = match self.active_view { RightPanelView::Review => self.review_view.clone().into_any_element(), RightPanelView::Files => self.files_view.clone().into_any_element(), + RightPanelView::PullRequest => self.pull_request.clone().into_any_element(), #[cfg(feature = "browser-panel")] RightPanelView::Browser => self.browser.clone().into_any_element(), }; diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/actions.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/actions.rs new file mode 100644 index 000000000..6d362ae39 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/actions.rs @@ -0,0 +1,701 @@ +//! What the header offers to do with a pull request: the one prominent +//! control (merge, ready for review, auto-merge, or the state once it is +//! settled), "Check out" and the ⋯ menu. Ported from T3 Code's +//! `pullRequestDetail.logic.ts` and `PullRequestActionsMenu.tsx`. +//! +//! Merging, enabling auto-merge and closing take two clicks: the first arms +//! the control ("Confirm merge?"), the second within three seconds acts. A +//! press elsewhere disarms it. + +use super::PullRequestIntent; +use super::header::DetailHeader; +use super::presentation::{Tone, merge_method_label}; +use github::model::{ + Action, ChecksState, Detail, MergeMethod, Mergeability, PullRequestAction, PullRequestState, + PullRequestUpdate, UpdateMethod, +}; +use gpui_kit::component::button::{Button, ButtonVariants}; +use gpui_kit::component::menu::{DropdownMenu, PopupMenu, PopupMenuItem}; +use gpui_kit::component::{ActiveTheme, Icon, Side, Sizable}; +use gpui_kit::{ + AnyElement, App, Context, Hsla, SharedString, WeakEntity, div, prelude::*, px, svg, +}; + +/// The header's one prominent control, by where the pull request stands. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PrimaryControl { + /// Conflicts with the base: nothing merges until they are resolved. + Conflicts, + Ready, + Merge, + EnableAutoMerge, + /// Auto-merge waits for the requirements. + AutoMergeArmed, + Merged, + Closed, +} + +/// The actions the host lets the viewer take. +pub fn can(detail: &Detail, action: PullRequestAction) -> bool { + detail.viewer_permissions.actions.contains(&action) +} + +/// Merge methods the repository allows, in GitHub's order. +pub fn allowed_merge_methods(detail: &Detail) -> Vec { + let allows = detail.merge_capabilities; + [ + (MergeMethod::Merge, allows.merge), + (MergeMethod::Squash, allows.squash), + (MergeMethod::Rebase, allows.rebase), + ] + .into_iter() + .filter_map(|(method, allowed)| allowed.then_some(method)) + .collect() +} + +/// The method the merge controls use: the viewer's pick while allowed, +/// else the first allowed one. +pub fn resolve_merge_method(allowed: &[MergeMethod], picked: Option) -> MergeMethod { + picked + .filter(|method| allowed.contains(method)) + .or_else(|| allowed.first().copied()) + .unwrap_or(MergeMethod::Merge) +} + +/// `checks` is the verdict over the head's checks (see +/// [`PullRequestData::checks_state`](super::data::PullRequestData::checks_state)). +pub fn primary_control(detail: &Detail, checks: Option) -> Option { + match detail.state { + PullRequestState::Merged => return Some(PrimaryControl::Merged), + PullRequestState::Closed => return Some(PrimaryControl::Closed), + PullRequestState::Open => {} + } + if detail.mergeability == Mergeability::Conflicting { + return Some(PrimaryControl::Conflicts); + } + if detail.is_draft { + return can(detail, PullRequestAction::Ready).then_some(PrimaryControl::Ready); + } + if detail.auto_merge_enabled { + return Some(PrimaryControl::AutoMergeArmed); + } + if allowed_merge_methods(detail).is_empty() { + return None; + } + let unsettled = checks.is_some_and(|s| s != ChecksState::Passing); + if unsettled && detail.auto_merge_allowed && can(detail, PullRequestAction::EnableAutoMerge) { + return Some(PrimaryControl::EnableAutoMerge); + } + can(detail, PullRequestAction::Merge).then_some(PrimaryControl::Merge) +} + +/// Which entries the ⋯ menu shows besides the ones always there. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct MenuEntries { + pub draft_toggle: bool, + pub merge_now: bool, + pub enable_auto_merge: bool, + pub disable_auto_merge: bool, + pub merge_methods: bool, + pub maintainer_edits: bool, + pub close: bool, + pub reopen: bool, + pub delete_branch: bool, +} + +pub fn menu_entries(detail: &Detail, checks: Option) -> MenuEntries { + let open = detail.state == PullRequestState::Open; + let primary = primary_control(detail, checks); + let conflicting = detail.mergeability == Mergeability::Conflicting; + let methods = allowed_merge_methods(detail).len(); + let armed = open && detail.auto_merge_enabled; + let mergeable = open && !detail.is_draft && !conflicting && methods > 0; + let toggle = if detail.is_draft { + PullRequestAction::Ready + } else { + PullRequestAction::Draft + }; + MenuEntries { + draft_toggle: open + && can(detail, toggle) + && !(detail.is_draft && primary == Some(PrimaryControl::Ready)), + merge_now: mergeable + && matches!( + primary, + Some(PrimaryControl::EnableAutoMerge | PrimaryControl::AutoMergeArmed) + ) + && can(detail, PullRequestAction::Merge), + enable_auto_merge: mergeable + && !armed + && primary != Some(PrimaryControl::EnableAutoMerge) + && detail.auto_merge_allowed + && can(detail, PullRequestAction::EnableAutoMerge), + disable_auto_merge: armed && can(detail, PullRequestAction::DisableAutoMerge), + merge_methods: mergeable && methods > 1 && can(detail, PullRequestAction::Merge), + maintainer_edits: open && detail.viewer_permissions.maintainer_edits, + close: open && can(detail, PullRequestAction::Close), + reopen: detail.state == PullRequestState::Closed && can(detail, PullRequestAction::Reopen), + delete_branch: !open && can(detail, PullRequestAction::DeleteBranch), + } +} + +/// An open pull request whose branch fell behind its base: by how much, +/// and how the viewer may bring the base in. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BranchUpdate { + pub behind_by: Option, + pub methods: Vec, +} + +pub fn branch_update(detail: &Detail) -> Option { + use github::model::BaseComparison; + (detail.state == PullRequestState::Open + && detail.base_comparison == BaseComparison::Behind + && detail.mergeability == Mergeability::Mergeable) + .then(|| BranchUpdate { + behind_by: detail.behind_by, + methods: detail.viewer_permissions.update_methods.clone(), + }) +} + +/// The controls that take a second click. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Confirmable { + Merge, + EnableAutoMerge, + Close, +} + +/// Which control waits for its confirming click. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct InlineConfirm { + armed: Option, +} + +impl InlineConfirm { + pub fn is_armed(&self, key: Confirmable) -> bool { + self.armed == Some(key) + } + + /// A click on `key`: `true` when it confirms, else it arms `key`. + pub fn click(&mut self, key: Confirmable) -> bool { + if self.armed == Some(key) { + self.armed = None; + true + } else { + self.armed = Some(key); + false + } + } + + /// Whether something was armed. + pub fn disarm(&mut self) -> bool { + self.armed.take().is_some() + } +} + +fn icon(path: &'static str) -> Icon { + Icon::default().path(SharedString::from(path)) +} + +/// A label in a state colour with an outline, for what cannot be clicked. +fn badge(icon_path: &'static str, label: SharedString, color: Hsla) -> AnyElement { + div() + .flex_none() + .h(px(24.)) + .px_2() + .flex() + .items_center() + .gap_1() + .rounded_md() + .border_1() + .border_color(color.opacity(0.45)) + .text_xs() + .text_color(color) + .child(svg().size(px(14.)).path(icon_path).text_color(color)) + .child(label) + .into_any_element() +} + +fn auto_merge_label(method: Option) -> String { + match method { + Some(method) => format!("Auto-merge ({})", merge_method_label(method).to_lowercase()), + None => "Auto-merge".into(), + } +} + +/// The primary control, `None` when there is nothing to offer. +pub fn render_primary( + header: &DetailHeader, + detail: &Detail, + cx: &mut Context, +) -> Option { + let method = header.merge_method(detail); + let confirm = header.confirm(); + let control = primary_control(detail, header.checks_state())?; + let pending = header.pending(); + let confirmable = |key: Confirmable, label: String, action: Action, cx: &mut Context<_>| { + let armed = confirm.is_armed(key); + let label = if armed { + match key { + Confirmable::Merge => "Confirm merge?".to_string(), + _ => "Confirm auto-merge?".to_string(), + } + } else { + label + }; + Button::new("pr-primary") + .primary() + .xsmall() + .loading(pending) + .icon(icon(if armed { + "icons/check.svg" + } else { + "icons/git_merge.svg" + })) + .label(label) + .on_click(cx.listener(move |this: &mut DetailHeader, _, _, cx| { + this.click_confirmable(key, action, cx) + })) + .when(armed, |button| { + button.on_mouse_down_out( + cx.listener(|this: &mut DetailHeader, _, _, cx| this.disarm(cx)), + ) + }) + .into_any_element() + }; + let element = match control { + PrimaryControl::Merged | PrimaryControl::Closed => { + let tone = Tone::of(detail.state, detail.is_draft); + badge(tone.icon(), tone.label().into(), tone.color(cx)) + } + PrimaryControl::Conflicts => badge( + "icons/triangle_alert.svg", + "Has conflicts".into(), + cx.theme().danger, + ), + PrimaryControl::AutoMergeArmed => badge( + "icons/git_merge.svg", + auto_merge_label(detail.auto_merge_method).into(), + cx.theme().info, + ), + PrimaryControl::Ready => Button::new("pr-primary") + .primary() + .xsmall() + .loading(pending) + .icon(icon("icons/git_pull_request.svg")) + .label("Ready for review") + .on_click(cx.listener(|this: &mut DetailHeader, _, _, cx| { + this.emit(PullRequestIntent::Act(Action::Ready), cx) + })) + .into_any_element(), + PrimaryControl::Merge => confirmable( + Confirmable::Merge, + merge_method_label(method).into(), + Action::Merge(method), + cx, + ), + PrimaryControl::EnableAutoMerge => confirmable( + Confirmable::EnableAutoMerge, + auto_merge_label(Some(method)), + Action::EnableAutoMerge(method), + cx, + ), + }; + Some( + div() + .flex_none() + .debug_selector(|| "pr-primary".into()) + .child(element) + .into_any_element(), + ) +} + +/// "Check out ▾": for now the command that checks the branch out. +pub fn render_checkout(number: u64, header: WeakEntity) -> AnyElement { + Button::new("pr-checkout") + .outline() + .xsmall() + .icon(icon("icons/git_branch.svg")) + .label("Check out") + .dropdown_caret(true) + .dropdown_menu_with_anchor(gpui_kit::Anchor::TopRight, move |menu, _, _| { + let header = header.clone(); + let command = checkout_command(number); + menu.item( + PopupMenuItem::new(format!("Copy “{command}”")) + .icon(icon("icons/copy.svg")) + .on_click(move |_, _, cx| { + send(&header, PullRequestIntent::Copy(command.clone()), cx) + }), + ) + }) + .into_any_element() +} + +pub fn checkout_command(number: u64) -> String { + format!("gh pr checkout {number}") +} + +fn send(header: &WeakEntity, intent: PullRequestIntent, cx: &mut App) { + header.update(cx, |header, cx| header.emit(intent, cx)).ok(); +} + +/// A menu entry taking two clicks. The first arms it and keeps the menu +/// open (the entry's own click stops there); the second reaches the menu, +/// which acts and closes. +fn confirm_entry( + header: &WeakEntity, + key: Confirmable, + labels: (&'static str, &'static str), + icon_path: &'static str, + action: Action, +) -> PopupMenuItem { + let render_header = header.clone(); + let click_header = header.clone(); + PopupMenuItem::element(move |_, cx| { + let armed = render_header + .upgrade() + .is_some_and(|h| h.read(cx).confirm().is_armed(key)); + let arm_header = render_header.clone(); + div() + .id(SharedString::from(format!("pr-confirm-{key:?}"))) + .flex_1() + .when(armed, |el| el.text_color(cx.theme().danger)) + .child(if armed { labels.1 } else { labels.0 }) + .when(!armed, |el| { + el.on_click(move |_, _, cx| { + cx.stop_propagation(); + arm_header + .update(cx, |h, cx| { + h.click_confirmable(key, action, cx); + }) + .ok(); + }) + }) + }) + .icon(icon(icon_path)) + .on_click(move |_, _, cx| { + click_header + .update(cx, |h, cx| h.click_confirmable(key, action, cx)) + .ok(); + }) +} + +/// The ⋯ menu. +/// The ⋯ menu; its button spins while `refreshing`. +pub fn render_more_menu( + detail: &Detail, + refreshing: bool, + header: WeakEntity, +) -> AnyElement { + let detail = detail.clone(); + let closing = header.clone(); + Button::new("pr-more") + .ghost() + .xsmall() + .icon(icon("icons/ellipsis.svg")) + .loading(refreshing) + .dropdown_menu_with_anchor(gpui_kit::Anchor::TopRight, move |menu, _, cx| { + more_menu(menu, &detail, &header, cx) + }) + .on_open_change(move |open, _, cx| { + if !open { + closing.update(cx, |h, cx| h.disarm(cx)).ok(); + } + }) + .into_any_element() +} + +fn more_menu( + menu: PopupMenu, + detail: &Detail, + header: &WeakEntity, + cx: &App, +) -> PopupMenu { + let Some(state) = header.upgrade() else { + return menu; + }; + let state = state.read(cx); + let entries = menu_entries(detail, state.checks_state()); + let method = state.merge_method(detail); + let item = |label: &str, icon_path: &'static str, intent: PullRequestIntent| { + let header = header.clone(); + PopupMenuItem::new(SharedString::from(label.to_owned())) + .icon(icon(icon_path)) + .on_click(move |_, _, cx| send(&header, intent.clone(), cx)) + }; + let mut menu = menu + .min_w(px(220.)) + .check_side(Side::Right) + .item(item( + "Refresh", + "icons/rotate_cw.svg", + PullRequestIntent::Refresh, + )) + .separator(); + if entries.maintainer_edits { + let allowed = detail.maintainer_can_modify; + let update = PullRequestUpdate { + maintainer_can_modify: Some(!allowed), + ..PullRequestUpdate::default() + }; + menu = menu + .item( + item( + "Allow edits from maintainers", + "icons/pen_line.svg", + PullRequestIntent::Update(update), + ) + .checked(allowed), + ) + .separator(); + } + if entries.draft_toggle { + menu = menu.item(if detail.is_draft { + item( + "Ready for review", + "icons/git_pull_request.svg", + PullRequestIntent::Act(Action::Ready), + ) + } else { + item( + "Convert to draft", + "icons/git_pull_request_draft.svg", + PullRequestIntent::Act(Action::Draft), + ) + }); + } + if entries.merge_now { + menu = menu.item(confirm_entry( + header, + Confirmable::Merge, + ("Merge now", "Confirm merge?"), + "icons/git_merge.svg", + Action::Merge(method), + )); + } + if entries.disable_auto_merge { + menu = menu.item(item( + "Disable auto-merge", + "icons/git_merge.svg", + PullRequestIntent::Act(Action::DisableAutoMerge), + )); + } else if entries.enable_auto_merge { + menu = menu.item(confirm_entry( + header, + Confirmable::EnableAutoMerge, + ("Enable auto-merge", "Confirm auto-merge?"), + "icons/git_merge.svg", + Action::EnableAutoMerge(method), + )); + } + if entries.merge_methods { + menu = menu.separator(); + for allowed in allowed_merge_methods(detail) { + let header = header.clone(); + menu = menu.item( + PopupMenuItem::new(merge_method_label(allowed)) + .icon(icon("icons/git_merge.svg")) + .checked(allowed == method) + .on_click(move |_, _, cx| { + header + .update(cx, |h, cx| h.pick_merge_method(allowed, cx)) + .ok(); + }), + ); + } + } + menu = menu + .separator() + .item(item( + "Open on GitHub", + "icons/external_link.svg", + PullRequestIntent::OpenUrl(detail.url.clone()), + )) + .item(item( + "Copy link", + "icons/link.svg", + PullRequestIntent::Copy(detail.url.clone()), + )) + .item(item( + "Copy PR number", + "icons/copy.svg", + PullRequestIntent::Copy(format!("#{}", detail.number)), + )); + if entries.close { + menu = menu.separator().item(confirm_entry( + header, + Confirmable::Close, + ("Close pull request", "Confirm close?"), + "icons/git_pull_request_closed.svg", + Action::Close, + )); + } else if entries.reopen { + menu = menu.separator().item(item( + "Reopen pull request", + "icons/git_pull_request.svg", + PullRequestIntent::Act(Action::Reopen), + )); + } + if entries.delete_branch { + menu = menu.separator().item(item( + &format!("Delete branch {}", detail.head_branch), + "icons/trash.svg", + PullRequestIntent::Act(Action::DeleteBranch), + )); + } + menu +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::main_screen::right_panel::pull_request::fixtures::{check, detail, detail_with}; + use crate::main_screen::right_panel::pull_request::presentation::effective_checks_state; + use github::model::{BaseComparison, CheckStatus, MergeCapabilities}; + + fn primary(detail: &Detail) -> Option { + primary_control( + detail, + effective_checks_state(detail.checks_state, &detail.checks), + ) + } + + fn entries(detail: &Detail) -> MenuEntries { + menu_entries( + detail, + effective_checks_state(detail.checks_state, &detail.checks), + ) + } + + #[test] + fn settled_pull_requests_show_their_state() { + let merged = detail_with(1, |d| d.state = PullRequestState::Merged); + assert_eq!(primary(&merged), Some(PrimaryControl::Merged)); + let closed = detail_with(1, |d| d.state = PullRequestState::Closed); + assert_eq!(primary(&closed), Some(PrimaryControl::Closed)); + } + + #[test] + fn conflicts_come_before_everything_open() { + let conflicting = detail_with(1, |d| { + d.mergeability = Mergeability::Conflicting; + d.is_draft = true; + }); + assert_eq!(primary(&conflicting), Some(PrimaryControl::Conflicts)); + } + + #[test] + fn a_draft_offers_ready_for_review_when_allowed() { + let draft = detail_with(1, |d| d.is_draft = true); + assert_eq!(primary(&draft), Some(PrimaryControl::Ready)); + let draft = detail_with(1, |d| { + d.is_draft = true; + d.viewer_permissions.actions.clear(); + }); + assert_eq!(primary(&draft), None); + } + + #[test] + fn pending_checks_offer_auto_merge_passing_ones_merge() { + let pending = detail_with(1, |d| d.checks = vec![check("ci", CheckStatus::Pending)]); + assert_eq!(primary(&pending), Some(PrimaryControl::EnableAutoMerge)); + let passing = detail_with(1, |d| d.checks = vec![check("ci", CheckStatus::Success)]); + assert_eq!(primary(&passing), Some(PrimaryControl::Merge)); + // Without checks there is nothing to wait for. + assert_eq!(primary(&detail(1)), Some(PrimaryControl::Merge)); + let no_auto_merge = detail_with(1, |d| { + d.checks_state = Some(ChecksState::Failing); + d.auto_merge_allowed = false; + }); + assert_eq!(primary(&no_auto_merge), Some(PrimaryControl::Merge)); + let armed = detail_with(1, |d| d.auto_merge_enabled = true); + assert_eq!(primary(&armed), Some(PrimaryControl::AutoMergeArmed)); + } + + #[test] + fn nothing_to_merge_with_offers_nothing() { + let no_methods = detail_with(1, |d| d.merge_capabilities = MergeCapabilities::default()); + assert_eq!(primary(&no_methods), None); + let no_permission = detail_with(1, |d| d.viewer_permissions.actions.clear()); + assert_eq!(primary(&no_permission), None); + } + + #[test] + fn the_merge_method_is_the_pick_while_allowed() { + let all = [MergeMethod::Merge, MergeMethod::Squash, MergeMethod::Rebase]; + assert_eq!(resolve_merge_method(&all, None), MergeMethod::Merge); + assert_eq!( + resolve_merge_method(&all, Some(MergeMethod::Rebase)), + MergeMethod::Rebase + ); + assert_eq!( + resolve_merge_method(&[MergeMethod::Squash], Some(MergeMethod::Rebase)), + MergeMethod::Squash + ); + let squash_only = detail_with(1, |d| { + d.merge_capabilities = MergeCapabilities { + merge: false, + squash: true, + rebase: false, + } + }); + assert_eq!( + allowed_merge_methods(&squash_only), + vec![MergeMethod::Squash] + ); + } + + #[test] + fn menu_entries_follow_state_and_permissions() { + let open = entries(&detail(1)); + assert!(open.draft_toggle && open.enable_auto_merge && open.merge_methods && open.close); + assert!(!open.merge_now && !open.reopen && !open.delete_branch); + + let pending = entries(&detail_with(1, |d| { + d.checks_state = Some(ChecksState::Pending) + })); + // Auto-merge is the primary control, so merging at once moves here. + assert!(pending.merge_now && !pending.enable_auto_merge); + + let armed = entries(&detail_with(1, |d| d.auto_merge_enabled = true)); + assert!(armed.disable_auto_merge && armed.merge_now && !armed.enable_auto_merge); + + let draft = entries(&detail_with(1, |d| d.is_draft = true)); + // Ready for review is the primary control already. + assert!(!draft.draft_toggle && !draft.merge_methods); + + let closed = entries(&detail_with(1, |d| d.state = PullRequestState::Closed)); + assert!(closed.reopen && closed.delete_branch && !closed.close); + } + + #[test] + fn a_branch_behind_its_base_offers_an_update() { + assert_eq!(branch_update(&detail(1)), None); + let behind = detail_with(1, |d| { + d.base_comparison = BaseComparison::Behind; + d.behind_by = Some(3); + d.viewer_permissions.update_methods = vec![UpdateMethod::Merge]; + }); + assert_eq!( + branch_update(&behind), + Some(BranchUpdate { + behind_by: Some(3), + methods: vec![UpdateMethod::Merge], + }) + ); + } + + #[test] + fn the_second_click_confirms() { + let mut confirm = InlineConfirm::default(); + assert!(!confirm.click(Confirmable::Merge)); + assert!(confirm.is_armed(Confirmable::Merge)); + // Another control re-arms instead of confirming. + assert!(!confirm.click(Confirmable::Close)); + assert!(!confirm.is_armed(Confirmable::Merge)); + assert!(confirm.click(Confirmable::Close)); + assert!(!confirm.disarm()); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/avatar.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/avatar.rs new file mode 100644 index 000000000..2239ed6f7 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/avatar.rs @@ -0,0 +1,68 @@ +//! Round GitHub avatars. The image is asked for at twice the shown size +//! (sharp on Retina, small to load); the login's initial stands in while it +//! loads, when it fails and when there is none. + +use github::model::Actor; +use gpui_kit::component::ActiveTheme; +use gpui_kit::{AnyElement, App, Pixels, SharedString, div, img, prelude::*}; + +/// `url` asking GitHub for an image `size` pixels wide. +fn sized_url(url: &str, size: u32) -> String { + let separator = if url.contains('?') { '&' } else { '?' }; + format!("{url}{separator}s={size}") +} + +pub fn avatar(actor: Option<&Actor>, size: Pixels, cx: &App) -> AnyElement { + let login = actor.map_or("ghost", |a| a.login.as_str()); + let initial: SharedString = login + .chars() + .next() + .map(|c| c.to_uppercase().to_string()) + .unwrap_or_default() + .into(); + let (bg, fg) = (cx.theme().muted, cx.theme().muted_foreground); + let placeholder = move || { + div() + .flex_none() + .size(size) + .rounded_full() + .bg(bg) + .flex() + .items_center() + .justify_center() + .text_size(size * 0.6) + .text_color(fg) + .child(initial.clone()) + .into_any_element() + }; + match actor.and_then(|a| a.avatar_url.as_deref()) { + Some(url) => { + let pixels = (f32::from(size) * 2.).ceil() as u32; + img(SharedString::from(sized_url(url, pixels))) + .flex_none() + .size(size) + .rounded_full() + .with_loading(placeholder.clone()) + .with_fallback(placeholder) + .into_any_element() + } + None => placeholder(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn asks_for_a_small_image() { + assert_eq!( + sized_url("https://avatars.githubusercontent.com/u/1?v=4", 40), + "https://avatars.githubusercontent.com/u/1?v=4&s=40" + ); + assert_eq!( + sized_url("https://avatars.githubusercontent.com/in/15368", 28), + "https://avatars.githubusercontent.com/in/15368?s=28" + ); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/bus.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/bus.rs new file mode 100644 index 000000000..bd9230fd6 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/bus.rs @@ -0,0 +1,43 @@ +//! The core's pull request announcements, for the views showing pull +//! requests. The event loop reports them here ([`report`]); views subscribe +//! to [`bus`] and read again what changed. + +use code_assistant_core::pull_requests::PullRequestPart; +use github::{PullRequestRef, RepoRef}; +use gpui_kit::{App, AppContext, Entity, EventEmitter, Global}; + +#[derive(Clone, Debug, PartialEq)] +pub enum PullRequestEvent { + /// A part of a pull request was refreshed or invalidated by a write. + Changed { + pr: PullRequestRef, + part: PullRequestPart, + }, + /// The cached lists of a repository changed. + ListChanged { repo: RepoRef }, +} + +pub struct PullRequestBus; + +impl EventEmitter for PullRequestBus {} + +struct PullRequestBusGlobal(Entity); + +impl Global for PullRequestBusGlobal {} + +/// The bus, created with its first subscriber. +pub fn bus(cx: &mut App) -> Entity { + if let Some(global) = cx.try_global::() { + return global.0.clone(); + } + let bus = cx.new(|_| PullRequestBus); + cx.set_global(PullRequestBusGlobal(bus.clone())); + bus +} + +/// Tell the views about `event`; nothing listens before a view subscribed. +pub fn report(event: PullRequestEvent, cx: &mut App) { + if let Some(bus) = cx.try_global::().map(|g| g.0.clone()) { + bus.update(cx, |_, cx| cx.emit(event)); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/anchors.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/anchors.rs new file mode 100644 index 000000000..daf925f97 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/anchors.rs @@ -0,0 +1,211 @@ +//! Where GitHub's line positions sit in a file's diff, both ways: a review +//! thread or pending comment at `(side, line)` goes below the row showing +//! that line, if one is rendered (else it is listed apart, "not on the +//! current diff"); a selection of rows becomes the `(side, line)` range a +//! new comment is anchored to. `LEFT` is the old file, `RIGHT` the new one; +//! a context row shows a line of both. + +use github::model::DiffSide; +use similar::ChangeTag; +use std::collections::{BTreeMap, HashMap}; + +/// A line of one side of a diff, as GitHub addresses it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct LineRef { + pub side: DiffSide, + pub line: u64, +} + +/// A comment's position: one line, or a range from `start` to `end`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RangeRef { + pub start: Option, + pub end: LineRef, +} + +/// The rows of a file's diff by the lines they show (flat line indices, +/// as [`PreparedFileDiff::row_numbers`] counts them). +/// +/// [`PreparedFileDiff::row_numbers`]: crate::diff::prepare::PreparedFileDiff::row_numbers +#[derive(Clone, Debug, Default)] +pub struct LineIndex { + left: HashMap, + right: HashMap, +} + +impl LineIndex { + pub fn new(numbers: &[(ChangeTag, usize, usize)]) -> Self { + let mut index = Self::default(); + for (flat, &(tag, old, new)) in numbers.iter().enumerate() { + if tag != ChangeTag::Insert { + index.left.insert(old as u64, flat); + } + if tag != ChangeTag::Delete { + index.right.insert(new as u64, flat); + } + } + index + } + + /// The row showing `at`, when the diff has one. + pub fn row(&self, at: LineRef) -> Option { + match at.side { + DiffSide::Left => self.left.get(&at.line), + DiffSide::Right => self.right.get(&at.line), + } + .copied() + } +} + +/// The line a row stands for: deleted rows the old file's, the others the +/// new file's. +pub fn line_of((tag, old, new): (ChangeTag, usize, usize)) -> LineRef { + match tag { + ChangeTag::Delete => LineRef { + side: DiffSide::Left, + line: old as u64, + }, + _ => LineRef { + side: DiffSide::Right, + line: new as u64, + }, + } +} + +/// The position of a comment on rows `lo..=hi`: their last row, from their +/// first one when there are several. +pub fn range_of(numbers: &[(ChangeTag, usize, usize)], lo: usize, hi: usize) -> Option { + let end = line_of(*numbers.get(hi)?); + let start = (lo < hi) + .then(|| numbers.get(lo).copied().map(line_of)) + .flatten(); + Some(RangeRef { start, end }) +} + +/// What goes below a diff row. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Annotation { + /// A review thread, by index into the pull request's threads. + Thread(usize), + /// A comment of the pending review, by id. + Pending(u64), + /// The box a new comment is written in. + Draft, +} + +/// Annotations by the flat line they go below, each line's in the order +/// threads, pending comments, the draft; `None` for the ones whose line no +/// row shows. +pub fn place( + index: &LineIndex, + items: impl IntoIterator, +) -> (BTreeMap>, Vec) { + let mut placed: BTreeMap> = BTreeMap::new(); + let mut off = Vec::new(); + for (at, annotation) in items { + match index.row(at) { + Some(row) => placed.entry(row).or_default().push(annotation), + None => off.push(annotation), + } + } + for annotations in placed.values_mut() { + annotations.sort(); + } + (placed, off) +} + +#[cfg(test)] +mod tests { + use super::*; + use ChangeTag::*; + + fn left(line: u64) -> LineRef { + LineRef { + side: DiffSide::Left, + line, + } + } + + fn right(line: u64) -> LineRef { + LineRef { + side: DiffSide::Right, + line, + } + } + + /// `@@ -10,3 +10,4 @@`: ctx, -old, +new, +more, ctx. + fn numbers() -> Vec<(ChangeTag, usize, usize)> { + vec![ + (Equal, 10, 10), + (Delete, 11, 11), + (Insert, 12, 11), + (Insert, 12, 12), + (Equal, 12, 13), + ] + } + + #[test] + fn rows_are_found_by_side_and_line() { + let index = LineIndex::new(&numbers()); + assert_eq!(index.row(left(11)), Some(1)); + assert_eq!(index.row(right(11)), Some(2)); + // A context row shows a line of both sides. + assert_eq!(index.row(left(12)), Some(4)); + assert_eq!(index.row(right(13)), Some(4)); + assert_eq!(index.row(left(10)), Some(0)); + assert_eq!(index.row(right(14)), None); + assert_eq!(index.row(left(13)), None); + } + + #[test] + fn a_selection_is_anchored_like_github_wants_it() { + let numbers = numbers(); + // One deleted row: the old file's line. + assert_eq!( + range_of(&numbers, 1, 1), + Some(RangeRef { + start: None, + end: left(11) + }) + ); + // From the deleted row to the context row below. + assert_eq!( + range_of(&numbers, 1, 4), + Some(RangeRef { + start: Some(left(11)), + end: right(13) + }) + ); + assert_eq!(range_of(&numbers, 2, 9), None); + } + + #[test] + fn annotations_go_below_their_rows_in_a_fixed_order() { + let index = LineIndex::new(&numbers()); + let (placed, off) = place( + &index, + [ + (right(12), Annotation::Draft), + (right(12), Annotation::Pending(7)), + (right(12), Annotation::Thread(3)), + (left(11), Annotation::Thread(0)), + (right(40), Annotation::Thread(1)), + ], + ); + assert_eq!( + placed.into_iter().collect::>(), + vec![ + (1, vec![Annotation::Thread(0)]), + ( + 3, + vec![ + Annotation::Thread(3), + Annotation::Pending(7), + Annotation::Draft + ] + ), + ] + ); + assert_eq!(off, [Annotation::Thread(1)]); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/body.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/body.rs new file mode 100644 index 000000000..260fb31e3 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/body.rs @@ -0,0 +1,368 @@ +//! The Code tab's list rows apart from the file headers and the comment +//! rows: gaps, diff chunks, the tail, threads, the loading footer; the +//! states shown instead of the list, the strip of threads not on the diff +//! and the header that stays on top while its file scrolls. + +use super::rows::{CodeRow, gap, gap_label}; +use super::{CodeTab, Shown}; +use crate::diff::render::{GutterNumbers, render_diff_chunk_rows}; +use crate::main_screen::right_panel::line_selection::{ChunkMarks, row_selection}; +use gpui_kit::component::button::Button; +use gpui_kit::component::{ActiveTheme, Sizable}; +use gpui_kit::{ + AnyElement, App, Context, FontWeight, SharedString, Window, div, prelude::*, px, rems, svg, +}; + +/// The off-diff strip's height at most; it scrolls beyond. +const OFF_DIFF_MAX_HEIGHT: f32 = 256.; + +impl CodeTab { + /// The list item at `ix`. + pub(super) fn render_row( + &mut self, + ix: usize, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let Some(row) = self.rows.get(ix).cloned() else { + return div().into_any_element(); + }; + let shown = |file: usize| self.shown.get(file).copied(); + let shown_file = match &row { + CodeRow::Note { file, .. } + | CodeRow::Gap { file, .. } + | CodeRow::Chunk { file, .. } => shown(*file), + _ => None, + }; + match row { + CodeRow::File { file } => self.render_file_header(file, false, cx), + CodeRow::Note { .. } => match shown_file { + Some(shown) => self.render_note(shown, cx), + None => div().into_any_element(), + }, + CodeRow::Gap { hunk, .. } => match shown_file { + Some(shown) => self.render_gap(shown, hunk, cx), + None => div().into_any_element(), + }, + CodeRow::Chunk { chunk, .. } => match shown_file { + Some(shown) => self.render_chunk(shown, chunk, window, cx), + None => div().into_any_element(), + }, + CodeRow::Tail { .. } => { + hairline_label("More unchanged context may be available".into(), false, cx) + } + CodeRow::Thread { thread, .. } => match self.thread_card(thread, cx) { + Some(card) => annotation(card.into_any_element()), + None => div().into_any_element(), + }, + CodeRow::Pending { id, .. } => self.render_pending(id, cx), + CodeRow::Draft { .. } => self.render_draft(cx), + CodeRow::Footer => self.render_footer(cx), + } + } + + fn render_note(&self, shown: Shown, cx: &App) -> AnyElement { + let file = self.file(shown); + let text = if file.diff.is_binary { + "Binary file not shown." + } else if file.diff.too_large || file.additions + file.deletions > 0 { + "GitHub did not send this file's changes (too large)." + } else if file.orig_path.is_some() { + "Renamed without changes." + } else { + "No content changes." + }; + div() + .px_4() + .py_1p5() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(text) + .into_any_element() + } + + fn render_gap(&self, shown: Shown, hunk: usize, cx: &App) -> AnyElement { + let hunks = &self.file(shown).diff.hunks; + let label = hunks + .get(hunk) + .and_then(|h| gap(hunk.checked_sub(1).and_then(|p| hunks.get(p)), h)) + .map(|(lines, section)| gap_label(lines, section)) + .unwrap_or_default(); + hairline_label(label.into(), true, cx) + } + + /// One chunk of a file's diff, its rows selectable on the whole change. + fn render_chunk( + &self, + shown: Shown, + chunk_ix: usize, + window: &Window, + cx: &Context, + ) -> AnyElement { + let file = self.file(shown); + let Some(chunk) = file.diff.chunked.chunks.get(chunk_ix) else { + return div().into_any_element(); + }; + let theme = cx.theme(); + let rem_size = window.rem_size(); + let rows = chunk.lines.len(); + let selection = self.commit.is_none().then(|| { + let marks = ChunkMarks { + highlight: self.selection.highlight(&file.path, chunk.flat_start, rows), + color: theme.selection, + marked: self.pending_marks(file, chunk.flat_start, rows, cx), + mark_color: theme.warning, + anchor: self + .anchors_selection_end(&file.path, chunk.flat_start, rows) + .then(|| self.selection_anchor.clone()), + }; + row_selection( + file.path.clone(), + chunk.flat_start, + marks, + self.focus_handle.clone(), + cx, + ) + }); + div() + .w_full() + .text_size(rems(0.78125)) + .line_height(rems(1.25).to_pixels(rem_size).round()) + .font_family(theme.mono_font_family.clone()) + .font_weight(FontWeight(400.0)) + .child(render_diff_chunk_rows( + &file.diff, + chunk, + GutterNumbers::New, + theme, + rem_size, + selection, + )) + .into_any_element() + } + + fn render_footer(&mut self, cx: &mut Context) -> AnyElement { + // In (or near) view: time for the next page. + if self.loading.is_none() && self.load_error.is_none() { + self.load_next(cx); + } + let row = div() + .debug_selector(|| "pr-code-footer".into()) + .py_2() + .flex() + .items_center() + .justify_center() + .gap_2() + .text_xs() + .text_color(cx.theme().muted_foreground); + match &self.load_error { + Some(_) => row + .child("The rest of this diff could not be loaded.") + .child( + Button::new("pr-code-retry") + .outline() + .xsmall() + .label("Retry") + .on_click(cx.listener(|this, _, _, cx| this.retry(cx))), + ), + None => row.child("Loading more files…"), + } + .into_any_element() + } + + /// What shows instead of the list: loading, a failure, no changes. + pub(super) fn render_state(&self, cx: &mut Context) -> Option { + let note = |text: SharedString| { + div() + .px_4() + .py_3() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(text) + }; + if !self.pages.is_empty() { + let more = self.pages.last().is_some_and(|p| p.next_cursor.is_some()); + if self.pages.iter().all(|p| p.files.is_empty()) && !more { + return Some( + note(match self.commit { + None => "This pull request has no file changes.".into(), + Some(_) => "This commit has no file changes.".into(), + }) + .into_any_element(), + ); + } + return None; + } + Some(match &self.load_error { + Some(error) => note(error.clone()) + .flex() + .flex_col() + .items_start() + .gap_2() + .child( + Button::new("pr-code-retry") + .outline() + .xsmall() + .label("Retry") + .on_click(cx.listener(|this, _, _, cx| this.retry(cx))), + ) + .into_any_element(), + None => note("Loading the diff…".into()).into_any_element(), + }) + } + + /// "Conversations not on the current diff (N)": threads whose line no + /// rendered row shows (outdated, on a hidden or not yet loaded file, or + /// in a commit's scope), grouped by file. + pub(super) fn render_off_diff(&mut self, cx: &mut Context) -> Option { + if self.off_diff.is_empty() { + return None; + } + let theme = cx.theme(); + let muted = theme.muted_foreground; + let more = self.pages.last().is_some_and(|p| p.next_cursor.is_some()); + let title = if more { + "Conversations not on the diff loaded so far" + } else { + "Conversations not on the current diff" + }; + let chevron = if self.off_diff_open { + "icons/chevron_down.svg" + } else { + "icons/chevron_right.svg" + }; + let header = div() + .id("pr-code-off-diff") + .debug_selector(|| "pr-code-off-diff".into()) + .px_4() + .py_2() + .flex() + .items_center() + .gap_1p5() + .text_xs() + .text_color(muted) + .cursor_pointer() + .child(title) + .child(svg().size(px(12.)).path(chevron).text_color(muted)) + .child(self.off_diff.len().to_string()) + .on_click(cx.listener(|this, _, _, cx| { + this.off_diff_open = !this.off_diff_open; + cx.notify(); + })); + let border = theme.border; + let mut strip = div() + .flex_none() + .border_b_1() + .border_color(border) + .child(header); + if self.off_diff_open { + let activity = self.activity.clone(); + let threads = activity.as_ref().map_or(&[][..], |a| &a.review_threads[..]); + // Grouped by file, files in the order their first thread came. + let mut groups: Vec<(&str, Vec)> = Vec::new(); + for &ix in &self.off_diff { + let path = threads[ix].path.as_str(); + match groups.iter_mut().find(|(p, _)| *p == path) { + Some((_, ixs)) => ixs.push(ix), + None => groups.push((path, vec![ix])), + } + } + let mut body = div().px_4().pb_3().flex().flex_col().gap_3(); + for (path, ixs) in groups { + let mut group = div().flex().flex_col().gap_1().child( + div() + .px_3() + .text_xs() + .text_color(muted) + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .child(path.to_owned()), + ); + for ix in ixs { + let line = threads[ix].line; + if let Some(card) = self.thread_card(ix, cx) { + group = group + .children(line.map(|line| { + div() + .px_3() + .text_xs() + .text_color(muted) + .child(format!("Line {line}")) + })) + .child(card); + } + } + body = body.child(group); + } + strip = strip.child( + div() + .id("pr-code-off-diff-threads") + .max_h(px(OFF_DIFF_MAX_HEIGHT)) + .overflow_y_scroll() + .child(body), + ); + } + Some(strip.into_any_element()) + } + + /// The header of the file at the top of the view, kept there while its + /// rows scroll under it. + pub(super) fn render_sticky_header(&mut self, cx: &mut Context) -> Option { + let top = self.list_state.logical_scroll_top(); + let file = match self.rows.get(top.item_ix)? { + CodeRow::File { .. } if top.offset_in_item == px(0.) => return None, + CodeRow::File { file } + | CodeRow::Note { file, .. } + | CodeRow::Gap { file, .. } + | CodeRow::Chunk { file, .. } + | CodeRow::Tail { file, .. } + | CodeRow::Thread { file, .. } + | CodeRow::Pending { file, .. } + | CodeRow::Draft { file } => *file, + CodeRow::Footer => return None, + }; + Some( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .shadow_sm() + .child(self.render_file_header(file, true, cx)) + .into_any_element(), + ) + } +} + +/// A row below a diff line: a thread or comment card, indented a little. +pub(super) fn annotation(card: AnyElement) -> AnyElement { + div().px_3().py_1().child(card).into_any_element() +} + +/// Centred muted text on a hairline ("30 unmodified lines"). +fn hairline_label(label: SharedString, above: bool, cx: &App) -> AnyElement { + let theme = cx.theme(); + let line = || div().flex_1().h(px(1.)).bg(theme.border); + div() + .w_full() + .px_3() + .when(above, |d| d.pt_2().pb_1()) + .when(!above, |d| d.pt_1().pb_2()) + .flex() + .items_center() + .gap_3() + .text_xs() + .text_color(theme.muted_foreground) + .child(line()) + .child( + div() + .min_w_0() + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .child(label), + ) + .child(line()) + .into_any_element() +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/comments.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/comments.rs new file mode 100644 index 000000000..864d90ffc --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/comments.rs @@ -0,0 +1,503 @@ +//! New comments on the diff: rows selected with the shared line selection +//! show the pill (copy, comment); commenting opens a box below the last +//! row, from which the comment goes to the pending review ("Add to +//! review", a dashed card on its line until the review is submitted) or to +//! the agent's draft ("Add to agent", like the Review view's line +//! comments). Only on the whole change: a commit's lines are not the ones +//! GitHub anchors comments to. + +use super::CodeTab; +use super::anchors::{RangeRef, range_of}; +use super::body::annotation; +use super::pages::LoadedFile; +use crate::comments::{CommentChange, floating, selection_pill}; +use crate::diff::model::DiffLine; +use code_assistant_core::line_comments::LineComment; +use github::model::DiffSide; +use gpui_kit::component::button::{Button, ButtonVariants}; +use gpui_kit::component::input::{Escape, InputEvent, Textarea, TextareaState}; +use gpui_kit::component::{ActiveTheme, Icon, Sizable}; +use gpui_kit::{ + AnyElement, ClipboardItem, Context, Entity, Subscription, Window, div, point, prelude::*, px, +}; +use similar::ChangeTag; +use std::ops::Range; +use std::path::PathBuf; + +use super::super::review_draft::PendingComment; + +/// The box a new comment is written in, below the last selected row. +pub(super) struct Draft { + pub path: String, + /// The commented rows, flat line indices, inclusive. + pub lo: usize, + pub hi: usize, + pub at: RangeRef, + input: Entity, + _subscription: Subscription, +} + +/// A file's rows in flat order (as the selection counts them). +fn flat_lines(file: &LoadedFile) -> impl Iterator { + file.diff.hunks.iter().flat_map(|hunk| &hunk.lines) +} + +/// Rows `lo..=hi` as shown, `+`/`-`/` ` prefixed. +fn excerpt(file: &LoadedFile, lo: usize, hi: usize) -> String { + flat_lines(file) + .skip(lo) + .take(hi + 1 - lo) + .map(|line| { + let prefix = match line.tag { + ChangeTag::Insert => '+', + ChangeTag::Delete => '-', + ChangeTag::Equal => ' ', + }; + format!("{prefix}{}", line.text) + }) + .collect::>() + .join("\n") +} + +/// "12" or "10-12", the lines of `at`, `L` for the old file's. +fn lines_label(at: &RangeRef) -> String { + let side = |side: DiffSide| match side { + DiffSide::Left => "L", + DiffSide::Right => "", + }; + match at.start { + Some(start) if start != at.end => format!( + "{}{}-{}{}", + side(start.side), + start.line, + side(at.end.side), + at.end.line + ), + _ => format!("{}{}", side(at.end.side), at.end.line), + } +} + +/// The agent's line comment on rows `lo..=hi` of `path`: their new-file +/// lines, or the old file's when only deleted rows are selected. +fn agent_comment( + path: &str, + numbers: &[(ChangeTag, usize, usize)], + lo: usize, + hi: usize, + excerpt: String, + text: String, +) -> Option { + let rows = numbers.get(lo..=hi)?; + let new_side: Vec = rows + .iter() + .filter(|(tag, ..)| *tag != ChangeTag::Delete) + .map(|&(_, _, new)| new) + .collect(); + let (old_side, lines) = if new_side.is_empty() { + (true, rows.iter().map(|&(_, old, _)| old).collect()) + } else { + (false, new_side) + }; + Some(LineComment { + id: 0, + file: PathBuf::from(path), + start_line: *lines.iter().min()?, + end_line: *lines.iter().max()?, + old_side, + in_diff: true, + on_message: false, + on_file: false, + excerpt, + text, + }) +} + +impl CodeTab { + fn selected_file(&self) -> Option<(&LoadedFile, usize, usize)> { + let selected = self.selection.get()?; + let file = self.file(self.shown[self.shown_ix(&selected.key)?]); + let (lo, hi) = selected.range(); + let hi = hi.min(file.numbers.len().checked_sub(1)?); + (lo <= hi).then_some((file, lo, hi)) + } + + pub(super) fn copy_selection(&mut self, cx: &mut Context) { + if let Some((file, lo, hi)) = self.selected_file() { + let text = flat_lines(file) + .skip(lo) + .take(hi + 1 - lo) + .map(|line| line.text.as_ref()) + .collect::>() + .join("\n"); + cx.write_to_clipboard(ClipboardItem::new_string(text)); + } + } + + /// Open the comment box below the selection. + pub(super) fn start_comment(&mut self, window: &mut Window, cx: &mut Context) { + if self.commit.is_some() { + return; + } + let Some((file, lo, hi)) = self.selected_file() else { + return; + }; + let Some(at) = range_of(&file.numbers, lo, hi) else { + return; + }; + let path = file.path.clone(); + let input = cx.new(|cx| { + TextareaState::new(window, cx) + .auto_grow(2, 8) + .placeholder("Add a comment…") + }); + input.update(cx, |input, cx| input.focus(window, cx)); + let subscription = cx.subscribe(&input, |this, _, event: &InputEvent, cx| { + if let InputEvent::PressEnter { + secondary: true, .. + } = event + { + this.add_to_review(cx); + } + }); + self.draft = Some(Draft { + path, + lo, + hi, + at, + input, + _subscription: subscription, + }); + self.place_annotations(cx); + } + + fn draft_text(&self, cx: &Context) -> Option { + let draft = self.draft.as_ref()?; + let text = draft.input.read(cx).value().trim().to_owned(); + (!text.is_empty()).then_some(text) + } + + /// The draft's comment into the pending review. + pub(super) fn add_to_review(&mut self, cx: &mut Context) { + let Some(body) = self.draft_text(cx) else { + return; + }; + let Some(draft) = self.draft.as_ref() else { + return; + }; + let Some(file) = self + .shown_ix(&draft.path) + .map(|ix| self.file(self.shown[ix])) + else { + return; + }; + let comment = PendingComment { + id: 0, + path: draft.path.clone(), + side: draft.at.end.side, + line: draft.at.end.line, + start_side: draft.at.start.map(|s| s.side), + start_line: draft.at.start.map(|s| s.line), + body, + excerpt: excerpt(file, draft.lo, draft.hi), + }; + let pr = self.pr.clone(); + self.drafts + .update(cx, |drafts, cx| drafts.add(&pr, comment, cx)); + self.close_draft(cx); + } + + /// The draft's comment into the agent's draft (the composer). + fn add_to_agent(&mut self, cx: &mut Context) { + let Some(text) = self.draft_text(cx) else { + return; + }; + let comment = self.draft.as_ref().and_then(|draft| { + let file = self.file(self.shown[self.shown_ix(&draft.path)?]); + agent_comment( + &draft.path, + &file.numbers, + draft.lo, + draft.hi, + excerpt(file, draft.lo, draft.hi), + text, + ) + }); + if let Some(comment) = comment { + crate::comments::report(CommentChange::Upsert(comment), cx); + } + self.close_draft(cx); + } + + fn close_draft(&mut self, cx: &mut Context) { + self.draft = None; + self.selection.clear(); + self.place_annotations(cx); + } + + /// Rows of a chunk (relative to it) a pending comment is on. + pub(super) fn pending_marks( + &self, + file: &LoadedFile, + base: usize, + rows: usize, + cx: &Context, + ) -> Vec> { + let chunk = base..base + rows; + self.drafts + .read(cx) + .comments(&self.pr) + .iter() + .filter(|c| c.path == file.path) + .filter_map(|c| { + let end = file.lines.row(super::anchors::LineRef { + side: c.side, + line: c.line, + })?; + let start = c + .start_side + .zip(c.start_line) + .and_then(|(side, line)| file.lines.row(super::anchors::LineRef { side, line })) + .unwrap_or(end); + let covered = start.max(chunk.start)..(end + 1).min(chunk.end); + (covered.start < covered.end).then(|| covered.start - base..covered.end - base) + }) + .collect() + } + + /// Whether the chunk of `path` from flat line `base` holds the + /// selection's last row. + pub(super) fn anchors_selection_end(&self, path: &String, base: usize, rows: usize) -> bool { + self.selection.get().is_some_and(|sel| { + let (_, hi) = sel.range(); + &sel.key == path && (base..base + rows).contains(&hi) + }) + } + + /// The pill under the selection's last row (not while the comment box + /// is open). + pub(super) fn render_floating( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + // Written by the last paint; taken so a row scrolled away hides it. + let row = self.selection_anchor.take(); + let dismissed = self.selection.get() == self.pill_dismissed.as_ref(); + let position = row + .filter(|_| { + self.draft.is_none() + && self.selection.get().is_some() + && !self.selection.is_dragging() + && !dismissed + }) + .map(|b| point(b.right() - px(72.), b.bottom() + px(2.))); + self.anchor.track(position, window); + let view = cx.entity().downgrade(); + let view_for_comment = view.clone(); + let dismiss = cx.listener(|this, _: &gpui_kit::MouseDownEvent, _, cx| { + this.pill_dismissed = this.selection.get().cloned(); + cx.notify(); + }); + Some(floating( + position?, + div().on_mouse_down_out(dismiss).child(selection_pill( + "pr-code-selection", + move |_, cx| { + view.update(cx, |view, cx| view.copy_selection(cx)).ok(); + }, + move |window, cx| { + view_for_comment + .update(cx, |view, cx| view.start_comment(window, cx)) + .ok(); + }, + cx, + )), + )) + } + + /// The comment box row. + pub(super) fn render_draft(&self, cx: &mut Context) -> AnyElement { + let Some(draft) = &self.draft else { + return div().into_any_element(); + }; + let theme = cx.theme(); + let card = div() + .id("pr-code-draft") + .debug_selector(|| "pr-code-draft".into()) + .flex() + .flex_col() + .gap_1p5() + .p_2() + .rounded_lg() + .border_1() + .border_color(theme.border) + .bg(theme.popover) + .capture_action(cx.listener(|this, _: &Escape, _, cx| { + this.close_draft(cx); + cx.stop_propagation(); + })) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(format!( + "Comment on {}:{}", + draft.path, + lines_label(&draft.at) + )), + ) + .child(Textarea::new(&draft.input)) + .child( + div() + .flex() + .items_center() + .gap_1() + .child(div().flex_1()) + .child( + Button::new("pr-code-draft-cancel") + .label("Cancel") + .xsmall() + .ghost() + .on_click(cx.listener(|this, _, _, cx| this.close_draft(cx))), + ) + .child( + Button::new("pr-code-draft-agent") + .label("Add to agent") + .xsmall() + .outline() + .on_click(cx.listener(|this, _, _, cx| this.add_to_agent(cx))), + ) + .child( + Button::new("pr-code-draft-review") + .label("Add to review") + .xsmall() + .primary() + .tooltip("⌘↵") + .on_click(cx.listener(|this, _, _, cx| this.add_to_review(cx))), + ), + ); + annotation(card.into_any_element()) + } + + /// A comment of the pending review: dashed, with "Pending" and delete. + pub(super) fn render_pending(&self, id: u64, cx: &mut Context) -> AnyElement { + let drafts = self.drafts.read(cx); + let Some(comment) = drafts.comments(&self.pr).iter().find(|c| c.id == id) else { + return div().into_any_element(); + }; + let theme = cx.theme(); + let at = RangeRef { + start: comment + .start_side + .zip(comment.start_line) + .map(|(side, line)| super::anchors::LineRef { side, line }), + end: super::anchors::LineRef { + side: comment.side, + line: comment.line, + }, + }; + let card = div() + .debug_selector(move || format!("pr-code-pending-{id}")) + .flex() + .flex_col() + .gap_1() + .p_2() + .rounded_lg() + .border_1() + .border_dashed() + .border_color(theme.warning.opacity(0.6)) + .child( + div() + .flex() + .items_center() + .gap_2() + .text_xs() + .text_color(theme.muted_foreground) + .child( + div() + .px_1p5() + .rounded_sm() + .bg(theme.warning.opacity(0.15)) + .text_color(theme.warning) + .child("Pending"), + ) + .child(format!("Line {}", lines_label(&at))) + .child(div().flex_1()) + .child( + Button::new(("pr-code-pending-delete", id)) + .ghost() + .xsmall() + .icon(Icon::default().path("icons/trash.svg")) + .tooltip("Remove from the review") + .on_click(cx.listener(move |this, _, _, cx| { + let pr = this.pr.clone(); + this.drafts + .update(cx, |drafts, cx| drafts.remove(&pr, id, cx)); + })), + ), + ) + .child( + div() + .text_sm() + .text_color(theme.foreground) + .child(comment.body.clone()), + ); + annotation(card.into_any_element()) + } +} + +#[cfg(test)] +mod tests { + use super::super::anchors::LineRef; + use super::*; + use ChangeTag::*; + + #[test] + fn agent_comments_take_new_lines_or_else_old_ones() { + let numbers = [ + (Equal, 10, 10), + (Delete, 11, 11), + (Insert, 12, 11), + (Equal, 12, 12), + ]; + let comment = agent_comment("src/a.rs", &numbers, 0, 2, "x".into(), "why".into()).unwrap(); + assert_eq!( + (comment.start_line, comment.end_line, comment.old_side), + (10, 11, false) + ); + assert_eq!(comment.file, PathBuf::from("src/a.rs")); + assert!(comment.in_diff); + let deleted = agent_comment("src/a.rs", &numbers, 1, 1, "x".into(), "why".into()).unwrap(); + assert_eq!( + (deleted.start_line, deleted.end_line, deleted.old_side), + (11, 11, true) + ); + assert!(agent_comment("src/a.rs", &numbers, 2, 7, "x".into(), "y".into()).is_none()); + } + + #[test] + fn line_labels_name_the_old_side() { + let right = |line| LineRef { + side: DiffSide::Right, + line, + }; + let left = |line| LineRef { + side: DiffSide::Left, + line, + }; + assert_eq!( + lines_label(&RangeRef { + start: None, + end: right(12) + }), + "12" + ); + assert_eq!( + lines_label(&RangeRef { + start: Some(left(11)), + end: right(13) + }), + "L11-13" + ); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/file_header.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/file_header.rs new file mode 100644 index 000000000..6f59438e1 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/file_header.rs @@ -0,0 +1,133 @@ +//! A file's header row: fold chevron, status icon (modified, added, +//! deleted, renamed), path, "Changed" when it changed since it was viewed, +//! `+A −D` and the "Viewed" checkbox (shift-click ticks a range). + +use super::super::presentation::diff_stat; +use super::CodeTab; +use crate::shared::theme::merged_violet; +use git::ChangeStatus; +use gpui_kit::component::checkbox::Checkbox; +use gpui_kit::component::{ActiveTheme, Sizable, Size}; +use gpui_kit::{AnyElement, App, Context, Hsla, SharedString, div, prelude::*, px, svg}; + +fn status_icon(status: ChangeStatus, cx: &App) -> (&'static str, Hsla) { + let theme = cx.theme(); + match status { + ChangeStatus::Added | ChangeStatus::Untracked => ("icons/circle_plus.svg", theme.success), + ChangeStatus::Deleted => ("icons/circle_minus.svg", theme.danger), + ChangeStatus::Renamed | ChangeStatus::Copied => { + ("icons/circle_arrow_right.svg", merged_violet()) + } + ChangeStatus::Modified | ChangeStatus::TypeChanged => ("icons/circle_dot.svg", theme.info), + } +} + +impl CodeTab { + /// The header of the shown file `ix`; `sticky` for the copy kept on top. + pub(super) fn render_file_header( + &self, + ix: usize, + sticky: bool, + cx: &mut Context, + ) -> AnyElement { + let Some(&shown) = self.shown.get(ix) else { + return div().into_any_element(); + }; + let file = self.file(shown); + let theme = cx.theme(); + let muted = theme.muted_foreground; + let folded = self.folds.is_folded(&file.path); + let chevron = if folded { + "icons/chevron_right.svg" + } else { + "icons/chevron_down.svg" + }; + let (icon, icon_color) = status_icon(file.status, cx); + let prefix = if sticky { "pr-code-sticky" } else { "pr-code" }; + let viewed = self.viewed.is_viewed(&file.path); + let stale = self.viewed.is_stale(&file.path); + let path: SharedString = match &file.orig_path { + Some(orig) if file.status == ChangeStatus::Renamed => { + format!("{orig} → {}", file.path).into() + } + _ => file.path.clone().into(), + }; + + let toggle = div() + .id(SharedString::from(format!("{prefix}-file-{ix}"))) + .debug_selector({ + let selector = format!("{prefix}-file-{ix}"); + move || selector + }) + .flex_1() + .min_w_0() + .flex() + .items_center() + .gap_1p5() + .cursor_pointer() + .child( + svg() + .flex_none() + .size(px(12.)) + .path(chevron) + .text_color(muted), + ) + .child( + svg() + .flex_none() + .size(px(14.)) + .path(icon) + .text_color(icon_color), + ) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .text_color(theme.foreground) + .child(path), + ) + .on_click(cx.listener(move |this, _, _, cx| this.toggle_fold(ix, cx))); + + let selector = format!("{prefix}-viewed-{ix}"); + let checkbox = div() + .flex_none() + .debug_selector({ + let selector = selector.clone(); + move || selector + }) + .child( + Checkbox::new(SharedString::from(selector)) + .label("Viewed") + .checked(viewed) + .with_size(Size::XSmall) + .text_color(muted) + .on_click(cx.listener(move |this, checked: &bool, window, cx| { + let extend = window.modifiers().shift; + this.set_viewed(ix, *checked, extend, cx); + })), + ); + + div() + .w_full() + .flex() + .items_center() + .gap_2() + .px_3() + .py_1p5() + .text_xs() + .bg(theme.background) + .border_t_1() + .border_b_1() + .border_color(theme.border) + .child(toggle) + .when(stale, |d| { + d.child(div().flex_none().text_color(theme.warning).child("Changed")) + }) + .children(diff_stat(file.additions, file.deletions, cx)) + .child(checkbox) + .into_any_element() + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/file_kind.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/file_kind.rs new file mode 100644 index 000000000..a1231f3bb --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/file_kind.rs @@ -0,0 +1,266 @@ +//! What a changed file is, by its path alone: a test (the "hide tests" +//! toggle) and which tier it is listed in (sources, then tests, then +//! generated files). Ported from T3 Code's `classifyChangedFile` and +//! `diffFileTier`; T3 keeps the two apart and so do we: the toggle hides +//! test support (mocks, fixtures) too, the order only knows the three tiers. + +/// The tier a file is listed in. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Tier { + Source, + Test, + Generated, +} + +const GENERATED_FILES: &[&str] = &[ + "bun.lock", + "bun.lockb", + "cargo.lock", + "composer.lock", + "deno.lock", + "flake.lock", + "gemfile.lock", + "go.sum", + "gradle.lockfile", + "mix.lock", + "npm-shrinkwrap.json", + "package-lock.json", + "packages.lock.json", + "pipfile.lock", + "pnpm-lock.yaml", + "podfile.lock", + "poetry.lock", + "pubspec.lock", + "uv.lock", + "yarn.lock", +]; +const GENERATED_DIRS: &[&str] = &["__generated__", "generated"]; +const MOCK_WORDS: &[&str] = &[ + "mock", "mocks", "fixture", "fixtures", "stub", "stubs", "fake", "fakes", +]; +const MOCK_DIRS: &[&str] = &[ + "__fixtures__", + "__mocks__", + "__snapshots__", + "fakes", + "fixture", + "fixtures", + "mock", + "mocks", + "stubs", + "testdata", +]; +const TEST_DIRS: &[&str] = &[ + "__tests__", + "__test__", + "cypress", + "e2e", + "spec", + "specs", + "test", + "tests", +]; +const CLASS_TEST_EXTENSIONS: &[&str] = &[ + "java", "kt", "kts", "scala", "groovy", "cs", "php", "swift", "m", +]; + +/// Whether the "hide tests" toggle hides `path`: tests and what only +/// tests use (mocks, fixtures, snapshots). +pub fn is_test_file(path: &str) -> bool { + let (dirs, name) = split_path(path); + let dirs: Vec = dirs.iter().map(|d| d.to_lowercase()).collect(); + let in_dir = |set: &[&str]| dirs.iter().any(|d| set.contains(&d.as_str())); + if is_generated_name(name) || in_dir(GENERATED_DIRS) { + return false; + } + is_mock_name(name) || in_dir(MOCK_DIRS) || is_test_name(name) || in_dir(TEST_DIRS) +} + +/// The tier `path` is listed in. +pub fn tier(path: &str) -> Tier { + const NAMES: &[&str] = &[ + "pnpm-lock.yaml", + "yarn.lock", + "package-lock.json", + "bun.lockb", + "Cargo.lock", + "go.sum", + "composer.lock", + "Gemfile.lock", + ]; + const DIRS: &[&str] = &["__snapshots__", "__generated__", "dist", "build", "vendor"]; + const TEST_DIRS: &[&str] = &["__tests__", "tests", "test"]; + let (dirs, name) = split_path(path); + if NAMES.contains(&name) + || [".snap", ".min.js", ".min.css"] + .iter() + .any(|end| name.ends_with(end)) + || name.contains(".generated.") + || dirs.iter().any(|d| DIRS.contains(d)) + { + Tier::Generated + } else if name.contains(".test.") + || name.contains(".spec.") + || dirs.iter().any(|d| TEST_DIRS.contains(d)) + { + Tier::Test + } else { + Tier::Source + } +} + +/// The directories and the file name of `path` (`\` counts as `/`). +fn split_path(path: &str) -> (Vec<&str>, &str) { + let mut segments: Vec<&str> = path.split(['/', '\\']).collect(); + let name = segments.pop().unwrap_or_default(); + (segments, name) +} + +/// `X` of a name ending in `.X.ext`. +fn inner_extension(name: &str) -> Option<&str> { + let mut parts = name.rsplitn(3, '.'); + let ext = parts.next()?; + let inner = parts.next()?; + (parts.next().is_some() && !ext.is_empty()).then_some(inner) +} + +fn is_generated_name(name: &str) -> bool { + GENERATED_FILES.contains(&name.to_lowercase().as_str()) + || name.contains(".generated.") + || name.contains(".gen.") + || [ + ".g.dart", + ".pb.go", + "_pb2.py", + "_pb2_grpc.py", + ".pb.cc", + ".pb.h", + ".min.js", + ".min.css", + ".js.map", + ".css.map", + ] + .iter() + .any(|end| name.ends_with(end)) +} + +/// Mocks, fixtures, stubs, fakes (by an inner extension or a leading word) +/// and snapshots. +fn is_mock_name(name: &str) -> bool { + if name.ends_with(".snap") || inner_extension(name).is_some_and(|x| MOCK_WORDS.contains(&x)) { + return true; + } + MOCK_WORDS.iter().any(|word| { + name.strip_prefix(word).is_some_and(|rest| { + rest.chars() + .next() + .is_none_or(|c| matches!(c, '-' | '_' | '.') || c.is_ascii_uppercase()) + }) + }) +} + +fn is_test_name(name: &str) -> bool { + let lower = name.to_lowercase(); + let stem_ends = |suffix: &str| { + lower + .rsplit_once('.') + .is_some_and(|(stem, ext)| !ext.is_empty() && stem.ends_with(suffix)) + }; + inner_extension(&lower).is_some_and(|x| matches!(x, "test" | "spec" | "e2e")) + || stem_ends("_test") + || stem_ends("_spec") + || (lower.starts_with("test_") && lower.ends_with(".py") && lower.len() > 8) + || lower == "conftest.py" + || ["vitest.setup.", "jest.setup."] + .iter() + .any(|p| lower.strip_prefix(p).is_some_and(|ext| !ext.is_empty() && !ext.contains('.'))) + // Case-sensitive: `FooTest.java` is a test class, `Latest.java` is not. + || name.rsplit_once('.').is_some_and(|(stem, ext)| { + CLASS_TEST_EXTENSIONS.contains(&ext) + && ["Test", "Tests", "Spec"].iter().any(|s| stem.ends_with(s)) + }) + // A Rust module of tests (`mod tests;`). + || name == "tests.rs" +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tests_and_test_support_are_test_files() { + for path in [ + "src/a.test.ts", + "src/a.spec.tsx", + "e2e/login.e2e.ts", + "pkg/a_test.go", + "lib/a_spec.rb", + "tests/test_api.py", + "conftest.py", + "vitest.setup.ts", + "src/FooTest.java", + "src/FooTests.cs", + "crates/a/tests/it.rs", + "crates/a/src/session/tests.rs", + "src/__tests__/a.ts", + "test/fixtures/pr.json", + "src/__mocks__/fs.ts", + "src/api.mock.ts", + "src/mockServer.ts", + "src/fixtures-data.json", + "src/__snapshots__/a.ts.snap", + "testdata/input.txt", + "tests/README.md", + ] { + assert!(is_test_file(path), "{path} is a test file"); + } + } + + #[test] + fn code_docs_and_generated_files_are_not() { + for path in [ + "src/contest.py", + "src/Latest.java", + "src/testing.ts", + "src/mockingbird.ts", + "README.md", + "pnpm-lock.yaml", + "Cargo.lock", + "src/api.generated.ts", + "tests/generated/a.ts", + "public/app.min.js", + "src/a.rs", + ] { + assert!(!is_test_file(path), "{path} is no test file"); + } + } + + #[test] + fn tiers_follow_the_path_conventions() { + for path in ["src/app.ts", "src/dist.ts", "src/testing.ts"] { + assert_eq!(tier(path), Tier::Source, "{path}"); + } + for path in [ + "src/__tests__/app.ts", + "src/app.spec.tsx", + "src/app.test.ts", + "test/app.ts", + "tests/helpers/app.ts", + ] { + assert_eq!(tier(path), Tier::Test, "{path}"); + } + for path in [ + "apps/web/package-lock.json", + "dist/app.js", + "packages/core/vendor/lib.js", + "pnpm-lock.yaml", + "public/app.min.js", + "src/__snapshots__/app.ts", + "src/api.generated.ts", + "src/app.test.ts.snap", + "Cargo.lock", + ] { + assert_eq!(tier(path), Tier::Generated, "{path}"); + } + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/file_order.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/file_order.rs new file mode 100644 index 000000000..7ea3b0b1b --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/file_order.rs @@ -0,0 +1,422 @@ +//! The order the Code tab lists files in, ported from T3 Code's +//! `orderDiffFiles` and `orderDiffFilesLikeTree`: within a diff page, +//! sources before tests before generated files ([`Tier`]), a source after +//! the sources it imports, tests in the order of the sources they cover; +//! then all pages regrouped so each directory stays together, as a file +//! tree would list them. +//! +//! Imports are read from the diff's lines with the patterns T3 knows +//! (`from "…"`, `import "…"`, `require("…")`); Rust's `use` / `mod` are not +//! resolved, so Rust sources keep path order. + +use super::file_kind::{Tier, tier}; +use std::collections::{HashMap, HashSet}; + +const MODULE_EXTENSIONS: &[&str] = &[".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]; + +/// The order of one page's files: indices into `paths`. `lines` yields the +/// diff lines of the file at an index. +pub fn order_files<'a, L>(paths: &[&str], lines: impl Fn(usize) -> L) -> Vec +where + L: IntoIterator, +{ + let tiers: Vec = paths.iter().map(|p| tier(p)).collect(); + let tiers = &tiers; + let of_tier = |t: Tier| (0..paths.len()).filter(move |&ix| tiers[ix] == t); + + let sources: Vec = of_tier(Tier::Source).collect(); + let mut by_module: HashMap<&str, usize> = HashMap::new(); + let mut by_base: HashMap<&str, usize> = HashMap::new(); + let mut ambiguous: HashSet<&str> = HashSet::new(); + for &ix in &sources { + by_module.insert(strip_extension(paths[ix]), ix); + let base = base_name(paths[ix]); + if by_base.insert(base, ix).is_some() { + ambiguous.insert(base); + } + } + by_base.retain(|base, _| !ambiguous.contains(base)); + + let imports: HashMap> = sources + .iter() + .map(|&ix| { + let imported = lines(ix) + .into_iter() + .flat_map(import_specifiers) + .filter_map(|spec| resolve(paths[ix], spec, &by_module, &by_base)) + .filter(|&target| target != ix) + .collect(); + (ix, imported) + }) + .collect(); + let ordered_sources = order_by_imports(&sources, paths, &imports); + + let mut source_positions: HashMap<&str, usize> = HashMap::new(); + for (position, &ix) in ordered_sources.iter().enumerate() { + source_positions + .entry(base_name(paths[ix])) + .or_insert(position); + } + let mut tests: Vec = of_tier(Tier::Test).collect(); + tests.sort_by(|&a, &b| { + let position = |ix: usize| { + source_positions + .get(tested_base_name(paths[ix]).as_str()) + .copied() + .unwrap_or(usize::MAX) + }; + position(a) + .cmp(&position(b)) + .then_with(|| paths[a].cmp(paths[b])) + }); + let mut generated: Vec = of_tier(Tier::Generated).collect(); + generated.sort_by(|&a, &b| paths[a].cmp(paths[b])); + + ordered_sources + .into_iter() + .chain(tests) + .chain(generated) + .collect() +} + +/// `paths` regrouped so each directory stays contiguous, in the order its +/// first file had: indices into `paths`. +pub fn order_like_tree(paths: &[&str]) -> Vec { + let mut positions: HashMap<&str, usize> = HashMap::new(); + for (ix, path) in paths.iter().enumerate() { + positions.insert(path, ix); + for (end, _) in path.match_indices('/') { + positions.entry(&path[..=end]).or_insert(ix); + } + } + let keys: Vec> = paths + .iter() + .map(|path| { + path.match_indices('/') + .map(|(end, _)| positions[&path[..=end]]) + .chain([positions[path]]) + .collect() + }) + .collect(); + let mut order: Vec = (0..paths.len()).collect(); + // Lexicographic on the keys, a shorter key (a file beside a directory + // that started at the same position) first. + order.sort_by(|&a, &b| keys[a].cmp(&keys[b])); + order +} + +/// Picks, in path order, the first file whose imports are all placed; in a +/// cycle the first remaining file. +fn order_by_imports( + files: &[usize], + paths: &[&str], + imports: &HashMap>, +) -> Vec { + let mut sorted = files.to_vec(); + sorted.sort_by(|&a, &b| paths[a].cmp(paths[b])); + let mut remaining: HashSet = files.iter().copied().collect(); + let mut ordered = Vec::with_capacity(files.len()); + while !remaining.is_empty() { + let mut candidates = sorted.iter().copied().filter(|ix| remaining.contains(ix)); + let first = candidates.clone().next(); + let next = candidates + .find(|ix| imports[ix].iter().all(|dep| !remaining.contains(dep))) + .or(first) + .expect("a file remains"); + remaining.remove(&next); + ordered.push(next); + } + ordered +} + +/// The file a specifier names among the page's sources: by module path +/// (with or without `/index`), else by its last segment when that is +/// unambiguous. +fn resolve( + from: &str, + specifier: &str, + by_module: &HashMap<&str, usize>, + by_base: &HashMap<&str, usize>, +) -> Option { + let path = if specifier.starts_with('.') { + resolve_relative(from, specifier) + } else { + specifier.to_owned() + }; + // Module keys omit extensions, which can differ from disk (`.js` for `.ts`). + let module = MODULE_EXTENSIONS + .iter() + .find_map(|ext| path.strip_suffix(ext)) + .unwrap_or(&path); + by_module + .get(module) + .or_else(|| by_module.get(format!("{module}/index").as_str())) + .or_else(|| by_base.get(base_name(module))) + .copied() +} + +fn resolve_relative(from: &str, specifier: &str) -> String { + let mut segments: Vec<&str> = from.split('/').collect(); + segments.pop(); + for part in specifier.split('/') { + match part { + "." | "" => {} + ".." => { + segments.pop(); + } + part => segments.push(part), + } + } + segments.join("/") +} + +/// The quoted specifiers of `from "…"`, `import "…"` and `require("…")` in +/// one line (T3's `IMPORT_SPECIFIER`). +fn import_specifiers(line: &str) -> Vec<&str> { + let bytes = line.as_bytes(); + let is_word = |b: u8| b.is_ascii_alphanumeric() || b == b'_'; + let skip_space = |mut i: usize| { + while i < bytes.len() && bytes[i].is_ascii_whitespace() { + i += 1; + } + i + }; + let mut specifiers = Vec::new(); + let mut i = 0; + while i < bytes.len() { + let keyword = [&b"from"[..], b"import", b"require"] + .into_iter() + .find(|k| bytes[i..].starts_with(k)); + let found = keyword + .filter(|_| i == 0 || !is_word(bytes[i - 1])) + .and_then(|keyword| { + let mut j = skip_space(i + keyword.len()); + if keyword == b"require" { + if bytes.get(j) != Some(&b'(') { + return None; + } + j = skip_space(j + 1); + } + if bytes.get(j) == Some(&b'(') { + j += 1; + } + j = skip_space(j); + if !matches!(bytes.get(j), Some(b'"' | b'\'')) { + return None; + } + let start = j + 1; + let len = bytes[start..] + .iter() + .position(|&b| b == b'"' || b == b'\'')?; + (len > 0).then_some((start, start + len)) + }); + match found { + Some((start, end)) => { + specifiers.push(&line[start..end]); + i = end + 1; + } + None => i += 1, + } + } + specifiers +} + +fn strip_extension(path: &str) -> &str { + match (path.rfind('.'), path.rfind('/')) { + (Some(dot), Some(slash)) if dot > slash => &path[..dot], + (Some(dot), None) => &path[..dot], + _ => path, + } +} + +fn base_name(path: &str) -> &str { + strip_extension(path.rsplit('/').next().unwrap_or_default()) +} + +/// The base name of the source a test covers: `a` for `a.test.ts` and for +/// `__tests__/a.ts`. +fn tested_base_name(path: &str) -> String { + let name = path.rsplit('/').next().unwrap_or_default(); + let name = [".test.", ".spec."] + .iter() + .filter_map(|marker| name.find(marker)) + .min() + .map_or(name, |at| &name[..at]); + name.rsplit_once('.') + .map_or(name, |(stem, _)| stem) + .to_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Order `files` (path, added lines) and return the paths. + fn order(files: &[(&str, &[&str])]) -> Vec { + let paths: Vec<&str> = files.iter().map(|(p, _)| *p).collect(); + order_files(&paths, |ix| files[ix].1.iter().copied()) + .into_iter() + .map(|ix| paths[ix].to_owned()) + .collect() + } + + fn plain<'a>(paths: &[&'a str]) -> Vec<(&'a str, &'static [&'static str])> { + paths.iter().map(|p| (*p, &[][..])).collect() + } + + #[test] + fn sources_come_before_tests_and_generated_files() { + let source = ["src/app.ts", "src/dist.ts", "src/testing.ts"]; + let tests = [ + "src/__tests__/app.ts", + "src/app.spec.tsx", + "src/app.test.ts", + "test/app.ts", + "tests/helpers/app.ts", + ]; + let generated = [ + "apps/web/package-lock.json", + "dist/app.js", + "packages/core/vendor/lib.js", + "pnpm-lock.yaml", + "public/app.min.js", + "src/__snapshots__/app.ts", + "src/api.generated.ts", + "src/app.test.ts.snap", + ]; + let mut all: Vec<&str> = [&generated[..], &tests, &source].concat(); + all.reverse(); + let expected: Vec<&str> = [&source[..], &tests, &generated].concat(); + assert_eq!(order(&plain(&all)), expected); + } + + #[test] + fn imports_resolve_relative_index_extension_and_last_segment() { + assert_eq!( + order(&[ + ("src/ui/a.ts", &[r#"import { b } from "../lib/b";"#]), + ("src/lib/b.ts", &[]) + ]), + ["src/lib/b.ts", "src/ui/a.ts"] + ); + assert_eq!( + order(&[ + ("src/a.ts", &[r#"import { b } from "./lib";"#]), + ("src/lib/index.ts", &[]) + ]), + ["src/lib/index.ts", "src/a.ts"] + ); + assert_eq!( + order(&[ + ("src/a.ts", &[r#"import { b } from "~/lib/b";"#]), + ("lib/b.ts", &[]) + ]), + ["lib/b.ts", "src/a.ts"] + ); + assert_eq!( + order(&[ + ("src/a.ts", &[r#"import { b } from "./b.js";"#]), + ("src/b.ts", &[]) + ]), + ["src/b.ts", "src/a.ts"] + ); + } + + #[test] + fn an_ambiguous_last_segment_is_not_guessed() { + assert_eq!( + order(&[ + ("src/a.ts", &[r#"import { b } from "~/somewhere/b";"#]), + ("one/b.ts", &[]), + ("two/b.ts", &[]), + ]), + ["one/b.ts", "src/a.ts", "two/b.ts"] + ); + } + + #[test] + fn require_and_bare_imports_count_and_cycles_keep_path_order() { + assert_eq!( + order(&[ + ( + "src/a.ts", + &[r#"const b = require("./b");"#, r#"import "./c";"#] + ), + ("src/b.ts", &[]), + ("src/c.ts", &[]), + ]), + ["src/b.ts", "src/c.ts", "src/a.ts"] + ); + assert_eq!( + order(&[ + ("src/b.ts", &[r#"import { a } from "./a";"#]), + ("src/a.ts", &[r#"import { b } from "./b";"#]), + ]), + ["src/a.ts", "src/b.ts"] + ); + } + + #[test] + fn tests_follow_the_sources_they_cover() { + assert_eq!( + order(&[ + ("pnpm-lock.yaml", &[]), + ("src/a.test.ts", &[]), + ("src/__tests__/b.ts", &[]), + ("src/a.ts", &[r#"import { b } from "./b";"#]), + ("src/b.ts", &[]), + ("dist/a.js", &[]), + ]), + [ + "src/b.ts", + "src/a.ts", + "src/__tests__/b.ts", + "src/a.test.ts", + "dist/a.js", + "pnpm-lock.yaml", + ] + ); + // A test whose source is not in the diff comes last. + assert_eq!( + order(&plain(&["src/orphan.spec.ts", "src/a.test.ts", "src/a.ts"])), + ["src/a.ts", "src/a.test.ts", "src/orphan.spec.ts"] + ); + } + + #[test] + fn specifiers_are_read_like_t3s_pattern() { + assert_eq!( + import_specifiers(r#"import x from 'a'; export * from "b""#), + ["a", "b"] + ); + assert_eq!(import_specifiers(r#"await import("./lazy")"#), ["./lazy"]); + assert!(import_specifiers(r#"transform("x"); fromage "y""#).is_empty()); + assert!(import_specifiers(r#"from """#).is_empty()); + assert!(import_specifiers("grüße from ünïcode").is_empty()); + } + + #[test] + fn tree_order_keeps_directories_together() { + let paths = [ + "src/b.ts", + "lib/x.ts", + "src/a.ts", + "README.md", + "src/ui/c.ts", + ]; + let ordered: Vec<&str> = order_like_tree(&paths) + .into_iter() + .map(|ix| paths[ix]) + .collect(); + assert_eq!( + ordered, + [ + "src/b.ts", + "src/a.ts", + "src/ui/c.ts", + "lib/x.ts", + "README.md" + ] + ); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/folds.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/folds.rs new file mode 100644 index 000000000..de3408605 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/folds.rs @@ -0,0 +1,107 @@ +//! Which files show folded, and which a shift-click ticks. Ported from T3 +//! Code's `pullRequestDiff.logic.ts`: a file's fold is kept relative to +//! the toolbar's collapse / expand all, so pages loaded later follow it. + +use std::collections::HashSet; +use std::ops::Range; + +#[derive(Debug, Default)] +pub struct Folds { + /// Collapse all was pressed last (rather than expand all). + all_folded: bool, + /// Files folded the other way. + toggled: HashSet, +} + +impl Folds { + pub fn is_folded(&self, path: &str) -> bool { + self.toggled.contains(path) != self.all_folded + } + + pub fn toggle(&mut self, path: &str) { + if !self.toggled.remove(path) { + self.toggled.insert(path.to_owned()); + } + } + + /// Fold or unfold `path` (ticking a file folds it, unticking unfolds). + pub fn set(&mut self, path: &str, folded: bool) { + if self.is_folded(path) != folded { + self.toggle(path); + } + } + + /// Collapse or expand every file, now and as pages load. + pub fn set_all(&mut self, folded: bool) { + self.all_folded = folded; + self.toggled.clear(); + } + + /// Whether all of `paths` are folded (and there are some). + pub fn all_folded<'a>(&self, mut paths: impl Iterator) -> bool { + let mut any = false; + let all = paths.all(|path| { + any = true; + self.is_folded(path) + }); + any && all + } +} + +/// The files a click on `ix`'s checkbox ticks: with `anchor` (a shift-click +/// after a click on another file) everything between the two, else `ix` +/// alone. +pub fn viewed_range(ix: usize, anchor: Option) -> Range { + match anchor { + Some(anchor) => ix.min(anchor)..ix.max(anchor) + 1, + None => ix..ix + 1, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn folds_are_kept_relative_to_collapse_all() { + let mut folds = Folds::default(); + assert!(!folds.is_folded("a")); + folds.toggle("a"); + assert!(folds.is_folded("a")); + folds.set_all(true); + assert!(folds.is_folded("a") && folds.is_folded("later")); + folds.toggle("b"); + assert!(!folds.is_folded("b")); + folds.set_all(false); + assert!(!folds.is_folded("a") && !folds.is_folded("b")); + } + + #[test] + fn setting_a_fold_only_changes_it_when_it_differs() { + let mut folds = Folds::default(); + folds.set("a", true); + folds.set("a", true); + assert!(folds.is_folded("a")); + folds.set_all(true); + folds.set("a", false); + folds.set("a", false); + assert!(!folds.is_folded("a")); + assert!(folds.is_folded("b")); + } + + #[test] + fn all_folded_needs_files() { + let mut folds = Folds::default(); + assert!(!folds.all_folded(std::iter::empty())); + folds.set("a", true); + assert!(folds.all_folded(["a"].into_iter())); + assert!(!folds.all_folded(["a", "b"].into_iter())); + } + + #[test] + fn a_shift_click_ticks_the_range_from_the_last_click() { + assert_eq!(viewed_range(3, None), 3..4); + assert_eq!(viewed_range(3, Some(1)), 1..4); + assert_eq!(viewed_range(1, Some(3)), 1..4); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/mod.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/mod.rs new file mode 100644 index 000000000..05d94d248 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/mod.rs @@ -0,0 +1,825 @@ +//! The Code tab: a pull request's changed files (or one commit's) with +//! their diffs, review threads next to their lines and the pending +//! review's comments, modelled on T3 Code's `PullRequestCodeTab`. +//! +//! - **Loading**: the diff page by page (`pull_request_diff`), the first +//! when the tab is first drawn, the next when the end of the list comes +//! into view; each page parsed and highlighted once, off the UI thread +//! ([`pages`]). A re-read (the core announces a changed diff, or the head +//! moved) keeps unchanged pages and everything built from them. +//! - **Order**: per page by imports and tiers, then as a file tree would +//! list them ([`file_order`]); tests can be hidden ([`file_kind`]). +//! - **Viewed**: optimistic ticks, written in batches ([`viewed`]); ticking +//! folds a file, a shift-click ticks a range ([`folds`]). +//! - **List**: one virtualized list over flat rows ([`rows`]): headers, +//! gaps, ≤ 40-line chunks of the shared diff layer, threads, pending +//! comments and the comment box right below their line ([`anchors`]). +//! State changes reach it as one splice, so heights and the scroll +//! position survive; rows are rebuilt only when something changed. +//! - **Comments**: a selection of rows → the pill → a box below the last +//! row → the pending review ([`review_draft`](super::review_draft)) or +//! the agent's draft ([`comments`]). Not in a commit's scope: GitHub +//! anchors comments to the whole change. + +mod anchors; +mod body; +mod comments; +mod file_header; +mod file_kind; +mod file_order; +mod folds; +mod pages; +mod rows; +mod toolbar; +mod viewed; + +use super::PullRequestIntent; +use super::bus::{PullRequestEvent, bus}; +use super::conversation::thread::ThreadCard; +use super::data::{PullRequestData, session_service}; +use super::detail::TabScroll; +use super::review_draft::{ReviewDrafts, review_drafts}; +use crate::comments::AnchorTracker; +use crate::diff::prepare::highlight_patch; +use crate::main_screen::right_panel::line_selection::{LineSelection, Selected, SelectsLines}; +use crate::main_screen::right_panel::review_rows::changed_span; +use anchors::{Annotation, LineRef, place}; +use code_assistant_core::pull_requests::PullRequestPart; +use comments::Draft; +use file_order::order_like_tree; +use folds::{Folds, viewed_range}; +use github::PullRequestRef; +use github::model::{Activity, ReviewThread}; +use gpui_kit::component::ActiveTheme; +use gpui_kit::{ + App, Bounds, Context, Entity, EventEmitter, FocusHandle, Focusable, KeyDownEvent, + ListAlignment, ListState, Pixels, Render, SharedString, Subscription, Task, Window, div, list, + prelude::*, px, +}; +use pages::{Arrival, LoadedFile, Page, arrival, fingerprint, parse_page}; +use rows::{CodeRow, FileBody, FileOutline, files_by_proximity, flatten}; +use std::cell::Cell; +use std::collections::{BTreeMap, HashMap}; +use std::hash::{Hash, Hasher}; +use std::rc::Rc; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use viewed::{Batch, ViewedFiles}; + +/// Presses on "Viewed" wait this long for more before they are written. +const VIEWED_FLUSH_DELAY: Duration = Duration::from_millis(400); + +/// How far beyond the viewport the list builds rows; the footer within it +/// asks for the next page. +const LIST_OVERDRAW: Pixels = px(512.); + +/// A shown file: its page and its index there. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Shown { + page: usize, + file: usize, +} + +pub struct CodeTab { + data: Entity, + pr: PullRequestRef, + /// The commit the diff is scoped to; `None` for all of them. + pub(super) commit: Option, + /// Bumped with each new scope; arrivals for an older one are dropped. + scope: u64, + /// The first page was asked for (on the first draw). + started: bool, + pages: Vec, + /// The page read under way. + loading: Option>, + /// The file being highlighted. + highlighting: Option>, + /// When the tab was first drawn, until its first rows show. + mounted_at: Option, + load_error: Option, + next_stamp: u64, + + /// The files in the list, in order. + shown: Vec, + test_files: usize, + hide_tests: bool, + folds: Folds, + + viewed: ViewedFiles, + viewed_read: Option>, + viewed_flush: Option>, + viewed_write: Option>, + /// The last file whose checkbox was clicked: a shift-click ticks from it. + last_ticked: Option, + + activity: Option>, + head_sha: Option, + /// Per review thread: what its row shows, for the list's change check. + thread_versions: Vec, + /// Per shown file: what goes below which of its rows. + annotations: Vec>>, + /// Threads no rendered row shows, by index. + off_diff: Vec, + off_diff_open: bool, + thread_cards: HashMap, Subscription)>, + /// How many commits the scope menu lists. + commits_shown: usize, + + rows: Vec, + list_state: ListState, + /// `rows` needs rebuilding. + dirty: bool, + + selection: LineSelection, + /// Window bounds of the selection's last row, written when painted. + selection_anchor: Rc>>>, + anchor: AnchorTracker, + /// The selection whose pill a press elsewhere dismissed. + pill_dismissed: Option>, + draft: Option, + drafts: Entity, + + focus_handle: FocusHandle, + _subscriptions: Vec, +} + +impl EventEmitter for CodeTab {} + +impl CodeTab { + /// How far the tab is scrolled; the header condenses once it is. + pub fn scroll(&self) -> TabScroll { + TabScroll::of(&self.list_state) + } + + pub fn new(data: Entity, cx: &mut Context) -> Self { + let pr = data.read(cx).pr.clone(); + let events = bus(cx); + let drafts = review_drafts(cx); + let subscriptions = vec![ + cx.observe(&data, |this, _, cx| this.sync_data(cx)), + cx.subscribe(&events, |this, _, event: &PullRequestEvent, cx| { + if let PullRequestEvent::Changed { pr, part } = event + && *pr == this.pr + && this.started + { + match part { + PullRequestPart::Diff => this.reload(cx), + PullRequestPart::FilesViewed => this.read_viewed(cx), + _ => {} + } + } + }), + cx.observe(&drafts, |this, _, cx| this.place_annotations(cx)), + // Presses still waiting for their batch go out on the way. + cx.on_release(|this, cx| this.write_remaining_viewed(cx)), + ]; + let mut this = Self { + data, + pr, + commit: None, + scope: 0, + started: false, + pages: Vec::new(), + loading: None, + highlighting: None, + mounted_at: None, + load_error: None, + next_stamp: 0, + shown: Vec::new(), + test_files: 0, + hide_tests: false, + folds: Folds::default(), + viewed: ViewedFiles::default(), + viewed_read: None, + viewed_flush: None, + viewed_write: None, + last_ticked: None, + activity: None, + head_sha: None, + thread_versions: Vec::new(), + annotations: Vec::new(), + off_diff: Vec::new(), + off_diff_open: false, + thread_cards: HashMap::new(), + commits_shown: toolbar::COMMIT_PAGE, + rows: Vec::new(), + list_state: ListState::new(0, ListAlignment::Top, LIST_OVERDRAW).measure_all(), + dirty: true, + selection: LineSelection::default(), + selection_anchor: Rc::default(), + anchor: AnchorTracker::default(), + pill_dismissed: None, + draft: None, + drafts, + focus_handle: cx.focus_handle(), + _subscriptions: subscriptions, + }; + this.sync_data(cx); + this + } + + // -- Data -------------------------------------------------------------- + + /// Take what the page read: threads and commits from the activity; a + /// new head reads the diff and the ticks again. + fn sync_data(&mut self, cx: &mut Context) { + let (activity, head_sha) = { + let data = self.data.read(cx); + let head = data.detail.as_ref().and_then(|d| d.head_sha.clone()); + (data.activity.clone(), head) + }; + if head_sha.is_some() && head_sha != self.head_sha { + let moved = self.head_sha.is_some(); + self.head_sha = head_sha; + if moved && self.started { + self.reload(cx); + self.read_viewed(cx); + } + } + let same = match (&activity, &self.activity) { + (Some(new), Some(old)) => Arc::ptr_eq(new, old), + (None, None) => true, + _ => false, + }; + if same { + return; + } + self.activity = activity.clone(); + let threads = activity.as_ref().map_or(&[][..], |a| &a.review_threads[..]); + self.thread_versions = threads.iter().map(thread_version).collect(); + self.thread_cards + .retain(|id, _| threads.iter().any(|thread| &thread.id == id)); + for thread in threads { + if let Some((card, _)) = self.thread_cards.get(&thread.id) { + card.update(cx, |card, cx| card.set_thread(thread, cx)); + } + } + self.place_annotations(cx); + } + + /// Read the first page and the ticks; called on the first draw. + fn start(&mut self, cx: &mut Context) { + self.started = true; + self.mounted_at = Some(Instant::now()); + self.load_page(None, cx); + self.read_viewed(cx); + } + + /// Read the first page again; the pages after it stay unless it changed. + fn reload(&mut self, cx: &mut Context) { + self.loading = None; + self.load_error = None; + self.load_page(None, cx); + } + + /// Read the page after the last one, unless one is under way or the + /// last read failed (the footer offers a retry). + fn load_next(&mut self, cx: &mut Context) { + if self.loading.is_some() || self.load_error.is_some() { + return; + } + if let Some(cursor) = self.pages.last().and_then(|p| p.next_cursor.clone()) { + self.load_page(Some(cursor), cx); + } + } + + fn retry(&mut self, cx: &mut Context) { + self.load_error = None; + match self.pages.is_empty() { + true => self.load_page(None, cx), + false => self.load_next(cx), + } + cx.notify(); + } + + fn load_page(&mut self, cursor: Option, cx: &mut Context) { + let Some(service) = session_service(cx) else { + return; + }; + let (pr, commit, scope) = (self.pr.clone(), self.commit.clone(), self.scope); + self.loading = Some(cx.spawn(async move |this, cx| { + let started = Instant::now(); + let result = service.pull_request_diff(pr, commit, cursor.clone()).await; + tracing::debug!(?cursor, elapsed = ?started.elapsed(), "pull request diff page read"); + let page = match result { + Ok(fetched) => fetched.value, + Err(error) => { + this.update(cx, |this, cx| { + if this.scope == scope { + this.loading = None; + this.load_error = Some(format!("{error:#}").into()); + cx.notify(); + } + }) + .ok(); + return; + } + }; + let print = fingerprint(&page); + let wanted = this + .read_with(cx, |this, _| { + this.scope == scope + && matches!( + arrival(&this.pages, cursor.as_deref(), print), + Arrival::Place(_) + ) + }) + .unwrap_or(false); + if !wanted { + this.update(cx, |this, _| this.loading = None).ok(); + return; + } + let (next_cursor, truncated) = (page.next_cursor.clone(), page.truncated); + let parsing = Instant::now(); + let files = cx.background_spawn(async move { parse_page(&page) }).await; + tracing::debug!( + files = files.len(), + elapsed = ?parsing.elapsed(), + "pull request diff page parsed" + ); + this.update(cx, |this, cx| { + if this.scope == scope { + this.page_arrived( + Page { + cursor, + fingerprint: print, + next_cursor, + truncated, + files, + }, + cx, + ); + } + }) + .ok(); + })); + } + + fn page_arrived(&mut self, mut page: Page, cx: &mut Context) { + self.loading = None; + self.load_error = None; + if let Arrival::Place(ix) = arrival(&self.pages, page.cursor.as_deref(), page.fingerprint) { + for file in &mut page.files { + file.stamp = self.stamp(); + } + self.pages.truncate(ix); + self.pages.push(page); + self.sync_files(cx); + } + cx.notify(); + } + + /// Highlight the next file without syntax, nearest the view first, + /// one at a time off the UI thread. The rows show plain until then; + /// highlighting changes no height, so the list needs no splice. + fn highlight_next(&mut self, cx: &mut Context) { + if self.highlighting.is_some() { + return; + } + self.sync_rows(); + let top = self.list_state.logical_scroll_top().item_ix; + let Some(shown) = files_by_proximity(&self.rows, self.shown.len(), top) + .into_iter() + .map(|ix| self.shown[ix]) + .find(|&shown| !self.file(shown).highlighted) + else { + return; + }; + let file = self.file(shown); + let (path, hunks) = (file.path.clone(), file.diff.hunks.clone()); + let (page, print, scope) = (shown.page, self.pages[shown.page].fingerprint, self.scope); + // Captured here: the line styles are computed off the UI thread. + let theme = cx.theme().highlight_theme.clone(); + self.highlighting = Some(cx.spawn(async move |this, cx| { + let started = Instant::now(); + let (syntax, path) = cx + .background_spawn(async move { (highlight_patch(&path, &hunks, &theme), path) }) + .await; + tracing::debug!(%path, elapsed = ?started.elapsed(), "pull request file highlighted"); + this.update(cx, |this, cx| { + this.highlighting = None; + if this.scope == scope + && let Some(file) = this + .pages + .get_mut(page) + .filter(|p| p.fingerprint == print) + .and_then(|p| p.files.iter_mut().find(|f| f.path == path)) + { + file.diff.set_syntax(syntax); + file.highlighted = true; + cx.notify(); + } + this.highlight_next(cx); + }) + .ok(); + })); + } + + fn stamp(&mut self) -> u64 { + self.next_stamp += 1; + self.next_stamp + } + + /// Show the diff of `commit` (`None`: all commits), from the top. + pub(super) fn select_commit(&mut self, commit: Option, cx: &mut Context) { + if commit == self.commit { + return; + } + self.commit = commit; + self.scope += 1; + self.pages.clear(); + self.loading = None; + self.highlighting = None; + self.load_error = None; + self.selection.clear(); + self.draft = None; + self.rows.clear(); + self.list_state.reset(0); + self.sync_files(cx); + self.load_page(None, cx); + cx.notify(); + } + + fn file(&self, shown: Shown) -> &LoadedFile { + &self.pages[shown.page].files[shown.file] + } + + fn file_mut(&mut self, shown: Shown) -> &mut LoadedFile { + &mut self.pages[shown.page].files[shown.file] + } + + /// The shown file at `path`, by its index in the list. + fn shown_ix(&self, path: &str) -> Option { + self.shown.iter().position(|&s| self.file(s).path == path) + } + + /// Which files the list shows, in which order. + fn sync_files(&mut self, cx: &mut Context) { + let all: Vec = self + .pages + .iter() + .enumerate() + .flat_map(|(page, p)| (0..p.files.len()).map(move |file| Shown { page, file })) + .collect(); + let paths: Vec<&str> = all.iter().map(|&s| self.file(s).path.as_str()).collect(); + let ordered: Vec = order_like_tree(&paths) + .into_iter() + .map(|ix| all[ix]) + .collect(); + self.test_files = ordered.iter().filter(|&&s| self.file(s).is_test).count(); + self.shown = ordered + .into_iter() + .filter(|&s| !(self.hide_tests && self.file(s).is_test)) + .collect(); + let shown: Vec = self + .shown + .iter() + .map(|&s| self.file(s).path.clone()) + .collect(); + self.selection.retain(|path| shown.contains(path)); + if self + .draft + .as_ref() + .is_some_and(|d| !shown.contains(&d.path)) + { + self.draft = None; + } + self.place_annotations(cx); + self.highlight_next(cx); + } + + fn set_hide_tests(&mut self, hide: bool, cx: &mut Context) { + self.hide_tests = hide; + self.sync_files(cx); + cx.notify(); + } + + /// Put threads, pending comments and the comment box below their rows + /// (in a commit's scope none: its line numbers are not the change's), + /// and cut the chunks there. + fn place_annotations(&mut self, cx: &mut Context) { + // Held apart from `self`, which the loop changes. + let activity = self.activity.clone(); + let threads = activity.as_ref().map_or(&[][..], |a| &a.review_threads[..]); + let pending = self.drafts.read(cx).comments(&self.pr).to_vec(); + let whole_change = self.commit.is_none(); + let mut placed_threads = vec![false; threads.len()]; + let mut annotations = Vec::with_capacity(self.shown.len()); + for ix in 0..self.shown.len() { + let shown = self.shown[ix]; + let file = self.file(shown); + let mut items: Vec<(LineRef, Annotation)> = Vec::new(); + if whole_change { + items.extend(threads.iter().enumerate().filter_map(|(ix, t)| { + let line = t + .line + .filter(|_| t.path == file.path && !t.comments.is_empty())?; + Some((LineRef { side: t.side, line }, Annotation::Thread(ix))) + })); + items.extend(pending.iter().filter(|c| c.path == file.path).map(|c| { + ( + LineRef { + side: c.side, + line: c.line, + }, + Annotation::Pending(c.id), + ) + })); + if let Some(draft) = self.draft.as_ref().filter(|d| d.path == file.path) { + items.push((draft.at.end, Annotation::Draft)); + } + } + let (placed, _) = place(&file.lines, items); + for annotation in placed.values().flatten() { + if let Annotation::Thread(ix) = annotation { + placed_threads[*ix] = true; + } + } + let breaks: Vec = placed.keys().copied().collect(); + if file.breaks != breaks { + let stamp = self.stamp(); + let file = self.file_mut(shown); + file.diff.rechunk(&breaks); + file.breaks = breaks; + file.stamp = stamp; + } + annotations.push(placed); + } + self.annotations = annotations; + self.off_diff = (0..threads.len()) + .filter(|&ix| !placed_threads[ix] && !threads[ix].comments.is_empty()) + .collect(); + self.dirty = true; + cx.notify(); + } + + // -- Viewed ------------------------------------------------------------ + + fn read_viewed(&mut self, cx: &mut Context) { + let Some(service) = session_service(cx) else { + return; + }; + let pr = self.pr.clone(); + self.viewed_read = Some(cx.spawn(async move |this, cx| { + let result = service.pull_request_files_viewed(pr).await; + this.update(cx, |this, cx| { + this.viewed_read = None; + match result { + Ok(fetched) => this.viewed.answer(&fetched.value, fetched.fetched_at), + Err(error) => tracing::debug!("files viewed not read: {error:#}"), + } + cx.notify(); + }) + .ok(); + })); + } + + /// The checkbox of the shown file `ix` was clicked; with `extend` + /// (shift) every file from the last clicked one is ticked alike. + /// Ticking folds, unticking unfolds. + fn set_viewed(&mut self, ix: usize, viewed: bool, extend: bool, cx: &mut Context) { + let Some(&shown) = self.shown.get(ix) else { + return; + }; + let anchor = extend + .then(|| self.last_ticked.as_deref().and_then(|p| self.shown_ix(p))) + .flatten(); + let range = viewed_range(ix, anchor); + for &file in &self.shown[range] { + let path = &self.pages[file.page].files[file.file].path; + self.viewed.press(path, viewed); + self.folds.set(path, viewed); + } + self.last_ticked = Some(self.file(shown).path.clone()); + self.viewed_flush = Some(cx.spawn(async move |this, cx| { + cx.background_executor().timer(VIEWED_FLUSH_DELAY).await; + this.update(cx, |this, cx| { + this.viewed_flush = None; + this.flush_viewed(cx); + }) + .ok(); + })); + self.dirty = true; + cx.notify(); + } + + /// Write the queued presses, one batch at a time. + fn flush_viewed(&mut self, cx: &mut Context) { + let Some(batch) = self.viewed.take_batch() else { + return; + }; + let Some(service) = session_service(cx) else { + self.viewed_written(&batch, false, cx); + return; + }; + let pr = self.pr.clone(); + self.viewed_write = Some(cx.spawn(async move |this, cx| { + let ok = service + .pull_request_set_files_viewed(pr, batch.files.clone()) + .await + .inspect_err(|error| tracing::warn!("files viewed not written: {error:#}")) + .is_ok(); + this.update(cx, |this, cx| this.viewed_written(&batch, ok, cx)) + .ok(); + })); + } + + fn viewed_written(&mut self, batch: &Batch, ok: bool, cx: &mut Context) { + self.viewed_write = None; + let finished = self.viewed.finish(batch, ok); + if finished.report_failure { + self.data.update(cx, |data, cx| { + data.error = Some("The viewed files could not be saved.".into()); + cx.notify(); + }); + } + if finished.refresh { + self.read_viewed(cx); + } + // Presses made while this batch was under way. + if self.viewed.has_queued() && self.viewed_flush.is_none() { + self.flush_viewed(cx); + } + cx.notify(); + } + + fn write_remaining_viewed(&mut self, cx: &mut App) { + let files = self.viewed.take_remaining(); + if files.is_empty() { + return; + } + let Some(service) = session_service(cx) else { + return; + }; + let pr = self.pr.clone(); + cx.spawn(async move |_| { + if let Err(error) = service.pull_request_set_files_viewed(pr, files).await { + tracing::warn!("files viewed not written: {error:#}"); + } + }) + .detach(); + } + + // -- Folds ------------------------------------------------------------- + + fn toggle_fold(&mut self, ix: usize, cx: &mut Context) { + if let Some(&shown) = self.shown.get(ix) { + let path = self.file(shown).path.clone(); + self.folds.toggle(&path); + self.dirty = true; + cx.notify(); + } + } + + fn all_folded(&self) -> bool { + self.folds + .all_folded(self.shown.iter().map(|&s| self.file(s).path.as_str())) + } + + fn fold_all(&mut self, folded: bool, cx: &mut Context) { + self.folds.set_all(folded); + self.dirty = true; + cx.notify(); + } + + // -- List -------------------------------------------------------------- + + /// Bring the rows and the list in step with the state: one splice. + fn sync_rows(&mut self) { + if !self.dirty { + return; + } + self.dirty = false; + let versions = &self.thread_versions; + let thread_version = |ix: usize| versions.get(ix).copied().unwrap_or_default(); + let outlines: Vec = self + .shown + .iter() + .zip(&self.annotations) + .map(|(&shown, annotations)| { + let file = self.file(shown); + let diff = &file.diff; + let body = if diff.is_binary || diff.too_large || diff.hunks.is_empty() { + FileBody::Note + } else { + FileBody::Diff { + hunks: &diff.hunks, + chunks: &diff.chunked.chunks, + annotations, + thread_version: &thread_version, + tail: matches!( + file.status, + git::ChangeStatus::Modified | git::ChangeStatus::Renamed + ), + } + }; + FileOutline { + folded: self.folds.is_folded(&file.path), + stamp: file.stamp, + body, + } + }) + .collect(); + let footer = self.pages.last().is_some_and(|p| p.next_cursor.is_some()); + let rows = flatten(&outlines, footer); + if let Some((old, count)) = changed_span(&self.rows, &rows) { + self.list_state.splice(old, count); + } + if !rows.is_empty() + && let Some(mounted) = self.mounted_at.take() + { + tracing::debug!(elapsed = ?mounted.elapsed(), "pull request code tab: first rows"); + } + self.rows = rows; + } + + /// The thread card for `thread`, made on its first showing. + fn thread_card(&mut self, thread: usize, cx: &mut Context) -> Option> { + let activity = self.activity.clone()?; + let thread = activity.review_threads.get(thread)?; + if let Some((card, _)) = self.thread_cards.get(&thread.id) { + return Some(card.clone()); + } + let card = ThreadCard::for_thread(self.data.clone(), thread)?; + let card = cx.new(|_| card); + let intents = cx.subscribe(&card, |_, _, intent: &PullRequestIntent, cx| { + cx.emit(intent.clone()) + }); + self.thread_cards + .insert(thread.id.clone(), (card.clone(), intents)); + Some(card) + } +} + +/// What a thread's row shows, for telling a changed thread apart. +fn thread_version(thread: &ReviewThread) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + format!("{thread:?}").hash(&mut hasher); + hasher.finish() +} + +impl SelectsLines for CodeTab { + type Key = String; + + fn line_selection(&mut self) -> &mut LineSelection { + &mut self.selection + } +} + +impl Focusable for CodeTab { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for CodeTab { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !self.started { + self.start(cx); + } + self.sync_rows(); + let toolbar = self.render_toolbar(cx); + let content = match self.render_state(cx) { + Some(state) => state, + None => { + let list = list( + self.list_state.clone(), + cx.processor(|this: &mut Self, ix: usize, window, cx| { + this.render_row(ix, window, cx) + }), + ) + .size_full(); + div() + .relative() + .flex_1() + .min_h_0() + .child(list) + .children(self.render_sticky_header(cx)) + .into_any_element() + } + }; + div() + .size_full() + .flex() + .flex_col() + .bg(cx.theme().background) + .track_focus(&self.focus_handle) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { + let keys = &event.keystroke; + if keys.modifiers.secondary() && keys.key == "c" { + this.copy_selection(cx); + cx.stop_propagation(); + } else if keys.modifiers.secondary() && keys.modifiers.shift && keys.key == "m" { + this.start_comment(window, cx); + cx.stop_propagation(); + } + })) + .child(toolbar) + .children(self.render_off_diff(cx)) + .child(content) + .children(self.render_floating(window, cx)) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/pages.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/pages.rs new file mode 100644 index 000000000..04dd9fc30 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/pages.rs @@ -0,0 +1,247 @@ +//! The diff as GitHub sends it, page by page: each page's patch parsed +//! once, off the UI thread, into prepared file diffs in listing order +//! ([`parse_page`]), unhighlighted (the view highlights file by file +//! afterwards); arrivals merged like T3 Code does it ([`arrival`]): a +//! re-read page that did not change keeps everything, a changed one +//! replaces itself and drops the pages after it (their cursors point into +//! the old diff). + +use super::anchors::LineIndex; +use super::file_kind::is_test_file; +use super::file_order::order_files; +use crate::diff::prepare::PreparedFileDiff; +use git::ChangeStatus; +use github::model::DiffPage; +use similar::ChangeTag; +use std::hash::{Hash, Hasher}; + +/// One changed file of a page. +pub struct LoadedFile { + pub path: String, + pub orig_path: Option, + pub status: ChangeStatus, + pub diff: PreparedFileDiff, + /// Each row's change and line numbers (flat line indices). + pub numbers: Vec<(ChangeTag, usize, usize)>, + pub lines: LineIndex, + pub is_test: bool, + /// Lines added and removed; GitHub's count when it sent no hunks. + pub additions: u64, + pub deletions: u64, + /// Names this file's rows: new per load and per cut of the chunks. + pub stamp: u64, + /// The flat lines the chunks are cut after (annotations go there). + pub breaks: Vec, + /// The syntax was looked for (there may be no grammar). + pub highlighted: bool, +} + +pub struct Page { + /// The cursor this page was read with (`None` for the first). + pub cursor: Option, + pub fingerprint: u64, + pub next_cursor: Option, + /// GitHub withheld hunks in this page. + pub truncated: bool, + pub files: Vec, +} + +/// What became of a page read with `cursor`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Arrival { + /// The held page is the same. + Unchanged, + /// Goes at this index; the pages from there on are dropped. + Place(usize), + /// Read for a diff that is gone. + Stale, +} + +/// Where a page read with `cursor` goes among `pages`. +pub fn arrival(pages: &[Page], cursor: Option<&str>, fingerprint: u64) -> Arrival { + if let Some(ix) = pages.iter().position(|p| p.cursor.as_deref() == cursor) { + return if pages[ix].fingerprint == fingerprint { + Arrival::Unchanged + } else { + Arrival::Place(ix) + }; + } + let next = match pages.last() { + Some(last) => last.next_cursor.as_deref(), + None => None, + }; + if next == cursor && (cursor.is_some() || pages.is_empty()) { + Arrival::Place(pages.len()) + } else { + Arrival::Stale + } +} + +/// What tells two reads of a page apart. +pub fn fingerprint(page: &DiffPage) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + page.patch.hash(&mut hasher); + page.truncated.hash(&mut hasher); + page.next_cursor.hash(&mut hasher); + for stat in &page.omitted_file_stats { + (&stat.path, stat.additions, stat.deletions).hash(&mut hasher); + } + hasher.finish() +} + +/// The files of `page`, in the order they are listed, with their diffs +/// prepared but not highlighted. Call off the UI thread (a few ms for a +/// big page). Stamps are left for the view to give. +pub fn parse_page(page: &DiffPage) -> Vec { + let mut files: Vec> = git::parse_patch(&page.patch) + .into_iter() + .map(|patch| { + let diff = PreparedFileDiff::from_patch_plain(&patch); + let numbers = diff.row_numbers(); + let (mut additions, mut deletions) = (diff.additions as u64, diff.deletions as u64); + if additions == 0 + && deletions == 0 + && let Some(stat) = page + .omitted_file_stats + .iter() + .find(|stat| stat.path == patch.path) + { + (additions, deletions) = (stat.additions, stat.deletions); + } + Some(LoadedFile { + is_test: is_test_file(&patch.path), + lines: LineIndex::new(&numbers), + numbers, + diff, + additions, + deletions, + path: patch.path, + orig_path: patch.orig_path, + status: patch.status, + stamp: 0, + breaks: Vec::new(), + highlighted: false, + }) + }) + .collect(); + let paths: Vec = files + .iter() + .flatten() + .map(|file| file.path.clone()) + .collect(); + let paths: Vec<&str> = paths.iter().map(String::as_str).collect(); + let order = order_files(&paths, |ix| { + files[ix] + .as_ref() + .map(|file| { + file.diff + .hunks + .iter() + .flat_map(|hunk| &hunk.lines) + .filter(|line| line.tag != ChangeTag::Equal) + .map(|line| line.text.as_ref()) + .collect::>() + }) + .unwrap_or_default() + }); + order + .into_iter() + .filter_map(|ix| files[ix].take()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn page(cursor: Option<&str>, fingerprint: u64, next: Option<&str>) -> Page { + Page { + cursor: cursor.map(str::to_owned), + fingerprint, + next_cursor: next.map(str::to_owned), + truncated: false, + files: Vec::new(), + } + } + + #[test] + fn pages_append_in_cursor_order() { + assert_eq!(arrival(&[], None, 1), Arrival::Place(0)); + assert_eq!(arrival(&[], Some("2"), 1), Arrival::Stale); + let pages = [page(None, 1, Some("2"))]; + assert_eq!(arrival(&pages, Some("2"), 5), Arrival::Place(1)); + assert_eq!(arrival(&pages, Some("3"), 5), Arrival::Stale); + let last = [page(None, 1, None)]; + assert_eq!(arrival(&last, Some("2"), 5), Arrival::Stale); + } + + #[test] + fn a_reread_page_is_kept_or_replaces_the_rest() { + let pages = [page(None, 1, Some("2")), page(Some("2"), 2, None)]; + assert_eq!(arrival(&pages, None, 1), Arrival::Unchanged); + assert_eq!(arrival(&pages, None, 9), Arrival::Place(0)); + assert_eq!(arrival(&pages, Some("2"), 9), Arrival::Place(1)); + } + + const PATCH: &str = "\ +diff --git a/src/b.ts b/src/b.ts +--- a/src/b.ts ++++ b/src/b.ts +@@ -1,1 +1,1 @@ +-old ++new +diff --git a/src/a.ts b/src/a.ts +new file mode 100644 +--- /dev/null ++++ b/src/a.ts +@@ -0,0 +1,1 @@ ++import { x } from \"./z\"; +diff --git a/img.png b/img.png +Binary files a/img.png and b/img.png differ +diff --git a/src/z.ts b/src/z.ts +--- a/src/z.ts ++++ b/src/z.ts +@@ -3,1 +3,2 @@ + ctx ++more +"; + + #[test] + fn a_page_parses_into_ordered_files_with_their_counts() { + let page = DiffPage { + patch: PATCH.into(), + truncated: true, + next_cursor: None, + omitted_file_stats: vec![github::model::OmittedFileStat { + path: "img.png".into(), + additions: 3, + deletions: 1, + }], + }; + let files = parse_page(&page); + let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect(); + // `a.ts` imports `z.ts`; everything else in path order. + assert_eq!(paths, ["img.png", "src/b.ts", "src/z.ts", "src/a.ts"]); + let b = &files[1]; + assert_eq!((b.additions, b.deletions), (1, 1)); + assert_eq!(b.status, ChangeStatus::Modified); + assert_eq!(files[3].status, ChangeStatus::Added); + let png = &files[0]; + assert!(png.diff.is_binary); + assert_eq!((png.additions, png.deletions), (3, 1)); + // The line index points at rows: `more` is new line 4, row 1. + let z = &files[2]; + assert_eq!( + z.lines.row(super::super::anchors::LineRef { + side: github::model::DiffSide::Right, + line: 4 + }), + Some(1) + ); + assert_ne!(fingerprint(&page), { + let mut other = page.clone(); + other.truncated = false; + fingerprint(&other) + }); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/rows.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/rows.rs new file mode 100644 index 000000000..2f4c177cf --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/rows.rs @@ -0,0 +1,355 @@ +//! The Code tab as a flat list of rows, the items of its virtualized list: +//! per file a header, then (unless folded) the hunks as chunks, each hunk +//! after a gap row ("30 unmodified lines"), threads and comments right +//! below the row they are on, and the loading footer at the end. Rows that +//! stay the same compare equal, so a change reaches the list as one splice +//! ([`changed_span`](crate::main_screen::right_panel::review_rows::changed_span)) +//! and the scroll position survives. + +use super::anchors::Annotation; +use crate::diff::chunks::DiffChunk; +use crate::diff::model::DiffHunk; +use similar::ChangeTag; +use std::collections::BTreeMap; + +/// One item of the list. `file` indexes the shown files; `stamp` names +/// the cut of a file's diff a row was built from (a new diff or new cut +/// yields new rows); `version` changes with what a row shows. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CodeRow { + File { + file: usize, + }, + /// Binary, too large or no content change. + Note { + file: usize, + stamp: u64, + }, + /// The unchanged lines before hunk `hunk`, and its section. + Gap { + file: usize, + hunk: usize, + stamp: u64, + }, + Chunk { + file: usize, + chunk: usize, + stamp: u64, + }, + /// Below the last hunk of a file that has lines past it. + Tail { + file: usize, + stamp: u64, + }, + Thread { + file: usize, + thread: usize, + version: u64, + }, + Pending { + file: usize, + id: u64, + }, + Draft { + file: usize, + }, + /// More pages follow: "Loading more files…" or a retry. + Footer, +} + +/// What a file contributes below its header. +pub enum FileBody<'a> { + /// Binary, too large or no content change: a note says which. + Note, + Diff { + hunks: &'a [DiffHunk], + chunks: &'a [DiffChunk], + /// Annotations by the flat line they go below; chunks end there. + annotations: &'a BTreeMap>, + /// Versions of the threads, by index. + thread_version: &'a dyn Fn(usize) -> u64, + tail: bool, + }, +} + +pub struct FileOutline<'a> { + pub folded: bool, + pub stamp: u64, + pub body: FileBody<'a>, +} + +pub fn flatten(files: &[FileOutline], footer: bool) -> Vec { + let mut rows = Vec::new(); + for (file, outline) in files.iter().enumerate() { + rows.push(CodeRow::File { file }); + if outline.folded { + continue; + } + let stamp = outline.stamp; + match &outline.body { + FileBody::Note => rows.push(CodeRow::Note { file, stamp }), + FileBody::Diff { + hunks, + chunks, + annotations, + thread_version, + tail, + } => { + let mut prev = None; + for (ix, chunk) in chunks.iter().enumerate() { + if chunk.lines.start == 0 + && let Some(hunk) = hunks.get(chunk.hunk) + && gap(prev, hunk).is_some() + { + rows.push(CodeRow::Gap { + file, + hunk: chunk.hunk, + stamp, + }); + } + prev = hunks.get(chunk.hunk); + rows.push(CodeRow::Chunk { + file, + chunk: ix, + stamp, + }); + let last = chunk.flat_start + chunk.lines.len().saturating_sub(1); + for annotation in annotations.get(&last).into_iter().flatten() { + rows.push(match *annotation { + Annotation::Thread(thread) => CodeRow::Thread { + file, + thread, + version: thread_version(thread), + }, + Annotation::Pending(id) => CodeRow::Pending { file, id }, + Annotation::Draft => CodeRow::Draft { file }, + }); + } + } + if *tail && !chunks.is_empty() { + rows.push(CodeRow::Tail { file, stamp }); + } + } + } + } + if footer { + rows.push(CodeRow::Footer); + } + rows +} + +impl CodeRow { + /// The shown file a row belongs to (`None` for the footer). + pub fn file(&self) -> Option { + match *self { + CodeRow::File { file } + | CodeRow::Note { file, .. } + | CodeRow::Gap { file, .. } + | CodeRow::Chunk { file, .. } + | CodeRow::Tail { file, .. } + | CodeRow::Thread { file, .. } + | CodeRow::Pending { file, .. } + | CodeRow::Draft { file } => Some(file), + CodeRow::Footer => None, + } + } +} + +/// The shown files (`0..files`) in the order to highlight them when +/// `top_row` is the first row in view: from the file at the top of the +/// viewport downwards, then the ones above it, nearest first. +pub fn files_by_proximity(rows: &[CodeRow], files: usize, top_row: usize) -> Vec { + let top = rows + .get(top_row) + .and_then(CodeRow::file) + .unwrap_or_else(|| files.saturating_sub(1)) + .min(files.saturating_sub(1)); + (top..files).chain((0..top).rev()).collect() +} + +/// What separates `hunk` from the one before (`None` for the first): the +/// unchanged lines between them and its section, when there is either. +pub fn gap<'a>(prev: Option<&DiffHunk>, hunk: &'a DiffHunk) -> Option<(usize, Option<&'a str>)> { + let prev_end = prev.map_or(1, |prev| { + prev.old_start + + prev + .lines + .iter() + .filter(|l| l.tag != ChangeTag::Insert) + .count() + }); + let lines = hunk.old_start.saturating_sub(prev_end); + let section = hunk.header.as_deref(); + (lines > 0 || section.is_some()).then_some((lines, section)) +} + +/// The text of a gap row. +pub fn gap_label(lines: usize, section: Option<&str>) -> String { + let count = match lines { + 0 => None, + 1 => Some("1 unmodified line".to_owned()), + n => Some(format!("{n} unmodified lines")), + }; + // A section is the raw `@@` context: one line, its spacing collapsed. + let section = section + .map(|s| s.split_whitespace().collect::>().join(" ")) + .filter(|s| !s.is_empty()); + match (count, section) { + (Some(count), Some(section)) => format!("{count} · {section}"), + (Some(count), None) => count, + (None, Some(section)) => format!("⋯ {section}"), + (None, None) => "⋯".to_owned(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::diff::chunks::chunk_hunks; + use crate::diff::model::DiffLine; + use ChangeTag::*; + + fn hunk(old_start: usize, header: Option<&str>, tags: &[ChangeTag]) -> DiffHunk { + DiffHunk { + old_start, + new_start: old_start, + header: header.map(|h| h.to_owned().into()), + lines: tags.iter().map(|&tag| DiffLine::plain(tag, "x")).collect(), + } + } + + #[test] + fn gaps_count_the_old_files_unchanged_lines() { + let first = hunk(31, None, &[Equal, Insert, Delete, Equal]); + assert_eq!(gap(None, &first), Some((30, None))); + // `first` covers old lines 31..=33. + let second = hunk(40, Some("fn main() {"), &[Equal]); + assert_eq!(gap(Some(&first), &second), Some((6, Some("fn main() {")))); + let adjacent = hunk(34, None, &[Equal]); + assert_eq!(gap(Some(&first), &adjacent), None); + // A new file's hunk starts at old line 0. + assert_eq!(gap(None, &hunk(0, None, &[Insert])), None); + assert_eq!(gap(None, &hunk(1, None, &[Delete])), None); + } + + #[test] + fn files_highlight_from_the_top_of_the_view_outwards() { + use CodeRow::*; + let rows = vec![ + File { file: 0 }, + File { file: 1 }, + Chunk { + file: 1, + chunk: 0, + stamp: 1, + }, + File { file: 2 }, + File { file: 3 }, + Footer, + ]; + assert_eq!(files_by_proximity(&rows, 4, 0), [0, 1, 2, 3]); + // Inside file 1's rows: it first, then below, then above. + assert_eq!(files_by_proximity(&rows, 4, 2), [1, 2, 3, 0]); + // The footer or a stale index past the end: from the last file up. + assert_eq!(files_by_proximity(&rows, 4, 5), [3, 2, 1, 0]); + assert_eq!(files_by_proximity(&rows, 4, 99), [3, 2, 1, 0]); + assert!(files_by_proximity(&[], 0, 0).is_empty()); + } + + #[test] + fn gap_labels() { + assert_eq!(gap_label(30, None), "30 unmodified lines"); + assert_eq!(gap_label(1, None), "1 unmodified line"); + assert_eq!( + gap_label(4, Some("impl A {")), + "4 unmodified lines · impl A {" + ); + assert_eq!(gap_label(0, Some("impl A {")), "⋯ impl A {"); + assert_eq!( + gap_label(2, Some(" Layer 5:\t code — CLI ")), + "2 unmodified lines · Layer 5: code — CLI" + ); + assert_eq!(gap_label(2, Some(" ")), "2 unmodified lines"); + } + + #[test] + fn files_list_gaps_chunks_annotations_and_tail_in_order() { + let hunks = vec![ + hunk(5, None, &[Equal, Insert, Equal]), + hunk(8, None, &[Equal, Delete]), + ]; + let mut annotations = BTreeMap::new(); + annotations.insert(1, vec![Annotation::Thread(4), Annotation::Draft]); + annotations.insert(4, vec![Annotation::Pending(9)]); + let chunked = chunk_hunks(&hunks, 40, &[1]); + let version = |thread: usize| thread as u64 * 10; + let files = [ + FileOutline { + folded: false, + stamp: 7, + body: FileBody::Diff { + hunks: &hunks, + chunks: &chunked.chunks, + annotations: &annotations, + thread_version: &version, + tail: true, + }, + }, + FileOutline { + folded: true, + stamp: 8, + body: FileBody::Note, + }, + FileOutline { + folded: false, + stamp: 9, + body: FileBody::Note, + }, + ]; + use CodeRow::*; + assert_eq!( + flatten(&files, true), + vec![ + File { file: 0 }, + Gap { + file: 0, + hunk: 0, + stamp: 7 + }, + Chunk { + file: 0, + chunk: 0, + stamp: 7 + }, + Thread { + file: 0, + thread: 4, + version: 40 + }, + Draft { file: 0 }, + Chunk { + file: 0, + chunk: 1, + stamp: 7 + }, + // Hunk 0 ends at old line 6, hunk 1 starts at 8. + Gap { + file: 0, + hunk: 1, + stamp: 7 + }, + Chunk { + file: 0, + chunk: 2, + stamp: 7 + }, + Pending { file: 0, id: 9 }, + Tail { file: 0, stamp: 7 }, + File { file: 1 }, + File { file: 2 }, + Note { file: 2, stamp: 9 }, + Footer, + ] + ); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/tests.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/tests.rs new file mode 100644 index 000000000..643051ced --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/tests.rs @@ -0,0 +1,373 @@ +//! The Code tab in a window, fed pages and activity as the page would +//! read them (no session service behind it, so every write fails). + +use super::super::conversation::fixtures::{commit, data, thread, window}; +use super::super::fixtures::hours_ago; +use super::*; +use github::model::{DiffPage, DiffSide}; +use gpui_kit::{Modifiers, TestAppContext, VisualTestContext}; + +fn tab( + cx: &mut TestAppContext, +) -> ( + Entity, + Entity, + &mut VisualTestContext, +) { + let data = data(cx, None); + let (tab, cx) = window(cx, { + let data = data.clone(); + move |cx| CodeTab::new(data, cx) + }); + (tab, data, cx) +} + +/// Each path modified in one hunk at line 10: ctx, -old, +new, ctx. +fn patch(paths: &[&str]) -> String { + paths + .iter() + .map(|p| { + format!( + "diff --git a/{p} b/{p}\n--- a/{p}\n+++ b/{p}\n\ + @@ -10,3 +10,3 @@ fn f() {{\n ctx\n-old\n+new\n ctx\n" + ) + }) + .collect() +} + +/// A page of `paths` arrives as read with `cursor`. +fn arrive( + tab: &Entity, + cx: &mut VisualTestContext, + cursor: Option<&str>, + paths: &[&str], + next: Option<&str>, +) { + let page = DiffPage { + patch: patch(paths), + truncated: false, + next_cursor: next.map(str::to_owned), + omitted_file_stats: Vec::new(), + }; + tab.update(cx, |tab, cx| { + let files = parse_page(&page); + tab.page_arrived( + Page { + cursor: cursor.map(str::to_owned), + fingerprint: fingerprint(&page), + next_cursor: page.next_cursor.clone(), + truncated: false, + files, + }, + cx, + ) + }); + cx.run_until_parked(); +} + +fn set_activity( + data: &Entity, + cx: &mut VisualTestContext, + threads: Vec, + commits: Vec, +) { + data.update(cx, |data, cx| { + data.activity = Some(Arc::new(Activity { + author: None, + reviewers: Vec::new(), + comments: Vec::new(), + comment_count: 0, + comments_truncated: false, + review_threads: threads, + review_threads_truncated: false, + commits, + reactions: Vec::new(), + })); + cx.notify(); + }); + cx.run_until_parked(); +} + +fn rows(tab: &Entity, cx: &mut VisualTestContext) -> Vec { + tab.read_with(cx, |tab, _| { + assert_eq!(tab.list_state.item_count(), tab.rows.len()); + tab.rows.clone() + }) +} + +fn click(cx: &mut VisualTestContext, selector: &str, modifiers: Modifiers) { + let bounds = cx + .debug_bounds(Box::leak(selector.to_owned().into_boxed_str())) + .unwrap_or_else(|| panic!("nothing rendered as {selector}")); + cx.simulate_click(bounds.center(), modifiers); + cx.run_until_parked(); +} + +/// File header, gap ("9 unmodified lines · fn f() {"), chunk, tail. +fn file_rows(file: usize, stamp: u64) -> Vec { + vec![ + CodeRow::File { file }, + CodeRow::Gap { + file, + hunk: 0, + stamp, + }, + CodeRow::Chunk { + file, + chunk: 0, + stamp, + }, + CodeRow::Tail { file, stamp }, + ] +} + +#[gpui_kit::test] +fn pages_extend_the_list_by_a_splice(cx: &mut TestAppContext) { + let (tab, _, cx) = tab(cx); + arrive(&tab, cx, None, &["src/b.rs", "src/a.rs"], Some("2")); + let first = rows(&tab, cx); + assert_eq!( + first, + [file_rows(0, 1), file_rows(1, 2), vec![CodeRow::Footer]].concat() + ); + assert!(cx.debug_bounds("pr-code-footer").is_some()); + + // A page for a cursor no page points to is dropped. + arrive(&tab, cx, Some("9"), &["src/x.rs"], None); + assert_eq!(rows(&tab, cx), first); + + arrive(&tab, cx, Some("2"), &["src/c.rs"], None); + let second = rows(&tab, cx); + assert_eq!(second[..8], first[..8], "the first page's rows stay"); + assert_eq!(second[8..], file_rows(2, 3)[..]); + tab.read_with(cx, |tab, _| { + let paths: Vec<&str> = tab + .shown + .iter() + .map(|&s| tab.file(s).path.as_str()) + .collect(); + assert_eq!(paths, ["src/a.rs", "src/b.rs", "src/c.rs"]); + }); + assert!(cx.debug_bounds("pr-code-footer").is_none()); + + // The first page read again, changed: the pages after it go. + arrive(&tab, cx, None, &["src/a.rs"], Some("2")); + assert_eq!( + rows(&tab, cx), + [file_rows(0, 4), vec![CodeRow::Footer]].concat() + ); +} + +#[gpui_kit::test] +fn ticking_folds_a_shift_click_ticks_a_range_and_a_failed_write_reverts(cx: &mut TestAppContext) { + let (tab, data, cx) = tab(cx); + arrive(&tab, cx, None, &["a.rs", "b.rs", "c.rs"], None); + + click(cx, "pr-code-viewed-0", Modifiers::none()); + assert_eq!( + rows(&tab, cx)[..2], + [CodeRow::File { file: 0 }, CodeRow::File { file: 1 }] + ); + click(cx, "pr-code-viewed-2", Modifiers::shift()); + assert_eq!( + rows(&tab, cx), + (0..3) + .map(|file| CodeRow::File { file }) + .collect::>() + ); + tab.read_with(cx, |tab, _| { + assert!( + ["a.rs", "b.rs", "c.rs"] + .iter() + .all(|p| tab.viewed.is_viewed(p)) + ); + assert!(tab.viewed.has_queued(), "batched, not sent yet"); + }); + + // 400 ms after the last press the batch goes; without a service it + // fails, the ticks come off and the page says so. + cx.executor().advance_clock(Duration::from_millis(300)); + cx.run_until_parked(); + tab.read_with(cx, |tab, _| assert!(tab.viewed.has_queued())); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.run_until_parked(); + tab.read_with(cx, |tab, _| { + assert!(!tab.viewed.has_queued()); + assert!(!tab.viewed.is_viewed("a.rs")); + }); + data.read_with(cx, |data, _| assert!(data.error.is_some())); +} + +#[gpui_kit::test] +fn threads_sit_below_their_line_and_the_rest_off_the_diff(cx: &mut TestAppContext) { + let (tab, data, cx) = tab(cx); + arrive(&tab, cx, None, &["src/lib.rs"], None); + let on_line = thread("t1", &[("c1", hours_ago(2))]); + let mut outdated = thread("t2", &[("c2", hours_ago(2))]); + outdated.line = None; + let mut elsewhere = thread("t3", &[("c3", hours_ago(2))]); + elsewhere.path = "src/other.rs".into(); + // Right 12 is the context row below `+new`; Left 11 the deleted row. + let mut on_old = thread("t4", &[("c4", hours_ago(2))]); + (on_old.side, on_old.line) = (DiffSide::Left, Some(11)); + set_activity( + &data, + cx, + vec![on_line, outdated, elsewhere, on_old], + Vec::new(), + ); + + let rows = rows(&tab, cx); + let kinds: Vec<_> = rows + .iter() + .map(|row| match row { + CodeRow::Chunk { chunk, .. } => format!("chunk {chunk}"), + CodeRow::Thread { thread, .. } => format!("thread {thread}"), + other => format!("{other:?}") + .split_whitespace() + .next() + .unwrap() + .to_owned(), + }) + .collect(); + assert_eq!( + kinds, + [ + "File", "Gap", "chunk 0", "thread 3", "chunk 1", "thread 0", "Tail" + ] + ); + tab.read_with(cx, |tab, _| assert_eq!(tab.off_diff, [1, 2])); + click(cx, "pr-code-off-diff", Modifiers::none()); + tab.read_with(cx, |tab, _| assert!(tab.off_diff_open)); +} + +#[gpui_kit::test] +fn a_selection_becomes_a_pending_review_comment(cx: &mut TestAppContext) { + let (tab, _, cx) = tab(cx); + arrive(&tab, cx, None, &["src/a.rs"], None); + // Rows 1 and 2: `-old` (old line 11) and `+new` (new line 11). + tab.update(cx, |tab, cx| { + tab.selection.begin("src/a.rs".into(), 1, false); + tab.selection.extend(&"src/a.rs".into(), 2); + tab.selection.end(); + cx.notify(); + }); + // The first frame paints the rows and records the selection's end; + // the next one floats the pill there. + cx.run_until_parked(); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + click(cx, "pr-code-selection-comment", Modifiers::none()); + assert!(rows(&tab, cx).contains(&CodeRow::Draft { file: 0 })); + + cx.simulate_input("Rename this"); + // ⌘↵ adds it to the review. + cx.simulate_keystrokes("secondary-enter"); + cx.run_until_parked(); + + let pr = tab.read_with(cx, |tab, _| tab.pr.clone()); + let drafts = cx.update(|_, cx| review_drafts(cx)); + let pending = drafts.read_with(cx, |drafts, _| drafts.comments(&pr).to_vec()); + assert_eq!(pending.len(), 1); + let comment = &pending[0]; + assert_eq!(comment.path, "src/a.rs"); + assert_eq!((comment.side, comment.line), (DiffSide::Right, 11)); + assert_eq!( + (comment.start_side, comment.start_line), + (Some(DiffSide::Left), Some(11)) + ); + assert_eq!(comment.body, "Rename this"); + assert_eq!(comment.excerpt, "-old\n+new"); + + let rows = rows(&tab, cx); + assert!(rows.contains(&CodeRow::Pending { + file: 0, + id: comment.id + })); + assert!(!rows.contains(&CodeRow::Draft { file: 0 })); + assert!( + cx.debug_bounds(Box::leak( + format!("pr-code-pending-{}", comment.id).into_boxed_str() + )) + .is_some() + ); + tab.read_with(cx, |tab, _| assert!(tab.selection.get().is_none())); +} + +#[gpui_kit::test] +fn a_commit_scope_starts_over_without_comments(cx: &mut TestAppContext) { + let (tab, data, cx) = tab(cx); + arrive(&tab, cx, None, &["src/lib.rs"], None); + set_activity( + &data, + cx, + vec![thread("t1", &[("c1", hours_ago(2))])], + vec![commit("abc1234def", hours_ago(3))], + ); + tab.read_with(cx, |tab, _| assert!(tab.off_diff.is_empty())); + + tab.update(cx, |tab, cx| { + tab.select_commit(Some("abc1234def".into()), cx) + }); + cx.run_until_parked(); + assert!(rows(&tab, cx).is_empty()); + arrive(&tab, cx, None, &["src/lib.rs"], None); + tab.update_in(cx, |tab, window, cx| { + assert_eq!(tab.off_diff, [0], "a commit's lines are not the change's"); + tab.selection.begin("src/lib.rs".into(), 1, false); + tab.selection.end(); + tab.start_comment(window, cx); + assert!(tab.draft.is_none()); + }); +} + +#[gpui_kit::test] +fn hidden_tests_leave_the_list(cx: &mut TestAppContext) { + let (tab, _, cx) = tab(cx); + arrive(&tab, cx, None, &["src/a.rs", "tests/a.rs"], None); + tab.update(cx, |tab, cx| tab.set_hide_tests(true, cx)); + cx.run_until_parked(); + tab.read_with(cx, |tab, _| { + assert_eq!(tab.test_files, 1); + assert_eq!(tab.shown.len(), 1); + }); + assert_eq!(rows(&tab, cx), file_rows(0, 1)); +} + +#[gpui_kit::test] +fn rows_show_plain_first_and_highlight_file_by_file(cx: &mut TestAppContext) { + let (tab, _, cx) = tab(cx); + let page = DiffPage { + patch: patch(&["src/a.rs", "src/b.rs", "notes.unknown"]), + truncated: false, + next_cursor: None, + omitted_file_stats: Vec::new(), + }; + tab.update(cx, |tab, cx| { + tab.page_arrived( + Page { + cursor: None, + fingerprint: fingerprint(&page), + next_cursor: None, + truncated: false, + files: parse_page(&page), + }, + cx, + ); + tab.sync_rows(); + assert!(tab.shown.iter().all(|&s| tab.file(s).diff.syntax.is_none())); + }); + let plain = rows(&tab, cx); + cx.run_until_parked(); + tab.read_with(cx, |tab, _| { + let files: Vec<_> = tab.shown.iter().map(|&s| tab.file(s)).collect(); + assert!(files.iter().all(|f| f.highlighted)); + let with_syntax: Vec<&str> = files + .iter() + .filter(|f| f.diff.syntax.is_some()) + .map(|f| f.path.as_str()) + .collect(); + assert_eq!(with_syntax, ["src/a.rs", "src/b.rs"]); + }); + assert_eq!(rows(&tab, cx), plain, "highlighting adds no rows"); +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/toolbar.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/toolbar.rs new file mode 100644 index 000000000..fb40e954f --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/toolbar.rs @@ -0,0 +1,310 @@ +//! The Code tab's toolbar: the commit scope ("All commits" or one commit, +//! newest first, ten at a time), "6 files · 0 / 6 viewed", hidden tests, +//! and on the right collapse / expand all and the "hide tests" toggle. +//! +//! Not here yet: hiding whitespace changes (GitHub's diff endpoint takes +//! no such option through the core), unwrapped lines (diff rows always +//! wrap), the split layout and the file tree. + +use super::CodeTab; +use gpui_kit::component::button::{Button, ButtonVariants}; +use gpui_kit::component::popover::{Popover, PopoverState}; +use gpui_kit::component::{ActiveTheme, Icon, Selectable, Sizable}; +use gpui_kit::{AnyElement, Context, SharedString, WeakEntity, Window, div, prelude::*, px, svg}; + +/// Commits the scope menu lists at a time. +pub const COMMIT_PAGE: usize = 10; + +impl CodeTab { + pub(super) fn render_toolbar(&self, cx: &mut Context) -> AnyElement { + let scope = self.render_scope(cx); + let theme = cx.theme(); + let (muted, warning_color, border) = (theme.muted_foreground, theme.warning, theme.border); + let files = self.shown.len(); + let more = self.pages.last().is_some_and(|p| p.next_cursor.is_some()); + let viewed = self + .shown + .iter() + .filter(|&&s| self.viewed.is_viewed(&self.file(s).path)) + .count(); + let hidden_tests = if self.hide_tests { self.test_files } else { 0 }; + let warning = |id: &'static str, tooltip: &'static str| { + div() + .id(id) + .flex_none() + .child( + svg() + .size(px(13.)) + .path("icons/triangle_alert.svg") + .text_color(warning_color), + ) + .tooltip(move |window, cx| { + gpui_kit::component::tooltip::Tooltip::new(tooltip).build(window, cx) + }) + }; + let truncated = self.pages.iter().any(|p| p.truncated); + + let left = div() + .flex_1() + .min_w_0() + .flex() + .items_center() + .gap_2() + .children(scope) + .child( + div() + .flex_none() + .debug_selector(|| "pr-code-counts".into()) + .child(format!( + "{files} {}{} · {viewed} / {files} viewed", + if files == 1 { "file" } else { "files" }, + if more { "+" } else { "" }, + )), + ) + .when(self.viewed.truncated(), |d| { + d.child(warning( + "pr-code-viewed-truncated", + "GitHub reports ticks for only part of the files: the count is short.", + )) + }) + .when(truncated, |d| { + d.child(warning( + "pr-code-withheld", + "GitHub withheld part of this diff: a binary file, or a change too large to show.", + )) + }) + .when(hidden_tests > 0, |d| { + d.child( + Button::new("pr-code-hidden-tests") + .link() + .xsmall() + .label(format!( + "{hidden_tests} test {} hidden", + if hidden_tests == 1 { "file" } else { "files" } + )) + .on_click(cx.listener(|this, _, _, cx| this.set_hide_tests(false, cx))), + ) + }) + .when(self.commit.is_some(), |d| { + d.child( + div() + .id("pr-code-commit-scope-note") + .flex_none() + .child( + svg() + .size(px(13.)) + .path("icons/message_square.svg") + .text_color(muted.opacity(0.6)), + ) + .tooltip(|window, cx| { + gpui_kit::component::tooltip::Tooltip::new( + "Comments are anchored to the whole change: switch to All commits to write one.", + ) + .build(window, cx) + }), + ) + }); + + let all_folded = self.all_folded(); + let right = div() + .flex_none() + .flex() + .items_center() + .gap_1() + .when(files > 0, |d| { + d.child( + Button::new("pr-code-fold-all") + .ghost() + .xsmall() + .icon(Icon::default().path(if all_folded { + "icons/chevron_up_down.svg" + } else { + "icons/chevrons_down_up.svg" + })) + .tooltip(if all_folded { + "Expand all files" + } else { + "Collapse all files" + }) + .on_click( + cx.listener(move |this, _, _, cx| this.fold_all(!all_folded, cx)), + ), + ) + }) + .when(self.test_files > 0, |d| { + let hide = self.hide_tests; + d.child( + Button::new("pr-code-hide-tests") + .ghost() + .xsmall() + .selected(hide) + .toggled(hide) + .icon(Icon::default().path("icons/flask_conical_off.svg")) + .tooltip(if hide { + "Show test files" + } else { + "Hide test files" + }) + .on_click( + cx.listener(move |this, _, _, cx| this.set_hide_tests(!hide, cx)), + ), + ) + }); + + div() + .flex_none() + .h(px(40.)) + .px_4() + .flex() + .items_center() + .justify_between() + .gap_2() + .border_b_1() + .border_color(border) + .text_xs() + .text_color(muted) + .child(left) + .child(right) + .into_any_element() + } + + /// "All commits ▾", or the chosen commit's headline; `None` before the + /// commits are known. + fn render_scope(&self, cx: &mut Context) -> Option { + let activity = self.activity.as_ref()?; + if activity.commits.is_empty() { + return None; + } + let label: SharedString = self + .commit + .as_ref() + .and_then(|oid| activity.commits.iter().find(|c| &c.oid == oid)) + .map_or("All commits".into(), |c| c.message_headline.clone().into()); + let view = cx.entity().downgrade(); + Some( + Popover::new("pr-code-scope") + .anchor(gpui_kit::Anchor::TopLeft) + .trigger( + Button::new("pr-code-scope-trigger") + .ghost() + .xsmall() + .max_w(px(256.)) + .label(label) + .dropdown_caret(true), + ) + .content(move |state, window, cx| scope_menu(&view, state, window, cx)) + .into_any_element(), + ) + } + + pub(super) fn show_more_commits(&mut self, cx: &mut Context) { + self.commits_shown += COMMIT_PAGE; + cx.notify(); + } +} + +/// The scope menu: "All commits", then the commits newest first with their +/// short sha, [`COMMIT_PAGE`] at a time. +fn scope_menu( + view: &WeakEntity, + _state: &mut PopoverState, + _window: &mut Window, + cx: &mut Context, +) -> AnyElement { + let Some(tab) = view.upgrade() else { + return div().into_any_element(); + }; + let (mut commits, selected, shown) = { + let tab = tab.read(cx); + let commits = tab + .activity + .as_ref() + .map(|a| a.commits.clone()) + .unwrap_or_default(); + (commits, tab.commit.clone(), tab.commits_shown) + }; + commits.sort_by_key(|c| std::cmp::Reverse(c.committed_date)); + let theme = cx.theme(); + let (accent, muted, mono) = ( + theme.accent, + theme.muted_foreground, + theme.mono_font_family.clone(), + ); + let popover = cx.entity().downgrade(); + let item = |id: SharedString, checked: bool| { + div() + .id(id) + .px_2() + .py_1() + .rounded_md() + .flex() + .items_center() + .gap_2() + .text_sm() + .cursor_pointer() + .when(checked, |d| d.bg(accent)) + .hover(|d| d.bg(accent)) + }; + let pick = |commit: Option| { + let (view, popover) = (view.clone(), popover.clone()); + move |_: &gpui_kit::ClickEvent, window: &mut Window, cx: &mut gpui_kit::App| { + popover + .update(cx, |state, cx| state.dismiss(window, cx)) + .ok(); + let commit = commit.clone(); + view.update(cx, |tab, cx| tab.select_commit(commit, cx)) + .ok(); + } + }; + let mut menu = div() + .w(px(360.)) + .max_w(px(448.)) + .flex() + .flex_col() + .gap_0p5() + .child( + item("pr-code-scope-all".into(), selected.is_none()) + .child("All commits") + .on_click(pick(None)), + ); + for commit in commits.iter().take(shown) { + let short: String = commit.oid.chars().take(7).collect(); + menu = menu.child( + item( + format!("pr-code-scope-{short}").into(), + selected.as_ref() == Some(&commit.oid), + ) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .child(commit.message_headline.clone()), + ) + .child( + div() + .flex_none() + .font_family(mono.clone()) + .text_xs() + .text_color(muted) + .child(short), + ) + .on_click(pick(Some(commit.oid.clone()))), + ); + } + if commits.len() > shown { + let view = view.clone(); + menu = menu.child( + item("pr-code-scope-more".into(), false) + .text_color(muted) + .child(format!("Show more ({} left)", commits.len() - shown)) + .on_click(cx.listener(move |_, _, _, cx| { + view.update(cx, |tab, cx| tab.show_more_commits(cx)).ok(); + cx.notify(); + })), + ); + } + menu.into_any_element() +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/viewed.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/viewed.rs new file mode 100644 index 000000000..b82897830 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/viewed.rs @@ -0,0 +1,355 @@ +//! The "Viewed" ticks of a pull request's files, optimistic: a press shows +//! at once (an overlay over GitHub's answer), presses are sent in batches +//! (the view flushes 400 ms after the last one), one batch at a time, and +//! a press stays over the answer until a read that could have seen its +//! write arrives. Ported from T3 Code's `filesViewedStore.ts` and +//! `pullRequestFilesViewed.logic.ts`, without I/O: the view sends the +//! batches [`ViewedFiles::take_batch`] hands out and reports back. + +use github::model::{FileViewedState, FilesViewed}; +use std::collections::HashMap; +use std::time::SystemTime; + +/// Presses sent in one write. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Batch { + pub request: u64, + pub files: Vec<(String, bool)>, +} + +/// What the view does after a write finished. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Finished { + /// Read the ticks again. + pub refresh: bool, + /// Tell the user the ticks could not be saved. + pub report_failure: bool, +} + +/// GitHub's answer: the states and the read they came from. +#[derive(Debug)] +struct Answer { + states: HashMap, + truncated: bool, + read: SystemTime, +} + +#[derive(Debug, Default)] +pub struct ViewedFiles { + answer: Option, + /// Presses shown over the answer. + overlay: HashMap, + /// Presses not sent yet, in press order. + queued: Vec<(String, bool)>, + /// The request that last sent a path, while it is under way. + sent_by: HashMap, + requests: u64, + in_flight: Option, + /// The read that was current when a path's write succeeded: the first + /// answer from another read could have seen the write and retires the + /// press, whatever it says (a push in between comes back `Dismissed`). + answered_from: HashMap>, +} + +impl ViewedFiles { + fn state(&self, path: &str) -> Option { + self.answer.as_ref()?.states.get(path).copied() + } + + pub fn is_viewed(&self, path: &str) -> bool { + self.overlay + .get(path) + .copied() + .unwrap_or_else(|| self.state(path) == Some(FileViewedState::Viewed)) + } + + /// Viewed once, but the file changed since ("Changed"). + pub fn is_stale(&self, path: &str) -> bool { + !self.overlay.contains_key(path) && self.state(path) == Some(FileViewedState::Dismissed) + } + + /// GitHub reported ticks for only some of the files. + pub fn truncated(&self) -> bool { + self.answer.as_ref().is_some_and(|a| a.truncated) + } + + /// Tick or untick `path`. + pub fn press(&mut self, path: &str, viewed: bool) { + self.overlay.insert(path.to_owned(), viewed); + match self.queued.iter_mut().find(|(p, _)| p == path) { + Some(queued) => queued.1 = viewed, + None => self.queued.push((path.to_owned(), viewed)), + } + } + + pub fn has_queued(&self) -> bool { + !self.queued.is_empty() + } + + /// The queued presses to send now; `None` when there are none or a + /// write is still under way (writes go one after the other). + pub fn take_batch(&mut self) -> Option { + if self.in_flight.is_some() || self.queued.is_empty() { + return None; + } + self.requests += 1; + let request = self.requests; + let files = std::mem::take(&mut self.queued); + for (path, _) in &files { + self.sent_by.insert(path.clone(), request); + } + self.in_flight = Some(request); + Some(Batch { request, files }) + } + + /// Every queued press, for a view that is going away: sent at once, + /// whatever is under way, and not answered for. + pub fn take_remaining(&mut self) -> Vec<(String, bool)> { + std::mem::take(&mut self.queued) + } + + /// `batch` was written (`ok`) or failed. + pub fn finish(&mut self, batch: &Batch, ok: bool) -> Finished { + if self.in_flight == Some(batch.request) { + self.in_flight = None; + } + // Paths a later batch sent again belong to that one. + let mine: Vec<&str> = batch + .files + .iter() + .map(|(path, _)| path.as_str()) + .filter(|path| self.sent_by.get(*path) == Some(&batch.request)) + .collect(); + for path in &mine { + self.sent_by.remove(*path); + } + if ok { + let read = self.answer.as_ref().map(|a| a.read); + for path in mine { + self.answered_from.insert(path.to_owned(), read); + } + return Finished { + refresh: true, + report_failure: false, + }; + } + // Put back what this batch pressed, unless pressed again since. + let owned: Vec<&str> = mine + .into_iter() + .filter(|path| !self.queued.iter().any(|(p, _)| p == path)) + .collect(); + for (path, viewed) in &batch.files { + if owned.contains(&path.as_str()) && self.overlay.get(path) == Some(viewed) { + self.overlay.remove(path); + } + } + let failed = !owned.is_empty(); + Finished { + refresh: failed, + report_failure: failed, + } + } + + /// GitHub's answer from the read at `read`; the same read handed in + /// again changes nothing. + pub fn answer(&mut self, files: &FilesViewed, read: SystemTime) { + if self.answer.as_ref().is_some_and(|a| a.read == read) { + return; + } + self.answer = Some(Answer { + states: files + .files + .iter() + .map(|f| (f.path.clone(), f.state)) + .collect(), + truncated: files.truncated, + read, + }); + let pending = |path: &str| { + self.queued.iter().any(|(p, _)| p == path) || self.sent_by.contains_key(path) + }; + let answered: Vec = self + .answered_from + .iter() + .filter(|(path, from)| !pending(path) && **from != Some(read)) + .map(|(path, _)| path.clone()) + .collect(); + for path in &answered { + self.answered_from.remove(path); + } + let settled: Vec = self + .overlay + .iter() + .filter(|(path, pressed)| { + !pending(path) + && (answered.contains(path) + || (self.state(path) == Some(FileViewedState::Viewed)) == **pressed) + }) + .map(|(path, _)| path.clone()) + .collect(); + for path in settled { + self.overlay.remove(&path); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use github::model::FileViewed; + use std::time::Duration; + + fn read(n: u64) -> SystemTime { + SystemTime::UNIX_EPOCH + Duration::from_secs(n) + } + + fn files(state: FileViewedState) -> FilesViewed { + FilesViewed { + files: vec![FileViewed { + path: "a.ts".into(), + state, + }], + truncated: false, + } + } + + fn unviewed() -> ViewedFiles { + let mut viewed = ViewedFiles::default(); + viewed.answer(&files(FileViewedState::Unviewed), read(1)); + viewed + } + + #[test] + fn states_come_from_the_answer_under_the_presses() { + let mut viewed = ViewedFiles::default(); + viewed.answer( + &FilesViewed { + files: vec![ + FileViewed { + path: "a".into(), + state: FileViewedState::Viewed, + }, + FileViewed { + path: "b".into(), + state: FileViewedState::Dismissed, + }, + ], + truncated: true, + }, + read(1), + ); + assert!(viewed.is_viewed("a") && !viewed.is_viewed("b") && !viewed.is_viewed("c")); + assert!(viewed.is_stale("b") && !viewed.is_stale("a")); + assert!(viewed.truncated()); + viewed.press("b", true); + assert!(viewed.is_viewed("b") && !viewed.is_stale("b")); + } + + #[test] + fn presses_batch_and_hold_until_a_read_that_could_have_seen_them() { + let mut viewed = unviewed(); + viewed.press("a.ts", true); + viewed.press("b.ts", true); + viewed.press("a.ts", false); + viewed.press("a.ts", true); + assert!(viewed.is_viewed("a.ts")); + let batch = viewed.take_batch().expect("queued"); + assert_eq!( + batch.files, + [("a.ts".to_owned(), true), ("b.ts".to_owned(), true)] + ); + assert_eq!(viewed.take_batch(), None, "nothing more queued"); + + let done = viewed.finish(&batch, true); + assert_eq!( + done, + Finished { + refresh: true, + report_failure: false + } + ); + // The read from before the write, handed in again, is no answer. + viewed.answer(&files(FileViewedState::Unviewed), read(1)); + assert!(viewed.is_viewed("a.ts")); + // A push landed between the write and the next read: GitHub wins. + viewed.answer(&files(FileViewedState::Dismissed), read(2)); + assert!(!viewed.is_viewed("a.ts") && viewed.is_stale("a.ts")); + } + + #[test] + fn writes_go_one_after_the_other() { + let mut viewed = unviewed(); + viewed.press("a.ts", true); + let first = viewed.take_batch().unwrap(); + viewed.press("b.ts", true); + assert_eq!(viewed.take_batch(), None, "waits for the first write"); + viewed.finish(&first, true); + let second = viewed.take_batch().expect("now it goes"); + assert_eq!(second.files, [("b.ts".to_owned(), true)]); + assert_eq!(second.request, first.request + 1); + } + + #[test] + fn a_failed_press_is_put_back_and_reported() { + let mut viewed = unviewed(); + viewed.press("a.ts", true); + let batch = viewed.take_batch().unwrap(); + assert_eq!( + viewed.finish(&batch, false), + Finished { + refresh: true, + report_failure: true + } + ); + assert!(!viewed.is_viewed("a.ts")); + } + + #[test] + fn a_failure_leaves_presses_made_since_alone() { + let mut viewed = unviewed(); + viewed.press("a.ts", true); + let batch = viewed.take_batch().unwrap(); + viewed.press("a.ts", false); + assert_eq!(viewed.finish(&batch, false), Finished::default()); + assert!(!viewed.is_viewed("a.ts")); + assert!(viewed.has_queued()); + } + + #[test] + fn a_queued_press_holds_over_an_answer_that_cannot_have_seen_it() { + let mut viewed = unviewed(); + viewed.press("a.ts", true); + viewed.answer(&files(FileViewedState::Viewed), read(2)); + assert!(viewed.is_viewed("a.ts")); + let batch = viewed.take_batch().unwrap(); + viewed.finish(&batch, true); + viewed.answer(&files(FileViewedState::Viewed), read(2)); + assert!(viewed.is_viewed("a.ts"), "the same read is no new answer"); + viewed.answer(&files(FileViewedState::Unviewed), read(3)); + assert!(!viewed.is_viewed("a.ts")); + } + + #[test] + fn an_agreeing_answer_settles_a_press_without_a_write() { + let mut viewed = unviewed(); + viewed.press("a.ts", true); + viewed.take_remaining(); + viewed.answer(&files(FileViewedState::Viewed), read(2)); + assert!(viewed.is_viewed("a.ts")); + viewed.answer(&files(FileViewedState::Unviewed), read(3)); + assert!( + !viewed.is_viewed("a.ts"), + "the press settled with the first" + ); + } + + #[test] + fn leaving_takes_everything_queued() { + let mut viewed = unviewed(); + viewed.press("a.ts", true); + let _under_way = viewed.take_batch().unwrap(); + viewed.press("b.ts", true); + assert_eq!(viewed.take_remaining(), [("b.ts".to_owned(), true)]); + assert!(!viewed.has_queued()); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/composer.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/composer.rs new file mode 100644 index 000000000..1cf2092c8 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/composer.rs @@ -0,0 +1,851 @@ +//! The floating composer at the detail page's bottom right: a round button +//! (with the number of pending line comments) opening a popover to comment +//! on the pull request or to review it. Ported from T3 Code's +//! `PullRequestComposer`, `PullRequestCommentForm` and +//! `PullRequestReviewForm`. +//! +//! **Comment**: ⌘Enter or "Comment" posts; "Close with comment" (two +//! clicks) or "Reopen with comment" post and then act. **Review**: a +//! summary and a verdict (those the viewer may give) sent with the pending +//! line comments of [`review_drafts`]. Both texts live as long as the page, +//! so dismissing the popover keeps them; a sent text is cleared. + +use super::actions::{Confirmable, InlineConfirm, can}; +use super::data::PullRequestData; +use super::review_draft::{ReviewDrafts, review_drafts}; +use crate::shared::dismiss::{TriggerBounds, dismiss_layer}; +use crate::shared::segmented_switch::segmented_switch; +use github::model::{Action, Detail, PullRequestAction, PullRequestState, ReviewVerdict}; +use gpui_kit::component::button::{Button, ButtonVariants}; +use gpui_kit::component::input::{Escape, InputEvent, Textarea, TextareaState}; +use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem}; +use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable}; +use gpui_kit::{ + Anchor, AnyElement, Context, Edges, Entity, MouseButton, Render, Subscription, Task, Window, + anchored, deferred, div, prelude::*, px, svg, +}; +use std::time::Duration; + +/// How long "Close with comment" waits for its confirming click. +const CONFIRM_TIMEOUT: Duration = Duration::from_secs(3); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ComposerMode { + Comment, + Review, +} + +/// What follows a comment besides posting it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FollowUp { + Close, + Reopen, +} + +/// Whether the viewer may comment, and the verdicts they may give. +pub fn offered(detail: &Detail) -> (bool, Vec) { + let verdicts = ReviewVerdict::ALL + .into_iter() + .filter(|v| detail.viewer_permissions.verdicts.contains(v)) + .collect(); + (detail.viewer_permissions.comment, verdicts) +} + +pub fn follow_up(detail: &Detail) -> Option { + match detail.state { + PullRequestState::Open if can(detail, PullRequestAction::Close) => Some(FollowUp::Close), + PullRequestState::Closed if can(detail, PullRequestAction::Reopen) => { + Some(FollowUp::Reopen) + } + _ => None, + } +} + +/// An approval needs no words; another verdict needs a summary or line +/// comments. +pub fn can_submit_review(verdict: ReviewVerdict, summary: &str, pending: usize) -> bool { + verdict == ReviewVerdict::Approve || !summary.trim().is_empty() || pending > 0 +} + +fn verdict_label(verdict: ReviewVerdict) -> &'static str { + match verdict { + ReviewVerdict::Comment => "Comment", + ReviewVerdict::Approve => "Approve", + ReviewVerdict::RequestChanges => "Request changes", + } +} + +fn verdict_icon(verdict: ReviewVerdict) -> &'static str { + match verdict { + ReviewVerdict::Comment => "icons/message_square.svg", + ReviewVerdict::Approve => "icons/check.svg", + ReviewVerdict::RequestChanges => "icons/circle_x.svg", + } +} + +/// What is being sent. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Sending { + Comment, + CommentAnd(FollowUp), + Review, +} + +/// The two texts, made when the composer first opens (they need a window). +struct Forms { + comment: Entity, + summary: Entity, + _subscriptions: Vec, +} + +pub struct Composer { + data: Entity, + drafts: Entity, + open: bool, + /// The form asked for; permissions may force the other. + requested: ComposerMode, + forms: Option, + verdict: ReviewVerdict, + sending: Option, + confirm: InlineConfirm, + trigger: TriggerBounds, + _confirm_timeout: Option>, + _sending: Option>, + _subscriptions: Vec, +} + +impl Composer { + pub fn new(data: Entity, cx: &mut Context) -> Self { + let drafts = review_drafts(cx); + let subscriptions = vec![ + cx.observe(&data, |_, _, cx| cx.notify()), + cx.observe(&drafts, |_, _, cx| cx.notify()), + ]; + Self { + data, + drafts, + open: false, + requested: ComposerMode::Comment, + forms: None, + verdict: ReviewVerdict::Comment, + sending: None, + confirm: InlineConfirm::default(), + trigger: TriggerBounds::default(), + _confirm_timeout: None, + _sending: None, + _subscriptions: subscriptions, + } + } + + fn forms(&mut self, window: &mut Window, cx: &mut Context) -> &Forms { + if self.forms.is_none() { + let comment = cx.new(|cx| { + TextareaState::new(window, cx) + .auto_grow(3, 12) + .placeholder("Leave a comment") + }); + let summary = cx.new(|cx| { + TextareaState::new(window, cx) + .auto_grow(3, 12) + .placeholder("Summarize your review") + }); + let subscriptions = vec![ + cx.subscribe_in(&comment, window, |this, _, event, window, cx| { + if matches!( + event, + InputEvent::PressEnter { + secondary: true, + .. + } + ) { + this.send_comment(None, window, cx); + } else if matches!(event, InputEvent::Change) { + cx.notify(); + } + }), + cx.subscribe(&summary, |_, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + cx.notify(); + } + }), + ]; + self.forms = Some(Forms { + comment, + summary, + _subscriptions: subscriptions, + }); + } + self.forms.as_ref().expect("made above") + } + + /// One of the texts, empty before the composer first opened. + fn text(&self, input: fn(&Forms) -> &Entity, cx: &gpui_kit::App) -> String { + self.forms + .as_ref() + .map(|forms| input(forms).read(cx).value().to_string()) + .unwrap_or_default() + } + + fn pending(&self, cx: &gpui_kit::App) -> usize { + self.drafts.read(cx).count(&self.data.read(cx).pr) + } + + /// The form shown: the asked one where the viewer may use it. + fn mode(&self, comment: bool, verdicts: &[ReviewVerdict]) -> ComposerMode { + match (comment, verdicts.is_empty()) { + (true, true) => ComposerMode::Comment, + (false, _) => ComposerMode::Review, + (true, false) => self.requested, + } + } + + pub fn toggle(&mut self, window: &mut Window, cx: &mut Context) { + if self.open { + self.close(cx); + return; + } + // A review under way is what the user comes back to. + let started = self.pending(cx) > 0 || !self.text(|f| &f.summary, cx).trim().is_empty(); + self.requested = if started { + ComposerMode::Review + } else { + ComposerMode::Comment + }; + self.open = true; + self.focus_form(window, cx); + cx.notify(); + } + + fn close(&mut self, cx: &mut Context) { + self.open = false; + self.confirm.disarm(); + cx.notify(); + } + + fn select_mode(&mut self, mode: ComposerMode, window: &mut Window, cx: &mut Context) { + self.requested = mode; + self.focus_form(window, cx); + cx.notify(); + } + + fn focus_form(&mut self, window: &mut Window, cx: &mut Context) { + let requested = self.requested; + let forms = self.forms(window, cx); + let input = match requested { + ComposerMode::Comment => forms.comment.clone(), + ComposerMode::Review => forms.summary.clone(), + }; + input.update(cx, |input, cx| input.focus(window, cx)); + } + + /// Post the comment, then close or reopen when asked. + fn send_comment( + &mut self, + then: Option, + window: &mut Window, + cx: &mut Context, + ) { + let body = self.text(|f| &f.comment, cx).trim().to_owned(); + if body.is_empty() || self.sending.is_some() { + return; + } + self.sending = Some(match then { + Some(follow_up) => Sending::CommentAnd(follow_up), + None => Sending::Comment, + }); + cx.notify(); + let data = self.data.clone(); + let posted = PullRequestData::write(&data, cx, move |service, pr| async move { + service.pull_request_comment(pr, body).await + }); + self._sending = Some(cx.spawn_in(window, async move |this, cx| { + let posted = posted.await.is_ok(); + if posted && let Some(follow_up) = then { + let action = match follow_up { + FollowUp::Close => Action::Close, + FollowUp::Reopen => Action::Reopen, + }; + let acted = cx.update(|_, cx| { + PullRequestData::write(&data, cx, move |service, pr| async move { + service.pull_request_action(pr, action).await + }) + }); + if let Ok(acted) = acted { + let _ = acted.await; + } + } + this.update_in(cx, |this, window, cx| { + this.sending = None; + if posted { + let comment = this.forms(window, cx).comment.clone(); + comment.update(cx, |input, cx| input.set_value("", window, cx)); + this.close(cx); + } + cx.notify(); + }) + .ok(); + })); + } + + /// "Close with comment" takes two clicks. + fn click_close_with_comment(&mut self, window: &mut Window, cx: &mut Context) { + if self.confirm.click(Confirmable::Close) { + self._confirm_timeout = None; + self.send_comment(Some(FollowUp::Close), window, cx); + } else { + self._confirm_timeout = Some(cx.spawn(async move |this, cx| { + cx.background_executor().timer(CONFIRM_TIMEOUT).await; + this.update(cx, |this, cx| { + this.confirm.disarm(); + cx.notify(); + }) + .ok(); + })); + cx.notify(); + } + } + + /// Send the review with the pending line comments; those added while + /// it is under way stay pending, and so does a summary edited meanwhile. + fn submit_review(&mut self, window: &mut Window, cx: &mut Context) { + if self.sending.is_some() { + return; + } + let summary = self.text(|f| &f.summary, cx); + let pr = self.data.read(cx).pr.clone(); + let (review, ids) = + self.drafts + .read(cx) + .submission(&pr, self.verdict, summary.trim().to_owned()); + self.sending = Some(Sending::Review); + cx.notify(); + let sent = PullRequestData::write(&self.data, cx, move |service, pr| async move { + service.pull_request_submit_review(pr, review).await + }); + self._sending = Some(cx.spawn_in(window, async move |this, cx| { + let sent = sent.await.is_ok(); + this.update_in(cx, |this, window, cx| { + this.sending = None; + if sent { + this.drafts + .update(cx, |drafts, cx| drafts.remove_submitted(&pr, &ids, cx)); + if this.text(|f| &f.summary, cx) == summary { + let input = this.forms(window, cx).summary.clone(); + input.update(cx, |input, cx| input.set_value("", window, cx)); + } + this.close(cx); + } + cx.notify(); + }) + .ok(); + })); + } + + fn discard_pending(&mut self, cx: &mut Context) { + let pr = self.data.read(cx).pr.clone(); + self.drafts.update(cx, |drafts, cx| { + let ids: Vec = drafts.comments(&pr).iter().map(|c| c.id).collect(); + drafts.remove_submitted(&pr, &ids, cx); + }); + } + + fn render_trigger(&self, pending: usize, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + div() + .id("pr-composer") + .debug_selector(|| "pr-composer".into()) + .relative() + .occlude() + // It floats over the tabs: what lies beneath (a diff line) must + // not take the press too. + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_mouse_up(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .size(px(36.)) + .flex() + .items_center() + .justify_center() + .rounded_full() + .border_1() + .border_color(theme.border) + .bg(theme.popover) + .shadow_md() + .cursor_pointer() + .hover(|s| s.bg(theme.muted)) + .child(self.trigger.probe()) + .child( + svg() + .size(px(16.)) + .path("icons/message_square.svg") + .text_color(theme.foreground), + ) + .when(pending > 0, |el| { + el.child( + div() + .absolute() + .top(px(-4.)) + .right(px(-4.)) + .h(px(16.)) + .min_w(px(16.)) + .px_1() + .rounded_full() + .bg(theme.info) + .flex() + .items_center() + .justify_center() + .text_size(px(10.)) + .text_color(gpui_kit::white()) + .child(pending.to_string()), + ) + }) + .on_click(cx.listener(|this, _, window, cx| this.toggle(window, cx))) + } + + fn render_comment_form( + &self, + detail: &Detail, + input: &Entity, + cx: &mut Context, + ) -> AnyElement { + let empty = input.read(cx).value().trim().is_empty(); + let busy = self.sending.is_some(); + let armed = self.confirm.is_armed(Confirmable::Close); + div() + .flex() + .flex_col() + .gap_2() + .child(Textarea::new(input)) + .child( + div() + .flex() + .justify_end() + .gap_2() + .children(follow_up(detail).map(|follow_up| { + match follow_up { + FollowUp::Close => Button::new("pr-comment-close") + .outline() + .danger() + .xsmall() + .icon(Icon::default().path("icons/git_pull_request_closed.svg")) + .label(if self.sending == Some(Sending::CommentAnd(follow_up)) { + "Closing…" + } else if armed { + "Confirm close?" + } else { + "Close with comment" + }) + .disabled(empty || busy) + .on_click(cx.listener(|this, _, window, cx| { + this.click_close_with_comment(window, cx) + })) + .into_any_element(), + FollowUp::Reopen => Button::new("pr-comment-reopen") + .outline() + .xsmall() + .icon(Icon::default().path("icons/git_pull_request.svg")) + .label(if self.sending == Some(Sending::CommentAnd(follow_up)) { + "Reopening…" + } else { + "Reopen with comment" + }) + .disabled(empty || busy) + .on_click(cx.listener(move |this, _, window, cx| { + this.send_comment(Some(FollowUp::Reopen), window, cx) + })) + .into_any_element(), + } + })) + .child( + div().debug_selector(|| "pr-comment-post".into()).child( + Button::new("pr-comment-post") + .outline() + .xsmall() + .icon(Icon::default().path("icons/send.svg")) + .label(if self.sending == Some(Sending::Comment) { + "Posting…" + } else { + "Comment" + }) + .tooltip("⌘↵") + .disabled(empty || busy) + .on_click(cx.listener(|this, _, window, cx| { + this.send_comment(None, window, cx) + })), + ), + ), + ) + .into_any_element() + } + + fn render_review_form( + &self, + verdicts: &[ReviewVerdict], + pending: usize, + input: &Entity, + cx: &mut Context, + ) -> AnyElement { + let verdict = if verdicts.contains(&self.verdict) { + self.verdict + } else { + verdicts[0] + }; + let summary = input.read(cx).value().to_string(); + let ready = can_submit_review(verdict, &summary, pending) && self.sending.is_none(); + let composer = cx.entity().downgrade(); + let choices = verdicts.to_vec(); + div() + .flex() + .flex_col() + .gap_2() + .child(Textarea::new(input)) + .child( + div() + .flex() + .items_center() + .gap_2() + .when(choices.len() > 1, |row| { + row.child( + Button::new("pr-review-verdict") + .outline() + .xsmall() + .icon(Icon::default().path(verdict_icon(verdict))) + .label(verdict_label(verdict)) + .dropdown_caret(true) + .dropdown_menu_with_anchor( + Anchor::BottomLeft, + move |menu, _, _| { + choices.iter().fold(menu, |menu, &choice| { + let composer = composer.clone(); + menu.item( + PopupMenuItem::new(verdict_label(choice)) + .icon( + Icon::default().path(verdict_icon(choice)), + ) + .checked(choice == verdict) + .on_click(move |_, _, cx| { + composer + .update(cx, |this, cx| { + this.verdict = choice; + cx.notify(); + }) + .ok(); + }), + ) + }) + }, + ), + ) + }) + .child(div().flex_1()) + .child( + div().debug_selector(|| "pr-review-submit".into()).child( + Button::new("pr-review-submit") + .primary() + .xsmall() + .label(if self.sending == Some(Sending::Review) { + "Submitting…" + } else { + "Submit review" + }) + .disabled(!ready) + .on_click(cx.listener(move |this, _, window, cx| { + this.verdict = verdict; + this.submit_review(window, cx) + })), + ), + ), + ) + .into_any_element() + } + + fn render_popover( + &mut self, + detail: &Detail, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let (can_comment, verdicts) = offered(detail); + let verdicts = verdicts.as_slice(); + let pending = self.pending(cx); + let mode = self.mode(can_comment, verdicts); + let (comment, summary) = { + let forms = self.forms(window, cx); + (forms.comment.clone(), forms.summary.clone()) + }; + let composer = cx.entity().downgrade(); + let title: AnyElement = if can_comment && !verdicts.is_empty() { + segmented_switch( + "pr-composer-mode", + &[ + (ComposerMode::Comment, "Comment"), + (ComposerMode::Review, "Review"), + ], + mode, + move |mode, window, cx| { + composer + .update(cx, |this, cx| this.select_mode(mode, window, cx)) + .ok(); + }, + window, + cx, + ) + .into_any_element() + } else { + div() + .text_sm() + .font_weight(gpui_kit::FontWeight::MEDIUM) + .child(match mode { + ComposerMode::Comment => "Comment on pull request", + ComposerMode::Review => "Review pull request", + }) + .into_any_element() + }; + let body = match mode { + ComposerMode::Comment => self.render_comment_form(detail, &comment, cx), + ComposerMode::Review => self.render_review_form(verdicts, pending, &summary, cx), + }; + let theme = cx.theme(); + let muted = theme.muted_foreground; + div() + .id("pr-composer-popover") + .occlude() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_mouse_up(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .w(px(420.)) + .mb_2() + .p_3() + .flex() + .flex_col() + .gap_3() + .rounded_lg() + .border_1() + .border_color(theme.border) + .bg(theme.popover) + .shadow_lg() + .capture_action(cx.listener(|this, _: &Escape, _, cx| { + this.close(cx); + cx.stop_propagation(); + })) + .child( + div() + .flex() + .items_center() + .gap_2() + .child(title) + .when(mode == ComposerMode::Review && pending > 0, |row| { + row.child(div().text_xs().text_color(muted).child(format!( + "{pending} pending {}", + if pending == 1 { "comment" } else { "comments" } + ))) + .child( + Button::new("pr-review-discard") + .ghost() + .xsmall() + .icon(Icon::default().path("icons/trash.svg")) + .tooltip("Discard pending line comments") + .disabled(self.sending.is_some()) + .on_click(cx.listener(|this, _, _, cx| this.discard_pending(cx))), + ) + }) + .child(div().flex_1()) + .child( + Button::new("pr-composer-close") + .ghost() + .xsmall() + .icon(Icon::default().path("icons/close.svg")) + .on_click(cx.listener(|this, _, _, cx| this.close(cx))), + ), + ) + .child(body) + .into_any_element() + } +} + +impl Render for Composer { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(detail) = self.data.read(cx).detail.clone() else { + return div().into_any_element(); + }; + let (can_comment, verdicts) = offered(&detail); + if !can_comment && verdicts.is_empty() { + return div().into_any_element(); + } + let pending = self.pending(cx); + let mut root = div() + .relative() + .flex() + .child(self.render_trigger(pending, cx)); + if self.open { + let popover = self.render_popover(&detail, window, cx); + root = root + .child(dismiss_layer( + &self.trigger, + window, + cx.listener(|this, _, _, cx| this.close(cx)), + )) + .child(deferred( + anchored() + .anchor(Anchor::BottomRight) + .snap_to_window_with_margin(Edges::all(px(8.))) + .child(popover), + )); + } + root.into_any_element() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::main_screen::right_panel::pull_request::fixtures::{detail, detail_with, repo}; + use gpui_kit::{TestAppContext, VisualTestContext}; + use std::sync::Arc; + + #[test] + fn permissions_decide_the_forms() { + let commenter = detail(1); + assert_eq!(offered(&commenter), (true, Vec::new())); + let reviewer = detail_with(1, |d| { + d.viewer_permissions.verdicts = + vec![ReviewVerdict::RequestChanges, ReviewVerdict::Comment] + }); + // In the order the form offers them. + assert_eq!( + offered(&reviewer).1, + [ReviewVerdict::Comment, ReviewVerdict::RequestChanges] + ); + } + + #[test] + fn a_comment_can_close_an_open_pull_request_or_reopen_a_closed_one() { + assert_eq!(follow_up(&detail(1)), Some(FollowUp::Close)); + let closed = detail_with(1, |d| d.state = PullRequestState::Closed); + assert_eq!(follow_up(&closed), Some(FollowUp::Reopen)); + let merged = detail_with(1, |d| d.state = PullRequestState::Merged); + assert_eq!(follow_up(&merged), None); + let not_allowed = detail_with(1, |d| d.viewer_permissions.actions.clear()); + assert_eq!(follow_up(¬_allowed), None); + } + + #[test] + fn only_an_approval_goes_without_words() { + assert!(can_submit_review(ReviewVerdict::Approve, "", 0)); + assert!(!can_submit_review(ReviewVerdict::Comment, " ", 0)); + assert!(can_submit_review(ReviewVerdict::Comment, "", 2)); + assert!(can_submit_review( + ReviewVerdict::RequestChanges, + "Rename it", + 0 + )); + } + + fn composer( + detail: Detail, + cx: &mut TestAppContext, + ) -> (Entity, &mut VisualTestContext) { + let window = cx.update(|cx| { + gpui_kit::component::init(cx); + cx.open_window(Default::default(), |_, cx| { + let data = cx.new(|cx| PullRequestData::new(repo().pull_request(1), cx)); + data.update(cx, |data, _| data.detail = Some(Arc::new(detail))); + cx.new(|cx| Composer::new(data, cx)) + }) + .unwrap() + }); + let view = window.root(cx).unwrap(); + let cx = VisualTestContext::from_window(window.into(), cx).into_mut(); + cx.run_until_parked(); + (view, cx) + } + + #[gpui_kit::test] + fn a_started_review_reopens_on_the_review_form(cx: &mut TestAppContext) { + let reviewer = detail_with(1, |d| { + d.viewer_permissions.verdicts = ReviewVerdict::ALL.to_vec() + }); + let (view, cx) = composer(reviewer, cx); + let bounds = cx.debug_bounds("pr-composer").unwrap(); + cx.simulate_click(bounds.center(), gpui_kit::Modifiers::none()); + cx.run_until_parked(); + assert!( + cx.debug_bounds("pr-comment-post").is_some(), + "comment first" + ); + + cx.simulate_input("Needs a test"); + view.update_in(cx, |view, window, cx| { + view.select_mode(ComposerMode::Review, window, cx) + }); + cx.simulate_input("Mostly fine"); + view.update_in(cx, |view, window, cx| view.toggle(window, cx)); + view.update_in(cx, |view, window, cx| view.toggle(window, cx)); + cx.run_until_parked(); + assert!(cx.debug_bounds("pr-review-submit").is_some()); + view.read_with(cx, |view, cx| { + assert_eq!(view.text(|f| &f.comment, cx), "Needs a test"); + assert_eq!(view.text(|f| &f.summary, cx), "Mostly fine"); + }); + } + + /// The composer over a surface that counts the presses reaching it. + struct Over { + composer: Entity, + presses: std::rc::Rc>, + } + + impl Render for Over { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let presses = self.presses.clone(); + div() + .size_full() + .relative() + .on_mouse_down(gpui_kit::MouseButton::Left, move |_, _, _| { + presses.set(presses.get() + 1) + }) + .child( + div() + .absolute() + .top_4() + .left_4() + .child(self.composer.clone()), + ) + } + } + + #[gpui_kit::test] + fn a_press_on_the_composer_stays_there(cx: &mut TestAppContext) { + let presses = std::rc::Rc::new(std::cell::Cell::new(0)); + let window = cx.update(|cx| { + gpui_kit::component::init(cx); + let presses = presses.clone(); + cx.open_window(Default::default(), move |_, cx| { + let data = cx.new(|cx| PullRequestData::new(repo().pull_request(1), cx)); + data.update(cx, |data, _| data.detail = Some(Arc::new(detail(1)))); + let composer = cx.new(|cx| Composer::new(data, cx)); + cx.new(|_| Over { composer, presses }) + }) + .unwrap() + }); + let cx = VisualTestContext::from_window(window.into(), cx).into_mut(); + cx.run_until_parked(); + let bounds = cx.debug_bounds("pr-composer").unwrap(); + cx.simulate_click(bounds.center(), gpui_kit::Modifiers::none()); + cx.run_until_parked(); + assert!(cx.debug_bounds("pr-comment-post").is_some(), "it opened"); + assert_eq!(presses.get(), 0, "nothing beneath took the press"); + } + + #[gpui_kit::test] + fn a_comment_that_did_not_post_is_kept(cx: &mut TestAppContext) { + let (view, cx) = composer(detail(1), cx); + view.update_in(cx, |view, window, cx| view.toggle(window, cx)); + cx.simulate_input("Looks good"); + view.update_in(cx, |view, window, cx| view.send_comment(None, window, cx)); + cx.run_until_parked(); + // No session service: nothing reaches GitHub. + view.read_with(cx, |view, cx| { + assert!(view.open); + assert_eq!(view.text(|f| &f.comment, cx), "Looks good"); + assert!(view.data.read(cx).error.is_some()); + }); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/agent.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/agent.rs new file mode 100644 index 000000000..1379ca659 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/agent.rs @@ -0,0 +1,211 @@ +//! "Add to agent": a comment, a review thread or a failing check handed to +//! the session's agent as a text attachment of the composer's draft (T3's +//! "Fix in a thread"). The text names what it is, where (pull request, +//! `path:line`), who said it, and links it. + +use super::super::presentation::check_status_label; +use super::logic::{ReviewOutcome, ThreadItem, visible_body}; +use super::parts::Location; +use crate::comments::{CommentChange, report}; +use code_assistant_core::persistence::DraftAttachment; +use github::PullRequestRef; +use github::model::{Check, Comment, CommentKind}; +use gpui_kit::component::button::{Button, ButtonVariants}; +use gpui_kit::component::{Icon, Sizable}; +use gpui_kit::{App, SharedString}; + +/// Put `text` into the composer's draft as an attachment. +pub fn add_to_agent(text: String, cx: &mut App) { + report( + CommentChange::Attach(DraftAttachment::Text { content: text }), + cx, + ); +} + +/// "Add to agent", putting `text` into the draft. +pub fn add_to_agent_button(id: SharedString, text: String) -> Button { + Button::new(id) + .ghost() + .xsmall() + .icon(Icon::default().path("icons/message_circle_plus.svg")) + .label("Add to agent") + .tooltip("Hand this to the session's agent") + .on_click(move |_, _, cx| add_to_agent(text.clone(), cx)) +} + +/// `owner/repo#N`. +fn pr_label(pr: &PullRequestRef) -> String { + format!("{}#{}", pr.repo.full_name(), pr.number) +} + +fn login(author: Option<&github::model::Actor>) -> String { + format!("@{}", author.map_or("ghost", |a| a.login.as_str())) +} + +/// "on owner/repo#N, path:line (outdated)". +fn place(pr: &PullRequestRef, location: Option<&Location>, notes: &[&str]) -> String { + let mut place = format!("on {}", pr_label(pr)); + if let Some(location) = location { + place.push_str(&format!(", {}", location.label())); + } + let notes: Vec<&str> = location + .filter(|l| l.outdated) + .map(|_| "outdated") + .into_iter() + .chain(notes.iter().copied()) + .collect(); + if !notes.is_empty() { + place.push_str(&format!(" ({})", notes.join(", "))); + } + place +} + +/// The heading, the link under it, then the body after a blank line. +fn compose(heading: String, url: Option<&str>, body: Option<&str>) -> String { + let mut text = heading; + if let Some(url) = url { + text.push('\n'); + text.push_str(url); + } + if let Some(body) = body { + text.push_str("\n\n"); + text.push_str(body); + } + text +} + +/// A comment, its kind, author and place. +pub fn comment_text(pr: &PullRequestRef, comment: &Comment, location: Option<&Location>) -> String { + let kind = match comment.kind { + CommentKind::IssueComment => "Comment", + CommentKind::ReviewComment => "Review comment", + CommentKind::Review => "Review", + }; + let mut heading = format!( + "{kind} by {} {}", + login(comment.author.as_ref()), + place(pr, location, &[]) + ); + if let Some(outcome) = ReviewOutcome::of(comment.review_state) { + heading.push_str(&format!(": {}", outcome.label())); + } + compose(heading, comment.url.as_deref(), visible_body(&comment.body)) +} + +/// A review thread with every comment read. +pub fn thread_text(pr: &PullRequestRef, item: &ThreadItem) -> String { + let thread = &item.thread; + let location = Location { + path: thread.path.clone(), + line: thread.line, + outdated: thread.is_outdated, + }; + let resolved: &[&str] = if thread.is_resolved { + &["resolved"] + } else { + &[] + }; + let heading = format!("Review thread {}", place(pr, Some(&location), resolved)); + let comments: Vec = std::iter::once(&item.root) + .chain(&item.replies) + .filter_map(|c| { + visible_body(&c.body).map(|body| format!("{}:\n{body}", login(c.author.as_ref()))) + }) + .collect(); + let body = comments.join("\n\n"); + compose( + heading, + item.url.as_deref(), + (!body.is_empty()).then_some(body.as_str()), + ) +} + +/// A check that did not pass. +pub fn check_text(pr: &PullRequestRef, check: &Check) -> String { + let mut heading = format!( + "Check \"{}\" on {}: {}", + check.name, + pr_label(pr), + check_status_label(check) + ); + if let Some(description) = check + .description + .as_deref() + .filter(|d| !d.trim().is_empty()) + { + heading.push_str(&format!(" ({})", description.trim())); + } + compose(heading, check.url.as_deref(), None) +} + +#[cfg(test)] +mod tests { + use super::super::fixtures::{comment, review, thread}; + use super::super::logic::thread_item; + use super::*; + use crate::main_screen::right_panel::pull_request::fixtures::{check, hours_ago, repo}; + use github::model::{CheckStatus, ReviewState}; + + fn pr() -> PullRequestRef { + repo().pull_request(290) + } + + #[test] + fn a_comment_says_who_where_and_what() { + let mut remark = comment("c1", "ann", hours_ago(1)); + remark.kind = CommentKind::ReviewComment; + remark.body = "\nThis unwrap panics.".into(); + let location = Location { + path: "src/lib.rs".into(), + line: Some(12), + outdated: true, + }; + assert_eq!( + comment_text(&pr(), &remark, Some(&location)), + "Review comment by @ann on stippi/code-assistant#290, src/lib.rs:12 (outdated)\n\ + https://github.com/c/c1\n\n\ + \nThis unwrap panics." + ); + assert_eq!( + comment_text(&pr(), &comment("c2", "bob", hours_ago(1)), None), + "Comment by @bob on stippi/code-assistant#290\nhttps://github.com/c/c2\n\nc2 by bob" + ); + } + + #[test] + fn a_review_names_its_verdict() { + let mut verdict = review("r1", "cid", ReviewState::ChangesRequested, hours_ago(1)); + verdict.url = None; + verdict.body = " ".into(); + assert_eq!( + comment_text(&pr(), &verdict, None), + "Review by @cid on stippi/code-assistant#290: Changes requested" + ); + } + + #[test] + fn a_thread_quotes_every_comment() { + let mut t = thread("t1", &[("a", hours_ago(3)), ("b", hours_ago(2))]); + t.is_resolved = true; + let item = thread_item(&t, &Default::default()).unwrap(); + assert_eq!( + thread_text(&pr(), &item), + "Review thread on stippi/code-assistant#290, src/lib.rs:12 (resolved)\n\ + https://github.com/c/a\n\n\ + @rev:\na in a thread\n\n\ + @rev:\nb in a thread" + ); + } + + #[test] + fn a_check_names_its_state_and_link() { + let mut failed = check("build / test", CheckStatus::Failure); + failed.url = Some("https://github.com/o/r/actions/runs/1".into()); + failed.description = Some("2 tests failed".into()); + assert_eq!( + check_text(&pr(), &failed), + "Check \"build / test\" on stippi/code-assistant#290: Failed (2 tests failed)\n\ + https://github.com/o/r/actions/runs/1" + ); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/body.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/body.rs new file mode 100644 index 000000000..96f6e59b3 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/body.rs @@ -0,0 +1,158 @@ +//! A comment's body as GitHub renders it ([`markdown::prepare`]). Its +//! Markdown is parsed once, when it is first shown, and kept with the card +//! that owns it; a long body is clipped at 240 px under a fade, with "Show +//! full comment" to unfold it. + +use super::super::markdown; +use github::RepoRef; +use gpui_kit::component::button::{Button, ButtonVariants}; +use gpui_kit::component::text::{TextView, TextViewState}; +use gpui_kit::component::{ActiveTheme, Sizable}; +use gpui_kit::{ + AnyElement, Context, Entity, Hsla, Pixels, SharedString, div, linear_color_stop, + linear_gradient, prelude::*, px, +}; + +/// Taller bodies are clipped. +pub const CLIP_HEIGHT: Pixels = px(240.); +const FADE_HEIGHT: Pixels = px(40.); + +pub struct CommentBody { + id: SharedString, + /// The body prepared for the Markdown view. + text: SharedString, + /// Parsed on first render. + state: Option>, + /// Long bodies are clipped (not a description). + clip: bool, + expanded: bool, + /// The last layout was taller than [`CLIP_HEIGHT`]. + overflowing: bool, +} + +impl CommentBody { + /// `id` keys the body's elements; `text` is GitHub Markdown. + pub fn new(id: impl Into, text: &str, repo: &RepoRef) -> Self { + Self { + id: id.into(), + text: markdown::prepare(text, repo).into(), + state: None, + clip: true, + expanded: false, + overflowing: false, + } + } + + /// Shown whole, however long. + pub fn unclipped(mut self) -> Self { + self.clip = false; + self + } + + /// The body changed (an edit, a refresh); a shown body is parsed anew. + pub fn set_text(&mut self, text: &str, repo: &RepoRef, cx: &mut Context) { + let prepared = markdown::prepare(text, repo); + if self.text.as_ref() == prepared { + return; + } + if let Some(state) = &self.state { + state.update(cx, |state, cx| state.set_text(&prepared, cx)); + } + self.text = prepared.into(); + } + + pub fn is_empty(&self) -> bool { + self.text.trim().is_empty() + } + + #[cfg(test)] + pub fn is_mounted(&self) -> bool { + self.state.is_some() + } + + /// The body inside a card painted in `background` (the fade's colour). + /// `body` finds this body in its owner `V`. + pub fn render( + &mut self, + background: Hsla, + body: fn(&mut V) -> &mut CommentBody, + cx: &mut Context, + ) -> AnyElement { + let text = self.text.clone(); + let state = self + .state + .get_or_insert_with(|| cx.new(|cx| TextViewState::markdown(&text, cx).selectable(true))) + .clone(); + let clipped = self.clip && !self.expanded; + let fade = clipped && self.overflowing; + let owner = cx.entity().downgrade(); + let id = self.id.clone(); + div() + .min_w_0() + .flex() + .flex_col() + .child( + div() + .relative() + .min_w_0() + .when(clipped, |el| el.max_h(CLIP_HEIGHT).overflow_hidden()) + // The content keeps its height inside the clip; tell the + // owner whether it is taller. + .on_children_prepainted(move |bounds, _, cx| { + let Some(content) = bounds.first() else { + return; + }; + let overflowing = content.size.height > CLIP_HEIGHT; + let owner = owner.clone(); + cx.defer(move |cx| { + owner + .update(cx, |owner, cx| { + let shown = body(owner); + if shown.overflowing != overflowing { + shown.overflowing = overflowing; + cx.notify(); + } + }) + .ok(); + }); + }) + .child(div().min_w_0().child(TextView::new(&state))) + .when(fade, |el| { + el.child( + div() + .absolute() + .left_0() + .right_0() + .bottom_0() + .h(FADE_HEIGHT) + .bg(linear_gradient( + 180., + linear_color_stop(background.opacity(0.), 0.), + linear_color_stop(background, 1.), + )), + ) + }), + ) + .when(self.clip && self.overflowing, |el| { + el.child( + div().pt_2().child( + Button::new(SharedString::from(format!("{id}-full"))) + .ghost() + .xsmall() + .label(if self.expanded { + "Show less" + } else { + "Show full comment" + }) + .text_color(cx.theme().muted_foreground) + .on_click(cx.listener(move |owner, _, _, cx| { + let shown = body(owner); + shown.expanded = !shown.expanded; + cx.notify(); + })), + ), + ) + }) + .into_any_element() + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/card.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/card.rs new file mode 100644 index 000000000..2107ddd8d --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/card.rs @@ -0,0 +1,498 @@ +//! One comment: who wrote it and when (links to the profile and the +//! comment), a review's verdict, the reactions, an edit pencil for the +//! viewer's own comments (the [`MarkdownEditor`] in place, saved through +//! `pull_request_update_comment`), where it sits in the code, and the body. +//! +//! A card keeps its own state across refreshes of the conversation: the +//! parsed body, an open editor, pending reactions. + +use super::super::PullRequestIntent; +use super::super::data::PullRequestData; +use super::agent::{add_to_agent_button, comment_text}; +use super::body::CommentBody; +use super::editor::{EditorEvent, EditorOptions, MarkdownEditor}; +use super::logic::{can_edit, preview_text, visible_body}; +use super::parts::{ + Collapsed, Location, identity, location_line, open_on_github, render_collapsed, review_badge, +}; +use super::reaction_bar::reaction_bar; +use super::reactions::PendingReactions; +use github::model::{Comment, CommentKind, EditableCommentKind, ReactionContent}; +use gpui_kit::component::button::{Button, ButtonVariants}; +use gpui_kit::component::{ActiveTheme, Icon, Sizable}; +use gpui_kit::{ + AnyElement, Context, Entity, EventEmitter, Render, SharedString, Subscription, Window, div, + prelude::*, +}; + +/// How a card sits on the page. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CardStyle { + /// Bordered, with a header strip: a comment on its own. + Card, + /// A plain entry in a thread or a group. + Entry, +} + +pub struct CommentCard { + data: Entity, + comment: Comment, + style: CardStyle, + location: Option, + /// A verdict given before the newest commit. + stale: bool, + /// Settled (a dismissed review) and folded to one line. + collapsed: bool, + body: CommentBody, + editor: Option<(Entity, Subscription)>, + reactions: PendingReactions, + /// Offers "Add to agent". + agent_button: bool, + /// Shows ↗ to the comment on GitHub. + github_link: bool, +} + +impl EventEmitter for CommentCard {} + +impl CommentCard { + pub fn new( + data: Entity, + comment: Comment, + style: CardStyle, + cx: &mut Context, + ) -> Self { + let repo = data.read(cx).pr.repo.clone(); + let body = CommentBody::new(format!("c-{}-body", comment.id), &comment.body, &repo); + Self { + data, + comment, + style, + location: None, + stale: false, + collapsed: false, + body, + editor: None, + reactions: PendingReactions::default(), + agent_button: false, + github_link: false, + } + } + + /// Show where the comment sits in the code under its header. + pub fn with_location(mut self, location: Option) -> Self { + self.location = location; + self + } + + /// Offer "Add to agent" (comments on their own, not a thread's). + pub fn with_agent_button(mut self, show: bool) -> Self { + self.agent_button = show; + self + } + + /// Show ↗ to the comment on GitHub in the header. + pub fn with_github_link(mut self, show: bool) -> Self { + self.github_link = show; + self + } + + /// Start folded to one line (a dismissed review). + pub fn collapsed(mut self, collapsed: bool) -> Self { + self.collapsed = collapsed; + self + } + + #[cfg(test)] + pub fn comment(&self) -> &Comment { + &self.comment + } + + /// The conversation was read anew; what the viewer does here stays. + pub fn set_comment(&mut self, comment: Comment, cx: &mut Context) { + if comment == self.comment { + return; + } + let repo = self.data.read(cx).pr.repo.clone(); + self.body.set_text(&comment.body, &repo, cx); + self.comment = comment; + cx.notify(); + } + + pub fn set_stale(&mut self, stale: bool, cx: &mut Context) { + if self.stale != stale { + self.stale = stale; + cx.notify(); + } + } + + #[cfg(test)] + pub fn is_editing(&self) -> bool { + self.editor.is_some() + } + + #[cfg(test)] + pub fn body(&self) -> &CommentBody { + &self.body + } + + fn viewer_can_edit(&self, cx: &gpui_kit::App) -> bool { + let viewer = self.data.read(cx).viewer.as_ref().map(|v| v.login.as_str()); + can_edit(&self.comment, viewer) + } + + pub fn start_editing(&mut self, window: &mut Window, cx: &mut Context) { + if self.editor.is_some() || !self.viewer_can_edit(cx) { + return; + } + let repo = self.data.read(cx).pr.repo.clone(); + let text = self.comment.body.clone(); + let editor = cx.new(|cx| MarkdownEditor::new(repo, &text, EditorOptions::EDIT, window, cx)); + let subscription = cx.subscribe( + &editor, + |this, editor, event: &EditorEvent, cx| match event { + EditorEvent::Submit(body) => this.save(editor, body.clone(), cx), + EditorEvent::Cancel => { + this.editor = None; + cx.notify(); + } + }, + ); + self.editor = Some((editor, subscription)); + cx.notify(); + } + + fn save(&mut self, editor: Entity, body: String, cx: &mut Context) { + let kind = match self.comment.kind { + CommentKind::IssueComment => EditableCommentKind::IssueComment, + CommentKind::ReviewComment => EditableCommentKind::ReviewComment, + CommentKind::Review => return, + }; + editor.update(cx, |editor, cx| editor.set_busy(true, cx)); + let id = self.comment.id.clone(); + let sent = body.clone(); + let write = PullRequestData::write(&self.data, cx, move |service, pr| async move { + service + .pull_request_update_comment(pr, id, kind, sent) + .await + }); + cx.spawn(async move |this, cx| { + let saved = write.await.is_ok(); + this.update(cx, |this, cx| { + if saved { + // Shown until the conversation is read anew. + let repo = this.data.read(cx).pr.repo.clone(); + this.body.set_text(&body, &repo, cx); + this.comment.body = body; + this.editor = None; + } else { + editor.update(cx, |editor, cx| editor.set_busy(false, cx)); + } + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + /// The viewer (un)reacts: shown at once, taken back when the write + /// fails. + pub fn react(&mut self, content: ReactionContent, reacted: bool, cx: &mut Context) { + self.reactions + .choose(&self.comment.reactions, content, reacted); + cx.notify(); + let id = self.comment.id.clone(); + let write = PullRequestData::write(&self.data, cx, move |service, pr| async move { + service + .pull_request_react(pr, Some(id), content, reacted) + .await + }); + cx.spawn(async move |this, cx| { + if write.await.is_err() { + this.update(cx, |this, cx| { + this.reactions.revert(content); + cx.notify(); + }) + .ok(); + } + }) + .detach(); + } + + /// The reaction bar as it stands, the viewer's pending choices applied. + pub fn render_reactions(&self, cx: &mut Context) -> Option { + let shown = self.reactions.shown(&self.comment.reactions); + let can_react = self.data.read(cx).viewer.is_some(); + reaction_bar( + &format!("c-{}", self.comment.id), + &shown, + can_react, + |this: &mut Self, content, reacted, cx| this.react(content, reacted, cx), + cx, + ) + } + + fn render_header(&self, cx: &mut Context) -> impl IntoElement + use<> { + let id = format!("c-{}", self.comment.id); + let host = self.data.read(cx).pr.repo.host.clone(); + let editable = self.editor.is_none() && self.viewer_can_edit(cx); + let muted = cx.theme().muted_foreground; + let github_link = self + .comment + .url + .clone() + .filter(|_| self.github_link) + .map(|url| open_on_github(format!("{id}-github").into(), url, cx)); + let handed = (self.agent_button && visible_body(&self.comment.body).is_some()).then(|| { + let pr = self.data.read(cx).pr.clone(); + let text = comment_text(&pr, &self.comment, self.location.as_ref()); + let agent_id = format!("{id}-agent"); + div() + .debug_selector({ + let agent_id = agent_id.clone(); + move || agent_id.clone() + }) + .child(add_to_agent_button(agent_id.into(), text)) + }); + div() + .min_w_0() + .flex() + .flex_wrap() + .items_center() + .gap_2() + .child(identity( + &id, + self.comment.author.as_ref(), + &host, + self.comment.created_at, + self.comment.url.as_deref(), + cx, + )) + .children(review_badge(&id, self.comment.review_state, self.stale, cx)) + .child(div().flex_1()) + .when(editable, |row| { + row.child( + div().debug_selector(|| format!("{id}-edit")).child( + Button::new(SharedString::from(format!("{id}-edit"))) + .ghost() + .xsmall() + .icon(Icon::default().path("icons/pencil.svg").text_color(muted)) + .tooltip("Edit comment") + .on_click( + cx.listener(|this, _, window, cx| this.start_editing(window, cx)), + ), + ), + ) + }) + .children(handed) + .children(github_link) + .children(self.render_reactions(cx)) + } + + fn render_content(&mut self, cx: &mut Context) -> Option { + if let Some((editor, _)) = &self.editor { + return Some(editor.clone().into_any_element()); + } + visible_body(&self.comment.body)?; + let background = cx.theme().background; + Some( + self.body + .render(background, |this: &mut Self| &mut this.body, cx), + ) + } + + fn render_collapsed(&self, cx: &mut Context) -> AnyElement { + let id = format!("c-{}", self.comment.id); + let host = self.data.read(cx).pr.repo.host.clone(); + let reactions = self.render_reactions(cx); + render_collapsed( + Collapsed { + id: &id, + author: self.comment.author.as_ref(), + host: &host, + created_at: self.comment.created_at, + url: self.comment.url.as_deref(), + replies: 0, + label: "Review dismissed", + preview: preview_text(&self.comment.body), + location: self.location.as_ref(), + reactions, + }, + |this: &mut Self, cx| { + this.collapsed = false; + cx.notify(); + }, + cx, + ) + } +} + +impl Render for CommentCard { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + if self.collapsed { + return self.render_collapsed(cx); + } + let header = self.render_header(cx); + let location = self.location.as_ref().map(|l| location_line(l, cx)); + let content = self.render_content(cx); + let theme = cx.theme(); + let id = SharedString::from(format!("c-{}", self.comment.id)); + match self.style { + CardStyle::Card => div() + .id(id) + .min_w_0() + .flex() + .flex_col() + .rounded_lg() + .border_1() + .border_color(theme.border.opacity(0.6)) + .bg(theme.background) + .child( + div() + .px_3() + .py_2() + .rounded_t_lg() + .bg(theme.muted.opacity(0.25)) + .child(header), + ) + .children(location.map(|l| div().px_3().pt_2().child(l))) + .children(content.map(|c| div().px_3().py_3().child(c))) + .into_any_element(), + CardStyle::Entry => div() + .id(id) + .min_w_0() + .flex() + .flex_col() + .gap_1p5() + .child(header) + .children(location) + .children(content) + .into_any_element(), + } + } +} + +#[cfg(test)] +mod tests { + use super::super::fixtures::{agent_inbox, click, comment, data, review, window}; + use super::*; + use crate::main_screen::right_panel::pull_request::fixtures::hours_ago; + use github::model::{Reaction, ReviewState}; + use gpui_kit::TestAppContext; + + fn heart(count: u32, mine: bool) -> Reaction { + Reaction { + content: ReactionContent::Heart, + count, + actors: Vec::new(), + viewer_has_reacted: mine, + } + } + + #[gpui_kit::test] + fn a_reaction_shows_at_once_and_is_taken_back_when_the_write_fails(cx: &mut TestAppContext) { + let data = data(cx, Some("me")); + let mut liked = comment("c1", "ann", hours_ago(2)); + liked.reactions = vec![heart(1, false)]; + let (card, cx) = window(cx, move |cx| { + CommentCard::new(data, liked, CardStyle::Card, cx) + }); + card.update(cx, |card, cx| card.react(ReactionContent::Heart, true, cx)); + card.read_with(cx, |card, _| { + assert_eq!( + card.reactions.shown(&card.comment.reactions), + [heart(2, true)] + ) + }); + // No session service in tests: the write fails. + cx.run_until_parked(); + card.read_with(cx, |card, cx| { + assert_eq!( + card.reactions.shown(&card.comment.reactions), + [heart(1, false)] + ); + assert!(card.data.read(cx).error.is_some(), "the failure shows"); + }); + // The pill writes the same way. + card.update(cx, |card, cx| { + card.data.update(cx, |d, cx| d.dismiss_error(cx)) + }); + let pill = cx.debug_bounds("c-c1-reaction-Heart").expect("a pill"); + cx.simulate_click(pill.center(), gpui_kit::Modifiers::none()); + cx.run_until_parked(); + card.read_with(cx, |card, cx| assert!(card.data.read(cx).error.is_some())); + } + + #[gpui_kit::test] + fn the_viewer_edits_their_own_comment_in_place(cx: &mut TestAppContext) { + let data = data(cx, Some("ann")); + let (card, cx) = window(cx, move |cx| { + CommentCard::new( + data, + comment("c1", "ann", hours_ago(2)), + CardStyle::Card, + cx, + ) + }); + let edit = cx.debug_bounds("c-c1-edit").expect("a pencil"); + cx.simulate_click(edit.center(), gpui_kit::Modifiers::none()); + cx.run_until_parked(); + card.read_with(cx, |card, _| assert!(card.is_editing())); + assert!( + cx.debug_bounds("c-c1-edit").is_none(), + "no pencil while editing" + ); + cx.simulate_keystrokes("escape"); + card.read_with(cx, |card, _| assert!(!card.is_editing())); + } + + #[gpui_kit::test] + fn add_to_agent_hands_the_comment_to_the_draft(cx: &mut TestAppContext) { + let data = data(cx, None); + let (_, cx) = window(cx, move |cx| { + CommentCard::new( + data, + comment("c1", "ann", hours_ago(2)), + CardStyle::Card, + cx, + ) + .with_agent_button(true) + }); + let inbox = agent_inbox(cx); + click(cx, "c-c1-agent"); + assert_eq!(inbox.borrow().len(), 1); + assert!(inbox.borrow()[0].starts_with("Comment by @ann on stippi/code-assistant#290")); + } + + #[gpui_kit::test] + fn others_comments_and_review_summaries_have_no_pencil(cx: &mut TestAppContext) { + let data = data(cx, Some("ann")); + let (_, cx) = window(cx, move |cx| { + CommentCard::new( + data, + review("r1", "ann", ReviewState::Approved, hours_ago(2)), + CardStyle::Card, + cx, + ) + }); + assert!(cx.debug_bounds("c-r1-edit").is_none()); + } + + #[gpui_kit::test] + fn a_dismissed_review_unfolds_and_mounts_its_body_then(cx: &mut TestAppContext) { + let data = data(cx, None); + let (card, cx) = window(cx, move |cx| { + CommentCard::new( + data, + review("r1", "bob", ReviewState::Dismissed, hours_ago(2)), + CardStyle::Card, + cx, + ) + .collapsed(true) + }); + card.read_with(cx, |card, _| assert!(!card.body().is_mounted())); + let expand = cx.debug_bounds("c-r1-expand").expect("folded"); + cx.simulate_click(expand.center(), gpui_kit::Modifiers::none()); + cx.run_until_parked(); + card.read_with(cx, |card, _| assert!(card.body().is_mounted())); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/editor.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/editor.rs new file mode 100644 index 000000000..8a0ecba4f --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/editor.rs @@ -0,0 +1,379 @@ +//! The Markdown box every comment is written in: a new comment, a reply, +//! an edit, the description. Write | Preview (rendered as GitHub renders +//! it), ⌘↵ submits, Escape cancels. The owner carries out the submit and +//! says when it is under way ([`MarkdownEditor::set_busy`]); a failed +//! write keeps the text. + +use super::super::markdown; +use github::RepoRef; +use gpui_kit::component::button::{Button, ButtonVariants}; +use gpui_kit::component::input::{Escape, InputEvent, Textarea, TextareaState}; +use gpui_kit::component::text::{TextView, TextViewState}; +use gpui_kit::component::{ActiveTheme, Disableable, Selectable, Sizable}; +use gpui_kit::{ + App, Context, Entity, EventEmitter, FocusHandle, Focusable, KeyDownEvent, Render, SharedString, + Subscription, Window, div, prelude::*, +}; + +pub enum EditorEvent { + /// The trimmed text, to be sent. + Submit(String), + Cancel, +} + +/// What an editor is for. +#[derive(Clone, Copy, Debug)] +pub struct EditorOptions { + pub placeholder: &'static str, + pub submit_label: &'static str, + /// Submitting nothing is allowed (clearing a description). + pub allow_empty: bool, + pub min_rows: usize, + pub max_rows: usize, +} + +impl EditorOptions { + /// The composer's. + #[allow(dead_code, reason = "the composer's box, wired by the composer")] + pub const COMMENT: EditorOptions = EditorOptions { + placeholder: "Leave a comment", + submit_label: "Comment", + allow_empty: false, + min_rows: 3, + max_rows: 16, + }; + pub const REPLY: EditorOptions = EditorOptions { + placeholder: "Reply", + submit_label: "Reply", + allow_empty: false, + min_rows: 2, + max_rows: 12, + }; + pub const EDIT: EditorOptions = EditorOptions { + placeholder: "Edit comment", + submit_label: "Save", + allow_empty: false, + min_rows: 3, + max_rows: 20, + }; + pub const DESCRIPTION: EditorOptions = EditorOptions { + placeholder: "Describe this pull request", + submit_label: "Save", + allow_empty: true, + min_rows: 6, + max_rows: 24, + }; +} + +pub struct MarkdownEditor { + repo: RepoRef, + options: EditorOptions, + input: Entity, + /// The rendered draft, while previewing. + preview: Option>, + previewing: bool, + /// Holds the keyboard while previewing, for ⌘↵ and Escape. + preview_focus: FocusHandle, + busy: bool, + _subscription: Subscription, +} + +impl EventEmitter for MarkdownEditor {} + +impl MarkdownEditor { + /// An editor holding `text`, focused. `repo` resolves references in + /// the preview. + pub fn new( + repo: RepoRef, + text: &str, + options: EditorOptions, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let text = text.to_owned(); + let input = cx.new(|cx| { + TextareaState::new(window, cx) + .auto_grow(options.min_rows, options.max_rows) + .placeholder(options.placeholder) + .default_value(text) + }); + input.update(cx, |input, cx| input.focus(window, cx)); + let subscription = cx.subscribe(&input, |this, _, event: &InputEvent, cx| { + if let InputEvent::PressEnter { + secondary: true, .. + } = event + { + this.submit(cx); + } + }); + Self { + repo, + options, + input, + preview: None, + previewing: false, + preview_focus: cx.focus_handle(), + busy: false, + _subscription: subscription, + } + } + + pub fn text(&self, cx: &App) -> String { + self.input.read(cx).value().to_string() + } + + /// A submit is under way: no edits, no second submit. + pub fn set_busy(&mut self, busy: bool, cx: &mut Context) { + if self.busy != busy { + self.busy = busy; + cx.notify(); + } + } + + pub fn submit(&mut self, cx: &mut Context) { + let text = self.text(cx).trim().to_owned(); + if self.busy || (text.is_empty() && !self.options.allow_empty) { + return; + } + cx.emit(EditorEvent::Submit(text)); + } + + fn cancel(&mut self, cx: &mut Context) { + if !self.busy { + cx.emit(EditorEvent::Cancel); + } + } + + fn set_previewing(&mut self, previewing: bool, window: &mut Window, cx: &mut Context) { + if self.previewing == previewing { + return; + } + self.previewing = previewing; + if previewing { + let prepared = markdown::prepare(&self.text(cx), &self.repo); + match &self.preview { + Some(state) => state.update(cx, |state, cx| state.set_text(&prepared, cx)), + None => self.preview = Some(cx.new(|cx| TextViewState::markdown(&prepared, cx))), + } + self.preview_focus.focus(window, cx); + } else { + self.input.update(cx, |input, cx| input.focus(window, cx)); + } + cx.notify(); + } +} + +impl Focusable for MarkdownEditor { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.input.read(cx).focus_handle(cx) + } +} + +impl Render for MarkdownEditor { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + let id = cx.entity_id().as_u64(); + let empty = self.text(cx).trim().is_empty(); + let can_submit = !self.busy && (!empty || self.options.allow_empty); + let mode = |label: &'static str, previewing: bool, selected: bool| { + Button::new(SharedString::from(format!("md-editor-{id}-{label}"))) + .ghost() + .xsmall() + .label(label) + .selected(selected) + .disabled(self.busy) + .on_click(cx.listener(move |this, _, window, cx| { + this.set_previewing(previewing, window, cx) + })) + }; + let body = if self.previewing { + div() + .min_h_16() + .px_3() + .py_2() + .rounded_lg() + .border_1() + .border_color(theme.border) + .child(match (&self.preview, empty) { + (Some(preview), false) => TextView::new(preview).into_any_element(), + _ => div() + .text_xs() + .text_color(theme.muted_foreground) + .child("Nothing to preview.") + .into_any_element(), + }) + .into_any_element() + } else { + Textarea::new(&self.input) + .disabled(self.busy) + .into_any_element() + }; + div() + .debug_selector(move || format!("md-editor-{id}")) + .flex() + .flex_col() + .gap_1p5() + .track_focus(&self.preview_focus) + // The text area has the keys while writing; these are for the + // preview. + .on_key_down(cx.listener(|this, event: &KeyDownEvent, _, cx| { + if !this.previewing { + return; + } + let keystroke = &event.keystroke; + match keystroke.key.as_str() { + "enter" if keystroke.modifiers.secondary() => this.submit(cx), + "escape" => this.cancel(cx), + _ => return, + } + cx.stop_propagation(); + })) + .capture_action(cx.listener(|this, _: &Escape, _, cx| { + this.cancel(cx); + cx.stop_propagation(); + })) + .child( + div() + .flex() + .items_center() + .gap_0p5() + .child(mode("Write", false, !self.previewing)) + .child(mode("Preview", true, self.previewing)), + ) + .child(body) + .child( + div() + .flex() + .items_center() + .justify_end() + .gap_1() + .child( + Button::new(SharedString::from(format!("md-editor-{id}-cancel"))) + .ghost() + .xsmall() + .label("Cancel") + .disabled(self.busy) + .on_click(cx.listener(|this, _, _, cx| this.cancel(cx))), + ) + .child( + Button::new(SharedString::from(format!("md-editor-{id}-submit"))) + .primary() + .xsmall() + .label(self.options.submit_label) + .tooltip("⌘↵") + .loading(self.busy) + .disabled(!can_submit) + .on_click(cx.listener(|this, _, _, cx| this.submit(cx))), + ), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::main_screen::right_panel::pull_request::fixtures::repo; + use gpui_kit::{TestAppContext, VisualTestContext}; + use std::cell::RefCell; + use std::rc::Rc; + + #[derive(Debug, PartialEq)] + enum Seen { + Submit(String), + Cancel, + } + + fn editor<'a>( + text: &str, + options: EditorOptions, + cx: &'a mut TestAppContext, + ) -> ( + Entity, + Rc>>, + &'a mut VisualTestContext, + ) { + let text = text.to_owned(); + let window = cx.update(|cx| { + gpui_kit::component::init(cx); + cx.open_window(Default::default(), |window, cx| { + cx.new(|cx| MarkdownEditor::new(repo(), &text, options, window, cx)) + }) + .unwrap() + }); + let view = window.root(cx).unwrap(); + let cx = VisualTestContext::from_window(window.into(), cx).into_mut(); + let seen = Rc::new(RefCell::new(Vec::new())); + let sink = seen.clone(); + cx.update(|_, cx| { + cx.subscribe(&view, move |_, event: &EditorEvent, _| { + sink.borrow_mut().push(match event { + EditorEvent::Submit(text) => Seen::Submit(text.clone()), + EditorEvent::Cancel => Seen::Cancel, + }) + }) + .detach() + }); + cx.run_until_parked(); + (view, seen, cx) + } + + #[gpui_kit::test] + fn cmd_enter_submits_the_trimmed_text(cx: &mut TestAppContext) { + let (_, seen, cx) = editor(" Looks good ", EditorOptions::REPLY, cx); + cx.simulate_keystrokes("secondary-enter"); + assert_eq!( + seen.borrow().as_slice(), + [Seen::Submit("Looks good".into())] + ); + } + + #[gpui_kit::test] + fn cmd_enter_and_escape_work_while_previewing(cx: &mut TestAppContext) { + let (view, seen, cx) = editor("Looks good", EditorOptions::REPLY, cx); + view.update_in(cx, |editor, window, cx| { + editor.set_previewing(true, window, cx) + }); + cx.run_until_parked(); + cx.simulate_keystrokes("secondary-enter"); + cx.simulate_keystrokes("escape"); + assert_eq!( + seen.borrow().as_slice(), + [Seen::Submit("Looks good".into()), Seen::Cancel] + ); + } + + #[gpui_kit::test] + fn escape_cancels(cx: &mut TestAppContext) { + let (_, seen, cx) = editor("draft", EditorOptions::REPLY, cx); + cx.simulate_keystrokes("escape"); + assert_eq!(seen.borrow().as_slice(), [Seen::Cancel]); + } + + #[gpui_kit::test] + fn nothing_is_submitted_empty(cx: &mut TestAppContext) { + let (view, seen, cx) = editor(" ", EditorOptions::REPLY, cx); + view.update(cx, |editor, cx| editor.submit(cx)); + assert!(seen.borrow().is_empty()); + } + + #[gpui_kit::test] + fn a_description_may_be_cleared_but_not_twice(cx: &mut TestAppContext) { + let (view, seen, cx) = editor("", EditorOptions::DESCRIPTION, cx); + view.update(cx, |editor, cx| editor.submit(cx)); + assert_eq!(seen.borrow().as_slice(), [Seen::Submit(String::new())]); + view.update(cx, |editor, cx| { + editor.set_busy(true, cx); + editor.submit(cx); + }); + assert_eq!(seen.borrow().len(), 1, "busy: no second submit"); + } + + #[gpui_kit::test] + fn preview_renders_the_draft(cx: &mut TestAppContext) { + let (view, _, cx) = editor("Fixes #12", EditorOptions::COMMENT, cx); + view.update_in(cx, |editor, window, cx| { + editor.set_previewing(true, window, cx) + }); + view.read_with(cx, |editor, _| assert!(editor.preview.is_some())); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/fixtures.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/fixtures.rs new file mode 100644 index 000000000..358a0d9dc --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/fixtures.rs @@ -0,0 +1,150 @@ +//! Conversation values for tests: comments, reviews, threads and commits. + +use crate::main_screen::right_panel::pull_request::data::PullRequestData; +use crate::main_screen::right_panel::pull_request::fixtures::{actor, detail, repo}; +use chrono::{DateTime, Utc}; +use github::model::{ + Actor, Comment, CommentKind, Commit, DiffSide, ReviewState, ReviewThread, ThreadComment, Viewer, +}; +use gpui_kit::{AppContext, Context, Entity, Render, TestAppContext, VisualTestContext}; +use std::sync::Arc; + +pub fn bot(login: &str) -> Actor { + Actor { + is_bot: true, + ..actor(login) + } +} + +/// An issue comment by `login`, its body "`id` by `login`". +pub fn comment(id: &str, login: &str, at: DateTime) -> Comment { + Comment { + id: id.into(), + kind: CommentKind::IssueComment, + author: Some(actor(login)), + body: format!("{id} by {login}"), + created_at: at, + edited_at: None, + url: Some(format!("https://github.com/c/{id}")), + path: None, + review_state: None, + reactions: Vec::new(), + } +} + +pub fn review(id: &str, login: &str, state: ReviewState, at: DateTime) -> Comment { + Comment { + kind: CommentKind::Review, + review_state: Some(state), + ..comment(id, login, at) + } +} + +/// A thread comment by "rev". +pub fn thread_reply(id: &str, at: DateTime) -> ThreadComment { + ThreadComment { + id: id.into(), + author: Some(actor("rev")), + body: format!("{id} in a thread"), + created_at: at, + edited_at: None, + url: Some(format!("https://github.com/c/{id}")), + reactions: Vec::new(), + } +} + +/// An open thread on `src/lib.rs:12` holding `comments`, all read. +pub fn thread(id: &str, comments: &[(&str, DateTime)]) -> ReviewThread { + ReviewThread { + id: id.into(), + path: "src/lib.rs".into(), + line: Some(12), + side: DiffSide::Right, + is_resolved: false, + is_outdated: false, + comments: comments + .iter() + .map(|(id, at)| thread_reply(id, *at)) + .collect(), + comment_count: comments.len() as u64, + next_comments_cursor: None, + } +} + +pub fn commit(oid: &str, at: DateTime) -> Commit { + Commit { + oid: oid.into(), + message_headline: format!("Commit {oid}"), + committed_date: at, + additions: Some(10), + deletions: Some(2), + authors: vec![actor("ann")], + } +} + +/// What a page knows, with `viewer` signed in; no detail. +pub fn data(cx: &mut TestAppContext, viewer: Option<&str>) -> Entity { + let viewer = viewer.map(|login| Viewer { + id: 1, + login: login.into(), + }); + cx.update(|cx| { + cx.new(|cx| { + let mut data = PullRequestData::new(repo().pull_request(290), cx); + data.viewer = viewer; + data + }) + }) +} + +/// [`data`] with the detail of an open pull request the viewer may do +/// everything with. +pub fn data_with_detail(cx: &mut TestAppContext, viewer: Option<&str>) -> Entity { + let data = data(cx, viewer); + data.update(cx, |data, _| data.detail = Some(Arc::new(detail(290)))); + data +} + +/// A window showing the view `build` makes. +pub fn window( + cx: &mut TestAppContext, + build: impl FnOnce(&mut Context) -> V + 'static, +) -> (Entity, &mut VisualTestContext) { + let window = cx.update(|cx| { + gpui_kit::component::init(cx); + cx.open_window(Default::default(), |_, cx| cx.new(build)) + .unwrap() + }); + let view = window.root(cx).unwrap(); + let cx = VisualTestContext::from_window(window.into(), cx).into_mut(); + cx.run_until_parked(); + (view, cx) +} + +/// What reaches the composer's draft through the comment bus: the texts +/// handed to the agent. +pub fn agent_inbox(cx: &mut VisualTestContext) -> std::rc::Rc>> { + use crate::comments::{CommentBus, CommentBusGlobal, CommentChange}; + use code_assistant_core::persistence::DraftAttachment; + let inbox = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let sink = inbox.clone(); + cx.update(|_, cx| { + let bus = cx.new(|_| CommentBus); + cx.set_global(CommentBusGlobal(bus.clone())); + cx.subscribe(&bus, move |_, change: &CommentChange, _| { + if let CommentChange::Attach(DraftAttachment::Text { content }) = change { + sink.borrow_mut().push(content.clone()); + } + }) + .detach(); + }); + inbox +} + +pub fn click(cx: &mut VisualTestContext, selector: &'static str) { + let bounds = cx + .debug_bounds(selector) + .unwrap_or_else(|| panic!("nothing rendered as {selector}")); + cx.simulate_click(bounds.center(), gpui_kit::Modifiers::none()); + cx.run_until_parked(); +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/logic.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/logic.rs new file mode 100644 index 000000000..344bea545 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/logic.rs @@ -0,0 +1,773 @@ +//! The conversation of a pull request as the Summary and Timeline tabs show +//! it, ported from T3 Code (`pullRequestDetail.logic.ts`): loose comments +//! and review threads in one sequence, the comment filter, paging, the +//! reviewers' latest verdicts and what a collapsed card previews. + +use chrono::{DateTime, Utc}; +use github::model::{ + Actor, Comment, CommentKind, Commit, ReviewState, ReviewThread, ThreadComment, +}; +use gpui_kit::{ListOffset, ListState, px}; +use std::collections::{HashMap, HashSet}; + +/// `body` without its surrounding whitespace; `None` when nothing but +/// whitespace and HTML comments (bots' bookkeeping markers) is left. +pub fn visible_body(body: &str) -> Option<&str> { + let mut rest = body; + while let Some(start) = rest.find("") { + Some(end) => rest = &rest[start + end + 3..], + // An unclosed comment is text. + None => return Some(body.trim()), + } + } + (!rest.trim().is_empty()).then(|| body.trim()) +} + +/// What a review says by its state alone. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReviewOutcome { + Approved, + ChangesRequested, + Dismissed, +} + +impl ReviewOutcome { + pub fn of(state: Option) -> Option { + match state? { + ReviewState::Approved => Some(ReviewOutcome::Approved), + ReviewState::ChangesRequested => Some(ReviewOutcome::ChangesRequested), + ReviewState::Dismissed => Some(ReviewOutcome::Dismissed), + ReviewState::Commented | ReviewState::Pending => None, + } + } + + /// An approval or a change request; a dismissed review no longer counts. + pub fn is_verdict(self) -> bool { + self != ReviewOutcome::Dismissed + } + + pub fn label(self) -> &'static str { + match self { + ReviewOutcome::Approved => "Approved", + ReviewOutcome::ChangesRequested => "Changes requested", + ReviewOutcome::Dismissed => "Review dismissed", + } + } + + /// Said of a verdict given before the newest commit. + pub fn stale_label(self) -> String { + format!("{} earlier changes", self.label()) + } +} + +/// A review thread with its comments in the conversation's form. +#[derive(Clone, Debug, PartialEq)] +pub struct ThreadItem { + pub thread: ReviewThread, + /// The comment that opened the thread. + pub root: Comment, + pub replies: Vec, + /// Comments of the thread that were not read. + pub more_replies: u64, + /// More comments can be read (counted or behind a cursor). + pub has_more_replies: bool, + /// Where the thread is on GitHub. + pub url: Option, +} + +/// One entry of the conversation: a comment on its own or a review thread. +#[derive(Clone, Debug, PartialEq)] +pub enum ConversationItem { + Comment(Comment), + Thread(ThreadItem), +} + +impl ConversationItem { + pub fn id(&self) -> &str { + match self { + ConversationItem::Comment(c) => &c.id, + ConversationItem::Thread(t) => &t.thread.id, + } + } + + /// The comment the item starts with. + pub fn lead(&self) -> &Comment { + match self { + ConversationItem::Comment(c) => c, + ConversationItem::Thread(t) => &t.root, + } + } + + pub fn thread(&self) -> Option<&ThreadItem> { + match self { + ConversationItem::Comment(_) => None, + ConversationItem::Thread(t) => Some(t), + } + } + + /// Every comment of the item that was read. + pub fn comments(&self) -> impl Iterator { + let replies = self.thread().map_or(&[][..], |t| &t.replies[..]); + std::iter::once(self.lead()).chain(replies) + } +} + +/// A thread comment as the conversation holds it. +pub fn thread_comment(comment: &ThreadComment, path: &str) -> Comment { + Comment { + id: comment.id.clone(), + kind: CommentKind::ReviewComment, + author: comment.author.clone(), + body: comment.body.clone(), + created_at: comment.created_at, + edited_at: comment.edited_at, + url: comment.url.clone(), + path: Some(path.to_owned()), + review_state: None, + reactions: comment.reactions.clone(), + } +} + +/// A thread with the conversation's copies of its comments (they carry +/// what edits and reactions need), oldest first. +pub fn thread_item(thread: &ReviewThread, by_id: &HashMap<&str, &Comment>) -> Option { + let mut ordered: Vec = thread + .comments + .iter() + .map(|c| match by_id.get(c.id.as_str()) { + Some(known) => (*known).clone(), + None => thread_comment(c, &thread.path), + }) + .collect(); + ordered.sort_by_key(|c| c.created_at); + let mut comments = ordered.into_iter(); + let root = comments.next()?; + let replies: Vec = comments.collect(); + let read = 1 + replies.len() as u64; + let more_replies = thread.comment_count.saturating_sub(read); + let url = std::iter::once(&root) + .chain(&replies) + .find_map(|c| c.url.clone()); + Some(ThreadItem { + thread: thread.clone(), + root, + replies, + more_replies, + has_more_replies: more_replies > 0 || thread.next_comments_cursor.is_some(), + url, + }) +} + +/// Loose comments in their order, each thread inserted at the time of its +/// first comment. +pub fn conversation_items(comments: &[Comment], threads: &[ReviewThread]) -> Vec { + let by_id: HashMap<&str, &Comment> = comments.iter().map(|c| (c.id.as_str(), c)).collect(); + let mut claimed: HashSet = HashSet::new(); + let mut thread_items: Vec = threads + .iter() + .filter_map(|thread| thread_item(thread, &by_id)) + .inspect(|item| { + claimed.extend( + std::iter::once(&item.root) + .chain(&item.replies) + .map(|c| c.id.clone()), + ); + }) + .collect(); + thread_items.sort_by_key(|t| t.root.created_at); + let mut threads = thread_items.into_iter().peekable(); + let mut items = Vec::with_capacity(comments.len()); + for comment in comments.iter().filter(|c| !claimed.contains(&c.id)) { + while let Some(thread) = threads.next_if(|t| t.root.created_at < comment.created_at) { + items.push(ConversationItem::Thread(thread)); + } + items.push(ConversationItem::Comment(comment.clone())); + } + items.extend(threads.map(ConversationItem::Thread)); + items +} + +/// Settled: in a resolved thread, or a dismissed review. +pub fn is_finished(comment: &Comment, thread: Option<&ReviewThread>) -> bool { + thread.is_some_and(|t| t.is_resolved) + || ReviewOutcome::of(comment.review_state) == Some(ReviewOutcome::Dismissed) +} + +/// What the Comments section shows besides people's open remarks. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct CommentFilter { + pub bots: bool, + /// Resolved threads and dismissed reviews. + pub resolved: bool, +} + +impl CommentFilter { + /// An item shows when any of its comments passes, which keeps a bot's + /// finding beside a person's reply. A bot's approval or change request + /// is a verdict, never chatter. + pub fn shows(self, item: &ConversationItem) -> bool { + let thread = item.thread().map(|t| &t.thread); + item.comments().any(|comment| { + let is_bot = comment.author.as_ref().is_some_and(|a| a.is_bot); + let is_verdict = + ReviewOutcome::of(comment.review_state).is_some_and(ReviewOutcome::is_verdict); + (self.bots || !is_bot || is_verdict) && (self.resolved || !is_finished(comment, thread)) + }) + } +} + +/// The indices of `items` the filter shows. +pub fn filter_items(items: &[ConversationItem], filter: CommentFilter) -> Vec { + (0..items.len()) + .filter(|&i| filter.shows(&items[i])) + .collect() +} + +/// Comments in `items`, read ones. +pub fn comment_count<'a>(items: impl IntoIterator) -> usize { + items.into_iter().map(|item| item.comments().count()).sum() +} + +/// The newest `count` of `len` entries: the range shown and how many older +/// ones stay hidden. +pub fn page(len: usize, count: usize) -> (std::ops::Range, usize) { + let start = len.saturating_sub(count); + (start..len, start) +} + +pub fn newest_commit_at(commits: &[Commit]) -> Option> { + commits.iter().map(|c| c.committed_date).max() +} + +/// A verdict given before the newest commit reviewed older changes. +pub fn is_verdict_stale(at: DateTime, newest_commit_at: Option>) -> bool { + newest_commit_at.is_some_and(|commit_at| at < commit_at) +} + +/// One reviewer's latest verdict. +#[derive(Clone, Debug, PartialEq)] +pub struct ReviewerOutcome { + pub actor: Option, + pub outcome: ReviewOutcome, + pub at: DateTime, + pub stale: bool, +} + +/// Each reviewer's latest verdict; reviewers whose latest review was +/// dismissed have none. +pub fn latest_review_outcomes(comments: &[Comment], commits: &[Commit]) -> Vec { + let newest = newest_commit_at(commits); + let mut order: Vec = Vec::new(); + let mut latest: HashMap = HashMap::new(); + for comment in comments { + let Some(outcome) = ReviewOutcome::of(comment.review_state) else { + continue; + }; + let key = comment.author.as_ref().map_or_else( + || format!("ghost:{}", comment.id), + |a| a.login.to_lowercase(), + ); + // Hosts can return reviews out of order. + if latest + .get(&key) + .is_some_and(|current| current.at > comment.created_at) + { + continue; + } + if !latest.contains_key(&key) { + order.push(key.clone()); + } + latest.insert( + key, + ReviewerOutcome { + actor: comment.author.clone(), + outcome, + at: comment.created_at, + stale: is_verdict_stale(comment.created_at, newest), + }, + ); + } + order + .into_iter() + .filter_map(|key| latest.remove(&key)) + .filter(|entry| entry.outcome.is_verdict()) + .collect() +} + +/// A reviewer as the Reviewers row shows them. +#[derive(Clone, Debug, PartialEq)] +pub struct ReviewerEntry { + pub actor: Option, + pub outcome: Option, + pub stale: bool, +} + +/// The requested reviewers with their verdicts, then everyone else who gave +/// one (GitHub drops a request once it is answered). Logins match without +/// regard to case. +pub fn reviewer_entries(requested: &[Actor], outcomes: &[ReviewerOutcome]) -> Vec { + let outcome_of = |login: &str| { + outcomes.iter().find(|o| { + o.actor + .as_ref() + .is_some_and(|a| a.login.eq_ignore_ascii_case(login)) + }) + }; + let requested_entries = requested.iter().map(|actor| { + let outcome = outcome_of(&actor.login); + ReviewerEntry { + actor: Some(actor.clone()), + outcome: outcome.map(|o| o.outcome), + stale: outcome.is_some_and(|o| o.stale), + } + }); + let others = outcomes + .iter() + .filter(|o| { + !requested.iter().any(|actor| { + o.actor + .as_ref() + .is_some_and(|a| a.login.eq_ignore_ascii_case(&actor.login)) + }) + }) + .map(|o| ReviewerEntry { + actor: o.actor.clone(), + outcome: Some(o.outcome), + stale: o.stale, + }); + requested_entries.chain(others).collect() +} + +/// `loaded` appended to `base`, without the comments already there. +pub fn merge_thread_comments( + base: &[ThreadComment], + loaded: &[ThreadComment], +) -> Vec { + let mut seen: HashSet<&str> = base.iter().map(|c| c.id.as_str()).collect(); + let mut merged = base.to_vec(); + merged.extend( + loaded + .iter() + .filter(|c| seen.insert(c.id.as_str())) + .cloned(), + ); + merged +} + +/// The viewer may rewrite their own issue and review comments; GitHub has +/// no mutation for a review's summary. +pub fn can_edit(comment: &Comment, viewer: Option<&str>) -> bool { + comment.kind != CommentKind::Review + && viewer.is_some_and(|viewer| { + comment + .author + .as_ref() + .is_some_and(|a| a.login.trim().eq_ignore_ascii_case(viewer.trim())) + }) +} + +/// What changed between two row lists, as a splice for a `ListState`: the +/// range of `old` that `count` rows of `new` replace (common rows at both +/// ends stay measured and scrolled where they are). `None` when equal. +pub fn list_splice(old: &[T], new: &[T]) -> Option<(std::ops::Range, usize)> { + if old == new { + return None; + } + let prefix = old.iter().zip(new).take_while(|(a, b)| a == b).count(); + let suffix = old[prefix..] + .iter() + .rev() + .zip(new[prefix..].iter().rev()) + .take_while(|(a, b)| a == b) + .count(); + Some((prefix..old.len() - suffix, new.len() - prefix - suffix)) +} + +/// Tell `list` that its rows went from `old` to `new`. A list scrolled to +/// its very top stays there: GPUI keeps the row seen first in view, so rows +/// put in front of it (the first rows of an empty list among them) would +/// scroll it down past them. +pub fn splice_list(list: &ListState, old: &[T], new: &[T]) { + let Some((range, count)) = list_splice(old, new) else { + return; + }; + let top = list.logical_scroll_top(); + let at_top = top.item_ix == 0 && top.offset_in_item == px(0.); + list.splice(range, count); + if at_top { + list.scroll_to(ListOffset::default()); + } +} + +/// The viewer may rewrite the description of their own pull request, or +/// of any they may merge. +pub fn can_edit_description(detail: &github::model::Detail, viewer: Option<&str>) -> bool { + let is_author = viewer.is_some_and(|viewer| { + detail + .author + .as_ref() + .is_some_and(|a| a.login.eq_ignore_ascii_case(viewer.trim())) + }); + is_author + || detail + .viewer_permissions + .actions + .contains(&github::model::PullRequestAction::Merge) +} + +/// One line of plain text for a collapsed card: Markdown marks, alert +/// markers, link targets and HTML comments left out. +pub fn preview_text(body: &str) -> String { + let mut text = String::with_capacity(body.len()); + for line in strip_html_comments(body).lines() { + if is_alert_marker(line) { + continue; + } + let line = line.trim_start_matches(|c: char| c.is_whitespace() || "> #*-".contains(c)); + text.push_str(&unlink(line)); + text.push(' '); + } + text.retain(|c| c != '*' && c != '`'); + text.split_whitespace().collect::>().join(" ") +} + +fn strip_html_comments(body: &str) -> String { + let mut out = String::with_capacity(body.len()); + let mut rest = body; + while let Some(start) = rest.find("") else { + break; + }; + out.push_str(&rest[..start]); + rest = &rest[start + end + 3..]; + } + out.push_str(rest); + out +} + +/// `> [!NOTE]` alone on its line. +fn is_alert_marker(line: &str) -> bool { + let line = line.trim(); + let line = line.strip_prefix('>').unwrap_or(line).trim_start(); + line.strip_prefix("[!") + .and_then(|rest| rest.strip_suffix(']')) + .is_some_and(|kind| { + !kind.is_empty() && kind.chars().all(|c| c.is_alphanumeric() || c == '_') + }) +} + +/// `[text](target)` and `![alt](src)` as their text. +fn unlink(line: &str) -> String { + let mut out = String::with_capacity(line.len()); + let mut rest = line; + while let Some(open) = rest.find('[') { + let label_end = rest[open + 1..].find(']').map(|i| open + 1 + i); + let link = label_end.and_then(|close| { + let after = &rest[close + 1..]; + let target = after.strip_prefix('(')?.find(')')?; + (close > open + 1).then_some((close, close + 1 + 1 + target + 1)) + }); + match link { + Some((close, end)) => { + let before = &rest[..open]; + out.push_str(before.strip_suffix('!').unwrap_or(before)); + out.push_str(&rest[open + 1..close]); + rest = &rest[end..]; + } + None => { + out.push_str(&rest[..open + 1]); + rest = &rest[open + 1..]; + } + } + } + out.push_str(rest); + out +} + +#[cfg(test)] +mod tests { + use super::super::fixtures::{bot, comment, commit, review, thread, thread_reply}; + use super::*; + use crate::main_screen::right_panel::pull_request::fixtures::{actor, hours_ago}; + + fn ids(items: &[ConversationItem]) -> Vec<&str> { + items.iter().map(|i| i.id()).collect() + } + + #[test] + fn marker_only_bodies_are_empty() { + assert_eq!(visible_body(" \n "), None); + assert_eq!(visible_body(""), None); + assert_eq!( + visible_body(" Looks good \n"), + Some(" Looks good ") + ); + assert_eq!(visible_body("\n> [!WARNING]\n> **Potential issue** in `parse`\n\n\ + - See [the docs](https://x.io) and ![img](a.png)\n## Fix\n" + ), + "Potential issue in parse See the docs and img Fix" + ); + assert_eq!(preview_text("[WIP] not a link"), "[WIP] not a link"); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/mod.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/mod.rs new file mode 100644 index 000000000..32ea12fc9 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/mod.rs @@ -0,0 +1,38 @@ +//! The building blocks of a pull request's conversation, shared by the +//! Summary, Timeline and Code tabs: +//! +//! - [`logic`]: loose comments and threads as one sequence, the comment +//! filter, paging, reviewers' verdicts (pure, tested); +//! - [`reactions`]: emoji, tooltips and the optimistic overlay (pure); +//! - [`CommentCard`]: one comment with its header, verdict, reactions, +//! edit-in-place and clipped body; +//! - [`ThreadCard`]: a review thread with resolve, replies, "Load more +//! comments" and a reply box, folded to one line once resolved; +//! - [`MarkdownEditor`]: the Write | Preview box every comment is written +//! in (⌘↵ submits, Escape cancels). +//! +//! Cards are entities owned by the tab that shows them, kept per comment +//! (or thread) id across refreshes; they write through +//! [`PullRequestData::write`](super::data::PullRequestData::write) and +//! emit [`PullRequestIntent`](super::PullRequestIntent)s (links to open). + +pub mod agent; +pub mod body; +pub mod card; +pub mod editor; +pub mod logic; +pub mod parts; +pub mod preferences; +pub mod reaction_bar; +pub mod reactions; +pub mod thread; + +pub use agent::add_to_agent_button; +pub use body::CommentBody; +pub use card::{CardStyle, CommentCard}; +pub use editor::{EditorEvent, EditorOptions, MarkdownEditor}; +pub use parts::{Location, outcome_color, outcome_icon}; +pub use thread::ThreadCard; + +#[cfg(test)] +pub mod fixtures; diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/parts.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/parts.rs new file mode 100644 index 000000000..5a8ddd798 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/parts.rs @@ -0,0 +1,371 @@ +//! Small pieces the cards share: who wrote a comment and when, a review's +//! verdict, where a comment sits in the code, and the one-line form of a +//! settled card. + +use super::super::PullRequestIntent; +use super::super::avatar::avatar; +use super::super::presentation::relative_time; +use super::logic::ReviewOutcome; +use chrono::{DateTime, Utc}; +use github::model::{Actor, ReviewState}; +use gpui_kit::component::button::{Button, ButtonVariants}; +use gpui_kit::component::tooltip::Tooltip; +use gpui_kit::component::{ActiveTheme, Icon, Sizable}; +use gpui_kit::{ + AnyElement, App, Context, EventEmitter, FontWeight, Hsla, SharedString, div, prelude::*, px, + svg, +}; + +/// Where a comment sits in the code. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Location { + pub path: String, + /// `None` once the line left the diff. + pub line: Option, + pub outdated: bool, +} + +impl Location { + /// `path:line`, or the path alone. + pub fn label(&self) -> String { + match self.line { + Some(line) => format!("{}:{line}", self.path), + None => self.path.clone(), + } + } +} + +pub fn outcome_color(outcome: ReviewOutcome, cx: &App) -> Hsla { + match outcome { + ReviewOutcome::Approved => cx.theme().success, + ReviewOutcome::ChangesRequested => cx.theme().danger, + ReviewOutcome::Dismissed => cx.theme().muted_foreground, + } +} + +pub fn outcome_icon(outcome: ReviewOutcome) -> &'static str { + match outcome { + ReviewOutcome::Approved => "icons/circle_check.svg", + ReviewOutcome::ChangesRequested => "icons/circle_x.svg", + ReviewOutcome::Dismissed => "icons/circle_dashed.svg", + } +} + +/// ↗: open `url` on GitHub. +pub fn open_on_github>( + id: SharedString, + url: String, + cx: &mut Context, +) -> AnyElement { + let muted = cx.theme().muted_foreground; + div() + .debug_selector({ + let id = id.clone(); + move || id.to_string() + }) + .child( + Button::new(id) + .ghost() + .xsmall() + .icon( + Icon::default() + .path("icons/external_link.svg") + .text_color(muted), + ) + .tooltip("Open on GitHub") + .on_click( + cx.listener(move |_, _, _, cx| { + cx.emit(PullRequestIntent::OpenUrl(url.clone())) + }), + ), + ) + .into_any_element() +} + +/// The profile of `login` on `host`. +pub fn profile_url(host: &str, login: &str) -> String { + format!("https://{host}/{login}") +} + +/// Avatar and login (a link to the profile), then the relative time (a +/// link to the comment, the full date as tooltip). +pub fn identity>( + id: &str, + author: Option<&Actor>, + host: &str, + created_at: DateTime, + url: Option<&str>, + cx: &mut Context, +) -> AnyElement { + let theme = cx.theme(); + let muted = theme.muted_foreground; + let login: SharedString = author.map_or("ghost".into(), |a| a.login.clone().into()); + let profile = author.map(|a| profile_url(host, &a.login)); + let full_date: SharedString = created_at + .with_timezone(&chrono::Local) + .format("%Y-%m-%d %H:%M") + .to_string() + .into(); + let url = url.map(str::to_owned); + div() + .min_w_0() + .flex() + .items_center() + .gap_1p5() + .text_xs() + .child(avatar(author, px(16.), cx)) + .child( + div() + .id(SharedString::from(format!("{id}-author"))) + .min_w_0() + .truncate() + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.foreground) + .child(login) + .when_some(profile, |el, profile| { + el.cursor_pointer() + .hover(|s| s.underline()) + .on_click(cx.listener(move |_, _, _, cx| { + cx.emit(PullRequestIntent::OpenUrl(profile.clone())) + })) + }), + ) + .child( + div() + .id(SharedString::from(format!("{id}-time"))) + .flex_none() + .text_color(muted) + .child(relative_time(created_at, Utc::now())) + .tooltip(move |window, cx| Tooltip::new(full_date.clone()).build(window, cx)) + .when_some(url, |el, url| { + el.cursor_pointer() + .hover(|s| s.underline()) + .on_click(cx.listener(move |_, _, _, cx| { + cx.emit(PullRequestIntent::OpenUrl(url.clone())) + })) + }), + ) + .into_any_element() +} + +/// "Approved" / "Changes requested" as a coloured pill, a stale one muted +/// with the reason as tooltip; other review states as plain text. +pub fn review_badge( + id: &str, + state: Option, + stale: bool, + cx: &App, +) -> Option { + let theme = cx.theme(); + let Some(outcome) = ReviewOutcome::of(state) else { + return match state? { + ReviewState::Commented => Some( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child("Commented") + .into_any_element(), + ), + _ => None, + }; + }; + let color = if stale { + theme.muted_foreground + } else { + outcome_color(outcome, cx) + }; + let stale_label: SharedString = outcome.stale_label().into(); + Some( + div() + .id(SharedString::from(format!("{id}-verdict"))) + .flex_none() + .flex() + .items_center() + .gap_1() + .px_1p5() + .rounded_full() + .border_1() + .border_color(color.opacity(0.4)) + .bg(color.opacity(0.08)) + .text_xs() + .text_color(color) + .child( + svg() + .size(px(11.)) + .path(outcome_icon(outcome)) + .text_color(color), + ) + .child(outcome.label()) + .when(stale, |el| { + el.tooltip(move |window, cx| Tooltip::new(stale_label.clone()).build(window, cx)) + }) + .into_any_element(), + ) +} + +/// `path:line` in the mono font, "Outdated" after it. +pub fn location_line(location: &Location, cx: &App) -> AnyElement { + let muted = cx.theme().muted_foreground; + div() + .min_w_0() + .flex() + .items_center() + .gap_2() + .text_xs() + .text_color(muted) + .child( + svg() + .flex_none() + .size(px(12.)) + .path("icons/file_code.svg") + .text_color(muted), + ) + .child( + div() + .min_w_0() + .truncate() + .font_family(cx.theme().mono_font_family.clone()) + .child(location.label()), + ) + .when(location.outdated, |el| { + el.child(div().flex_none().child("Outdated")) + }) + .into_any_element() +} + +/// What the one-line form of a settled card shows. +pub struct Collapsed<'a> { + pub id: &'a str, + pub author: Option<&'a Actor>, + pub host: &'a str, + pub created_at: DateTime, + pub url: Option<&'a str>, + /// Replies in the thread, read or not. + pub replies: u64, + /// "Resolved", "Review dismissed". + pub label: &'static str, + pub preview: String, + pub location: Option<&'a Location>, + /// The reaction bar of the leading comment. + pub reactions: Option, +} + +/// A settled card folded to one line: who and when, the replies, the +/// state (a click unfolds it), the reactions, then where and a preview. +pub fn render_collapsed>( + collapsed: Collapsed, + on_expand: impl Fn(&mut V, &mut Context) + Clone + 'static, + cx: &mut Context, +) -> AnyElement { + let theme = cx.theme(); + let (muted, border, hover_bg, foreground) = ( + theme.muted_foreground, + theme.border, + theme.muted, + theme.foreground, + ); + let id = collapsed.id; + let expand = on_expand.clone(); + div() + .id(SharedString::from(format!("{id}-collapsed"))) + .debug_selector({ + let selector = format!("{id}-collapsed"); + move || selector.clone() + }) + .p_3() + .flex() + .flex_col() + .gap_2() + .rounded_lg() + .border_1() + .border_color(border.opacity(0.6)) + .child( + div() + .flex() + .items_center() + .gap_2() + .child(identity( + id, + collapsed.author, + collapsed.host, + collapsed.created_at, + collapsed.url, + cx, + )) + .when(collapsed.replies > 0, |row| { + row.child(div().flex_none().text_xs().text_color(muted).child(format!( + "{} {}", + collapsed.replies, + if collapsed.replies == 1 { + "reply" + } else { + "replies" + } + ))) + }) + .child( + div() + .id(SharedString::from(format!("{id}-expand"))) + .debug_selector({ + let selector = format!("{id}-expand"); + move || selector.clone() + }) + .flex_none() + .flex() + .items_center() + .gap_1() + .px_1p5() + .py_0p5() + .rounded_md() + .cursor_pointer() + .text_xs() + .text_color(muted) + .hover(|s| s.bg(hover_bg).text_color(foreground)) + .child(collapsed.label) + .child( + svg() + .size(px(12.)) + .path("icons/chevron_down.svg") + .text_color(muted), + ) + .on_click(cx.listener(move |this, _, _, cx| expand(this, cx))), + ) + .child(div().flex_1()) + .children(collapsed.reactions), + ) + .when_some(collapsed.location, |el, location| { + el.child(location_line(location, cx)) + }) + .when(!collapsed.preview.is_empty(), |el| { + el.child( + div() + .id(SharedString::from(format!("{id}-preview"))) + .min_w_0() + .truncate() + .cursor_pointer() + .text_xs() + .text_color(muted) + .hover(|s| s.text_color(foreground)) + .child(collapsed.preview) + .on_click(cx.listener(move |this, _, _, cx| on_expand(this, cx))), + ) + }) + .into_any_element() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn locations_name_their_line() { + let mut location = Location { + path: "src/lib.rs".into(), + line: Some(12), + outdated: false, + }; + assert_eq!(location.label(), "src/lib.rs:12"); + location.line = None; + assert_eq!(location.label(), "src/lib.rs"); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/preferences.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/preferences.rs new file mode 100644 index 000000000..83be8b625 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/preferences.rs @@ -0,0 +1,56 @@ +//! What the conversation views remember across restarts (with the pull +//! request view's other [preferences](super::super::preferences)): the +//! Summary's comment filter and order, and the Timeline's order. + +use super::logic::CommentFilter; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ConversationPreferences { + pub show_bot_comments: bool, + /// Resolved threads and dismissed reviews. + pub show_resolved_comments: bool, + pub comments_oldest_first: bool, + pub timeline_oldest_first: bool, +} + +impl ConversationPreferences { + pub fn comment_filter(&self) -> CommentFilter { + CommentFilter { + bots: self.show_bot_comments, + resolved: self.show_resolved_comments, + } + } + + pub fn set_comment_filter(&mut self, filter: CommentFilter) { + self.show_bot_comments = filter.bots; + self.show_resolved_comments = filter.resolved; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_filter_round_trips_and_defaults_to_hiding() { + let mut preferences = ConversationPreferences::default(); + assert_eq!(preferences.comment_filter(), CommentFilter::default()); + let filter = CommentFilter { + bots: true, + resolved: false, + }; + preferences.set_comment_filter(filter); + assert_eq!(preferences.comment_filter(), filter); + let json = serde_json::to_string(&preferences).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + preferences + ); + assert_eq!( + serde_json::from_str::("{}").unwrap(), + ConversationPreferences::default() + ); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/reaction_bar.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/reaction_bar.rs new file mode 100644 index 000000000..36ee89b10 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/reaction_bar.rs @@ -0,0 +1,185 @@ +//! The reactions under a comment: a pill per emoji with its count (the +//! viewer's own highlighted, a tooltip naming who reacted) and 😊+ to pick +//! one of GitHub's eight. The owner keeps the optimistic overlay +//! ([`PendingReactions`](super::reactions::PendingReactions)) and writes. + +use super::reactions::{emoji, name, tooltip}; +use github::model::{Reaction, ReactionContent}; +use gpui_kit::component::popover::Popover; +use gpui_kit::component::tooltip::Tooltip; +use gpui_kit::component::{ActiveTheme, Selectable}; +use gpui_kit::{ + AnyElement, App, Context, ElementId, Pixels, SharedString, Window, div, prelude::*, px, svg, +}; + +const PILL_HEIGHT: Pixels = px(22.); + +/// What a click asks of the owner `V`: react with the content (`true`) or +/// take the reaction back. +pub type ToggleReaction = fn(&mut V, ReactionContent, bool, &mut Context); + +/// The bar for `shown` (the host's reactions with the overlay applied); +/// `None` when there is nothing to show or do. +pub fn reaction_bar( + id: &str, + shown: &[Reaction], + can_react: bool, + toggle: ToggleReaction, + cx: &mut Context, +) -> Option { + if shown.is_empty() && !can_react { + return None; + } + let theme = cx.theme(); + let (primary, border, muted_bg, muted) = ( + theme.primary, + theme.border, + theme.muted, + theme.muted_foreground, + ); + let pills = shown.iter().map(|reaction| { + let content = reaction.content; + let mine = reaction.viewer_has_reacted; + let tip: SharedString = tooltip(reaction).into(); + div() + .id(SharedString::from(format!("{id}-reaction-{content:?}"))) + .debug_selector({ + let selector = format!("{id}-reaction-{content:?}"); + move || selector.clone() + }) + .flex_none() + .h(PILL_HEIGHT) + .px_2() + .flex() + .items_center() + .gap_1() + .rounded_full() + .border_1() + .text_xs() + .map(|pill| { + if mine { + pill.border_color(primary.opacity(0.6)) + .bg(primary.opacity(0.1)) + .text_color(theme.foreground) + } else { + pill.border_color(border.opacity(0.7)) + .bg(muted_bg.opacity(0.4)) + .text_color(muted) + } + }) + .child(emoji(content)) + .child(reaction.count.to_string()) + .tooltip(move |window, cx| Tooltip::new(tip.clone()).build(window, cx)) + .when(can_react, |pill| { + pill.cursor_pointer() + .hover(|s| s.border_color(primary.opacity(0.6))) + .on_click(cx.listener(move |owner, _, _, cx| toggle(owner, content, !mine, cx))) + }) + .into_any_element() + }); + let reacted: Vec = shown + .iter() + .filter(|r| r.viewer_has_reacted) + .map(|r| r.content) + .collect(); + let picker = can_react.then(|| { + let owner = cx.entity().downgrade(); + Popover::new(SharedString::from(format!("{id}-reaction-picker"))) + .anchor(gpui_kit::Anchor::BottomLeft) + .trigger(PickerTrigger { + id: SharedString::from(format!("{id}-react")).into(), + selected: false, + }) + .content(move |_, _, cx| { + let popover = cx.entity().downgrade(); + div().flex().items_center().gap_0p5().children( + ReactionContent::ALL.into_iter().map(|content| { + let owner = owner.clone(); + let popover = popover.clone(); + let already = reacted.contains(&content); + div() + .id(SharedString::from(format!("pick-{content:?}"))) + .size_7() + .flex() + .items_center() + .justify_center() + .rounded_md() + .cursor_pointer() + .text_base() + .when(already, |el| el.bg(primary.opacity(0.1))) + .hover(|s| s.bg(muted_bg)) + .tooltip(move |window, cx| { + Tooltip::new(name(content)).build(window, cx) + }) + .child(emoji(content)) + .on_click(move |_, window, cx| { + popover.update(cx, |p, cx| p.dismiss(window, cx)).ok(); + owner + .update(cx, |owner, cx| toggle(owner, content, !already, cx)) + .ok(); + }) + }), + ) + }) + .into_any_element() + }); + Some( + div() + .flex_none() + .flex() + .flex_wrap() + .items_center() + .gap_1() + .children(pills) + .children(picker) + .into_any_element(), + ) +} + +/// The 😊+ pill that opens the picker. +#[derive(IntoElement)] +struct PickerTrigger { + id: ElementId, + selected: bool, +} + +impl Selectable for PickerTrigger { + fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + fn is_selected(&self) -> bool { + self.selected + } +} + +impl RenderOnce for PickerTrigger { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let color = if self.selected { + theme.foreground + } else { + theme.muted_foreground + }; + div() + .id(self.id) + .flex_none() + .h(PILL_HEIGHT) + .px_1p5() + .flex() + .items_center() + .rounded_full() + .border_1() + .border_color(theme.border.opacity(0.7)) + .cursor_pointer() + .hover(|s| s.border_color(theme.primary.opacity(0.6))) + .tooltip(|window, cx| Tooltip::new("Add a reaction").build(window, cx)) + .child( + svg() + .size(px(14.)) + .path("icons/smile_plus.svg") + .text_color(color), + ) + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/reactions.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/reactions.rs new file mode 100644 index 000000000..ae9a8e87f --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/reactions.rs @@ -0,0 +1,297 @@ +//! Reactions as the bar under a comment shows them, ported from T3 Code +//! (`pullRequestReactions.ts`): emoji and names, the tooltip naming who +//! reacted, and the optimistic overlay. A click shows at once; the overlay +//! holds the viewer's pending choices until the host reports different +//! reactions (its signature changes), and a failed write takes its choice +//! back. + +use github::model::{Reaction, ReactionContent}; +use std::collections::HashMap; + +pub fn emoji(content: ReactionContent) -> &'static str { + match content { + ReactionContent::ThumbsUp => "👍", + ReactionContent::ThumbsDown => "👎", + ReactionContent::Laugh => "😄", + ReactionContent::Hooray => "🎉", + ReactionContent::Confused => "😕", + ReactionContent::Heart => "❤️", + ReactionContent::Rocket => "🚀", + ReactionContent::Eyes => "👀", + } +} + +pub fn name(content: ReactionContent) -> &'static str { + match content { + ReactionContent::ThumbsUp => "thumbs up", + ReactionContent::ThumbsDown => "thumbs down", + ReactionContent::Laugh => "laugh", + ReactionContent::Hooray => "hooray", + ReactionContent::Confused => "confused", + ReactionContent::Heart => "heart", + ReactionContent::Rocket => "rocket", + ReactionContent::Eyes => "eyes", + } +} + +/// People a tooltip names before it counts the rest. +const NAMED_ACTOR_LIMIT: usize = 3; + +/// "You, ann, and 2 others reacted with heart emoji". The host leaves the +/// viewer out of `actors`; "You" is added only while the count has room. +pub fn tooltip(reaction: &Reaction) -> String { + let count = reaction.count as usize; + let viewer_has_room = reaction.actors.len() < count; + let names = (reaction.viewer_has_reacted && viewer_has_room) + .then_some("You") + .into_iter() + .chain(reaction.actors.iter().map(String::as_str)); + let mut parts: Vec = names + .take(NAMED_ACTOR_LIMIT.min(count)) + .map(str::to_owned) + .collect(); + let others = count.saturating_sub(parts.len()); + if others > 0 { + let unit = match (parts.is_empty(), others == 1) { + (false, true) => "other", + (false, false) => "others", + (true, true) => "person", + (true, false) => "people", + }; + parts.push(format!("{others} {unit}")); + } + format!( + "{} reacted with {} emoji", + join_names(&parts), + name(reaction.content) + ) +} + +/// "a", "a and b", "a, b, and c". +fn join_names(parts: &[String]) -> String { + match parts { + [] => String::new(), + [one] => one.clone(), + [one, two] => format!("{one} and {two}"), + [rest @ .., last] => format!("{}, and {last}", rest.join(", ")), + } +} + +/// Changes whenever the host reports different reactions. +pub fn signature(reactions: &[Reaction]) -> String { + reactions + .iter() + .map(|r| { + format!( + "{:?}:{}:{}", + r.content, + r.count, + u8::from(r.viewer_has_reacted) + ) + }) + .collect::>() + .join(" ") +} + +/// `reactions` with the viewer's pending choices applied, in GitHub's order. +pub fn apply_pending( + reactions: &[Reaction], + pending: &HashMap, +) -> Vec { + if pending.is_empty() { + return reactions.to_vec(); + } + let mut by_content: HashMap = + reactions.iter().map(|r| (r.content, r.clone())).collect(); + for (&content, &reacted) in pending { + match by_content.get_mut(&content) { + None if reacted => { + by_content.insert( + content, + Reaction { + content, + count: 1, + actors: Vec::new(), + viewer_has_reacted: true, + }, + ); + } + None => {} + Some(current) if current.viewer_has_reacted == reacted => {} + Some(current) => { + if reacted { + current.count += 1; + } else { + current.count = current.count.saturating_sub(1); + } + current.viewer_has_reacted = reacted; + if current.count == 0 { + by_content.remove(&content); + } + } + } + } + ReactionContent::ALL + .into_iter() + .filter_map(|content| by_content.remove(&content)) + .collect() +} + +/// The viewer's reactions on one subject that the host has not reported +/// yet. +#[derive(Clone, Debug, Default)] +pub struct PendingReactions { + /// The host's reactions the choices were made on. + signature: String, + choices: HashMap, +} + +impl PendingReactions { + /// The choices still in force on the host's `reactions`: none once the + /// host reports something else than they were made on. + fn current(&self, reactions: &[Reaction]) -> Option<&HashMap> { + (self.signature == signature(reactions)).then_some(&self.choices) + } + + /// What the bar shows for the host's `reactions`. + pub fn shown(&self, reactions: &[Reaction]) -> Vec { + match self.current(reactions) { + Some(choices) => apply_pending(reactions, choices), + None => reactions.to_vec(), + } + } + + /// The viewer (un)reacts with `content` on the host's `reactions`. + pub fn choose(&mut self, reactions: &[Reaction], content: ReactionContent, reacted: bool) { + if self.current(reactions).is_none() { + self.signature = signature(reactions); + self.choices.clear(); + } + self.choices.insert(content, reacted); + } + + /// The write of `content` failed: show what the host has. + pub fn revert(&mut self, content: ReactionContent) { + self.choices.remove(&content); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ReactionContent::*; + + fn reaction(content: ReactionContent, count: u32, actors: &[&str], mine: bool) -> Reaction { + Reaction { + content, + count, + actors: actors.iter().map(|a| a.to_string()).collect(), + viewer_has_reacted: mine, + } + } + + #[test] + fn tooltips_name_a_few_and_count_the_rest() { + assert_eq!( + tooltip(&reaction(Heart, 1, &[], true)), + "You reacted with heart emoji" + ); + assert_eq!( + tooltip(&reaction(ThumbsUp, 2, &["ann"], true)), + "You and ann reacted with thumbs up emoji" + ); + assert_eq!( + tooltip(&reaction(Rocket, 5, &["ann", "bob", "cid"], true)), + "You, ann, bob, and 2 others reacted with rocket emoji" + ); + // Ten actors are reported at most; the viewer has no room among them. + assert_eq!( + tooltip(&reaction(Eyes, 2, &["ann", "bob"], true)), + "ann and bob reacted with eyes emoji" + ); + assert_eq!( + tooltip(&reaction(Laugh, 4, &[], false)), + "4 people reacted with laugh emoji" + ); + assert_eq!( + tooltip(&reaction(Laugh, 1, &[], false)), + "1 person reacted with laugh emoji" + ); + } + + #[test] + fn signatures_follow_count_and_viewer() { + let a = [ + reaction(ThumbsUp, 1, &[], false), + reaction(Heart, 2, &[], true), + ]; + assert_eq!(signature(&a), "ThumbsUp:1:0 Heart:2:1"); + assert_ne!( + signature(&a), + signature(&[reaction(ThumbsUp, 1, &[], false)]) + ); + } + + #[test] + fn pending_choices_overlay_the_hosts_reactions() { + let host = [ + reaction(Heart, 2, &["ann"], true), + reaction(ThumbsUp, 1, &["bob"], false), + ]; + let pending = HashMap::from([ + (ThumbsUp, true), + (Heart, false), + (Rocket, true), + (Eyes, false), + ]); + assert_eq!( + apply_pending(&host, &pending), + [ + reaction(ThumbsUp, 2, &["bob"], true), + reaction(Heart, 1, &["ann"], false), + reaction(Rocket, 1, &[], true), + ] + ); + // The last reactor leaving drops the pill. + let mine = [reaction(Eyes, 1, &[], true)]; + assert!(apply_pending(&mine, &HashMap::from([(Eyes, false)])).is_empty()); + // A choice the host already reflects changes nothing. + assert_eq!(apply_pending(&mine, &HashMap::from([(Eyes, true)])), mine); + } + + #[test] + fn choices_hold_until_the_host_reports_new_reactions() { + let host = vec![reaction(Heart, 1, &["ann"], false)]; + let mut pending = PendingReactions::default(); + pending.choose(&host, Heart, true); + assert_eq!(pending.shown(&host), [reaction(Heart, 2, &["ann"], true)]); + pending.choose(&host, Rocket, true); + assert_eq!(pending.shown(&host).len(), 2); + + // The host's next read carries the reaction: the overlay is spent. + let refreshed = vec![reaction(Heart, 2, &["ann"], true)]; + assert_eq!(pending.shown(&refreshed), refreshed); + // A choice on the new reactions starts over. + pending.choose(&refreshed, Heart, false); + assert_eq!( + pending.shown(&refreshed), + [reaction(Heart, 1, &["ann"], false)] + ); + } + + #[test] + fn a_failed_write_takes_its_choice_back() { + let host = vec![reaction(Heart, 1, &["ann"], false)]; + let mut pending = PendingReactions::default(); + pending.choose(&host, Heart, true); + pending.choose(&host, Eyes, true); + pending.revert(Heart); + assert_eq!( + pending.shown(&host), + [ + reaction(Heart, 1, &["ann"], false), + reaction(Eyes, 1, &[], true) + ] + ); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/thread.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/thread.rs new file mode 100644 index 000000000..91278b3dc --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/conversation/thread.rs @@ -0,0 +1,584 @@ +//! A review thread: "○ Open · 3 comments" or "✓ Resolved" (a click folds +//! it), Outdated, Resolve / Unresolve, where it sits in the code (when +//! asked), its comments, "Load more comments" for the ones the conversation +//! read left out, and a reply box. A resolved thread starts folded to one +//! line. The Summary and Timeline tabs show threads with it, the Code tab +//! next to their lines. + +use super::super::PullRequestIntent; +use super::super::data::PullRequestData; +use super::agent::{add_to_agent_button, thread_text}; +use super::card::{CardStyle, CommentCard}; +use super::editor::{EditorEvent, EditorOptions, MarkdownEditor}; +use super::logic::{ThreadItem, merge_thread_comments, preview_text, thread_comment, thread_item}; +use super::parts::{Collapsed, Location, location_line, open_on_github, render_collapsed}; +use github::model::{Comment, ReviewThread, ThreadComment}; +use gpui_kit::component::button::{Button, ButtonVariants}; +use gpui_kit::component::{ActiveTheme, Sizable}; +use gpui_kit::{ + AnyElement, Context, Entity, EventEmitter, Render, SharedString, Subscription, Window, div, + prelude::*, px, svg, +}; +use std::collections::HashMap; + +struct MountedCard { + card: Entity, + _intents: Subscription, +} + +pub struct ThreadCard { + data: Entity, + item: ThreadItem, + /// Comments read with "Load more comments", past the item's. + loaded: Vec, + /// The cursor after `loaded`; `None` before the first page. + loaded_cursor: Option>, + loading_more: bool, + cards: HashMap, + expanded: bool, + show_location: bool, + resolving: bool, + reply: Option<(Entity, Subscription)>, + /// Shows ↗ to the thread on GitHub. + github_link: bool, +} + +impl EventEmitter for ThreadCard {} + +impl ThreadCard { + /// A thread of the conversation ([`conversation_items`](super::logic::conversation_items)). + pub fn new(data: Entity, item: ThreadItem) -> Self { + Self { + expanded: !item.thread.is_resolved, + data, + item, + loaded: Vec::new(), + loaded_cursor: None, + loading_more: false, + cards: HashMap::new(), + show_location: false, + resolving: false, + reply: None, + github_link: false, + } + } + + /// A thread as the activity lists it (the Code tab). `None` for a + /// thread without comments. + pub fn for_thread(data: Entity, thread: &ReviewThread) -> Option { + Some(Self::new(data, thread_item(thread, &HashMap::new())?)) + } + + /// Show ↗ to the thread on GitHub in the header. + pub fn with_github_link(mut self, show: bool) -> Self { + self.github_link = show; + self + } + + /// Show `path:line` under the header. + pub fn show_location(mut self, show: bool) -> Self { + self.show_location = show; + self + } + + #[cfg(test)] + pub fn thread(&self) -> &ReviewThread { + &self.item.thread + } + + pub fn is_expanded(&self) -> bool { + self.expanded + } + + /// The conversation was read anew. Loaded pages and an open reply stay; + /// a thread resolved or reopened meanwhile folds or unfolds. + pub fn set_item(&mut self, item: ThreadItem, cx: &mut Context) { + if item == self.item { + return; + } + if item.thread.is_resolved != self.item.thread.is_resolved { + self.expanded = !item.thread.is_resolved; + } + self.item = item; + let comments = self.comments(); + for comment in comments { + if let Some(mounted) = self.cards.get(&comment.id) { + mounted + .card + .update(cx, |card, cx| card.set_comment(comment, cx)); + } + } + cx.notify(); + } + + /// [`Self::set_item`] for a thread as the activity lists it. + pub fn set_thread(&mut self, thread: &ReviewThread, cx: &mut Context) { + if let Some(item) = thread_item(thread, &HashMap::new()) { + self.set_item(item, cx); + } + } + + pub fn set_expanded(&mut self, expanded: bool, cx: &mut Context) { + if self.expanded != expanded { + self.expanded = expanded; + cx.notify(); + } + } + + /// Every comment read, oldest first: the conversation's, then pages + /// loaded here. + fn comments(&self) -> Vec { + let item = &self.item; + let mut comments: Vec = std::iter::once(item.root.clone()) + .chain(item.replies.iter().cloned()) + .collect(); + let more: Vec = self + .loaded + .iter() + .filter(|c| !comments.iter().any(|known| known.id == c.id)) + .map(|c| thread_comment(c, &item.thread.path)) + .collect(); + comments.extend(more); + comments + } + + /// The cursor for the next page, if there is one. + fn next_cursor(&self) -> Option { + match &self.loaded_cursor { + Some(cursor) => cursor.clone(), + None => self.item.thread.next_comments_cursor.clone(), + } + } + + fn card(&mut self, comment: &Comment, cx: &mut Context) -> Entity { + if let Some(mounted) = self.cards.get(&comment.id) { + return mounted.card.clone(); + } + let data = self.data.clone(); + let comment = comment.clone(); + let id = comment.id.clone(); + let card = cx.new(|cx| CommentCard::new(data, comment, CardStyle::Entry, cx)); + let intents = cx.subscribe(&card, |_, _, intent: &PullRequestIntent, cx| { + cx.emit(intent.clone()) + }); + self.cards.insert( + id, + MountedCard { + card: card.clone(), + _intents: intents, + }, + ); + card + } + + fn can(&self, cx: &gpui_kit::App) -> (bool, bool) { + let data = self.data.read(cx); + let permissions = data.detail.as_ref().map(|d| &d.viewer_permissions); + ( + permissions.is_some_and(|p| p.comment), + permissions.is_some_and(|p| p.resolve), + ) + } + + pub fn toggle_resolved(&mut self, cx: &mut Context) { + if self.resolving { + return; + } + let resolved = !self.item.thread.is_resolved; + self.resolving = true; + cx.notify(); + let thread_id = self.item.thread.id.clone(); + let write = PullRequestData::write(&self.data, cx, move |service, pr| async move { + service + .pull_request_resolve_thread(pr, thread_id, resolved) + .await + }); + cx.spawn(async move |this, cx| { + let done = write.await.is_ok(); + this.update(cx, |this, cx| { + this.resolving = false; + if done { + // Shown until the conversation is read anew. + this.item.thread.is_resolved = resolved; + this.expanded = !resolved; + } + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + pub fn load_more(&mut self, cx: &mut Context) { + let Some(cursor) = self.next_cursor() else { + return; + }; + if self.loading_more { + return; + } + self.loading_more = true; + cx.notify(); + let thread_id = self.item.thread.id.clone(); + let read = PullRequestData::write(&self.data, cx, move |service, pr| async move { + service + .pull_request_thread_comments(pr, thread_id, cursor) + .await + }); + cx.spawn(async move |this, cx| { + let page = read.await; + this.update(cx, |this, cx| { + this.loading_more = false; + if let Ok(page) = page { + this.loaded = merge_thread_comments(&this.loaded, &page.comments); + this.loaded_cursor = Some(page.next_cursor); + } + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + pub fn start_reply(&mut self, window: &mut Window, cx: &mut Context) { + if self.reply.is_some() { + return; + } + let repo = self.data.read(cx).pr.repo.clone(); + let editor = cx.new(|cx| MarkdownEditor::new(repo, "", EditorOptions::REPLY, window, cx)); + let subscription = cx.subscribe( + &editor, + |this, editor, event: &EditorEvent, cx| match event { + EditorEvent::Submit(body) => this.send_reply(editor, body.clone(), cx), + EditorEvent::Cancel => { + this.reply = None; + cx.notify(); + } + }, + ); + self.reply = Some((editor, subscription)); + cx.notify(); + } + + fn send_reply(&mut self, editor: Entity, body: String, cx: &mut Context) { + editor.update(cx, |editor, cx| editor.set_busy(true, cx)); + let thread_id = self.item.thread.id.clone(); + let write = PullRequestData::write(&self.data, cx, move |service, pr| async move { + service.pull_request_reply(pr, thread_id, body).await + }); + cx.spawn(async move |this, cx| { + let sent = write.await.is_ok(); + this.update(cx, |this, cx| { + if sent { + // The reply comes with the next read; a page read to + // the end may continue again. + this.reply = None; + if this.loaded_cursor == Some(None) { + this.loaded_cursor = Some(this.item.thread.next_comments_cursor.clone()); + } + } else { + editor.update(cx, |editor, cx| editor.set_busy(false, cx)); + } + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + fn location(&self) -> Location { + Location { + path: self.item.thread.path.clone(), + line: self.item.thread.line, + outdated: self.item.thread.is_outdated, + } + } + + fn render_folded(&mut self, cx: &mut Context) -> AnyElement { + let root = self.item.root.clone(); + let reactions = self + .card(&root, cx) + .update(cx, |card, cx| card.render_reactions(cx)); + let host = self.data.read(cx).pr.repo.host.clone(); + let location = self.show_location.then(|| self.location()); + let id = format!("t-{}", self.item.thread.id); + let replies = self.item.thread.comment_count.saturating_sub(1); + render_collapsed( + Collapsed { + id: &id, + author: root.author.as_ref(), + host: &host, + created_at: root.created_at, + url: root.url.as_deref(), + replies, + label: if self.item.thread.is_resolved { + "Resolved" + } else { + "Open" + }, + preview: preview_text(&root.body), + location: location.as_ref(), + reactions, + }, + |this: &mut Self, cx| this.set_expanded(true, cx), + cx, + ) + } + + fn render_header(&self, can_resolve: bool, cx: &mut Context) -> impl IntoElement + use<> { + let id = format!("t-{}", self.item.thread.id); + let pr = self.data.read(cx).pr.clone(); + let handed = thread_text(&pr, &self.item); + let github_link = self + .item + .url + .clone() + .filter(|_| self.github_link) + .map(|url| open_on_github(format!("{id}-github").into(), url, cx)); + let theme = cx.theme(); + let thread = &self.item.thread; + let count = thread.comment_count.max(1 + self.item.replies.len() as u64); + let (icon, color, state) = if thread.is_resolved { + ("icons/circle_check.svg", theme.success, "Resolved") + } else { + ("icons/circle.svg", theme.muted_foreground, "Open") + }; + div() + .flex() + .items_center() + .gap_2() + .text_xs() + .text_color(theme.muted_foreground) + .child(svg().size(px(13.)).path(icon).text_color(color)) + .child( + div() + .id(SharedString::from(format!("{id}-fold"))) + .debug_selector({ + let selector = format!("{id}-fold"); + move || selector.clone() + }) + .cursor_pointer() + .hover(|s| s.text_color(theme.foreground)) + .child(format!( + "{state} · {count} {}", + if count == 1 { "comment" } else { "comments" } + )) + .on_click(cx.listener(|this, _, _, cx| this.set_expanded(false, cx))), + ) + .when(thread.is_outdated, |row| row.child("Outdated")) + .child(div().flex_1()) + .child( + div() + .debug_selector({ + let selector = format!("{id}-agent"); + move || selector.clone() + }) + .child(add_to_agent_button(format!("{id}-agent").into(), handed)), + ) + .children(github_link) + .when(can_resolve, |row| { + row.child( + div().debug_selector(|| format!("{id}-resolve")).child( + Button::new(SharedString::from(format!("{id}-resolve"))) + .ghost() + .xsmall() + .label(if thread.is_resolved { + "Unresolve" + } else { + "Resolve" + }) + .loading(self.resolving) + .on_click(cx.listener(|this, _, _, cx| this.toggle_resolved(cx))), + ), + ) + }) + } +} + +impl Render for ThreadCard { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !self.expanded { + return self.render_folded(cx); + } + let (can_reply, can_resolve) = self.can(cx); + let id = format!("t-{}", self.item.thread.id); + let cards: Vec> = self + .comments() + .iter() + .map(|comment| self.card(comment, cx)) + .collect(); + let next_cursor = self.next_cursor(); + let unread = self.item.more_replies > 0 && next_cursor.is_none() && self.loaded.is_empty(); + let header = self.render_header(can_resolve, cx); + let location = self + .show_location + .then(|| location_line(&self.location(), cx)); + let theme = cx.theme(); + let muted = theme.muted_foreground; + let url = self.item.url.clone(); + div() + .id(SharedString::from(id.clone())) + .min_w_0() + .p_3() + .flex() + .flex_col() + .gap_2() + .rounded_lg() + .border_1() + .border_color(theme.border.opacity(0.7)) + .bg(theme.background) + .child(header) + .children(location) + .child(div().flex().flex_col().gap_3().children(cards)) + .when_some(next_cursor, |el, _| { + el.child( + div().child( + Button::new(SharedString::from(format!("{id}-more"))) + .ghost() + .xsmall() + .label("Load more comments") + .loading(self.loading_more) + .on_click(cx.listener(|this, _, _, cx| this.load_more(cx))), + ), + ) + }) + .when(unread, |el| { + let count = self.item.more_replies; + el.child( + div() + .flex() + .items_center() + .gap_2() + .text_xs() + .text_color(muted) + .child(format!( + "{count} more {}", + if count == 1 { "reply" } else { "replies" } + )) + .when_some(url, |row, url| { + row.child( + div() + .id(SharedString::from(format!("{id}-on-github"))) + .cursor_pointer() + .hover(|s| s.text_color(theme.foreground).underline()) + .child("View on GitHub") + .on_click(cx.listener(move |_, _, _, cx| { + cx.emit(PullRequestIntent::OpenUrl(url.clone())) + })), + ) + }), + ) + }) + .when(can_reply, |el| match &self.reply { + Some((editor, _)) => el.child(editor.clone()), + None => el.child( + div().debug_selector(|| format!("{id}-reply")).child( + Button::new(SharedString::from(format!("{id}-reply"))) + .ghost() + .xsmall() + .label("Reply") + .on_click( + cx.listener(|this, _, window, cx| this.start_reply(window, cx)), + ), + ), + ), + }) + .into_any_element() + } +} + +#[cfg(test)] +mod tests { + use super::super::fixtures::{agent_inbox, click, data_with_detail, thread, window}; + use super::*; + use crate::main_screen::right_panel::pull_request::fixtures::hours_ago; + use gpui_kit::TestAppContext; + + fn card( + thread: ReviewThread, + cx: &mut TestAppContext, + ) -> (Entity, &mut gpui_kit::VisualTestContext) { + let data = data_with_detail(cx, Some("me")); + window(cx, move |_| { + ThreadCard::for_thread(data, &thread) + .unwrap() + .show_location(true) + }) + } + + #[gpui_kit::test] + fn an_open_thread_shows_its_comments_and_a_reply_box(cx: &mut TestAppContext) { + let (view, cx) = card( + thread("t1", &[("a", hours_ago(3)), ("b", hours_ago(2))]), + cx, + ); + view.read_with(cx, |view, _| { + assert!(view.is_expanded()); + assert_eq!(view.cards.len(), 2); + }); + let reply = cx.debug_bounds("t-t1-reply").expect("a reply button"); + cx.simulate_click(reply.center(), gpui_kit::Modifiers::none()); + cx.run_until_parked(); + view.read_with(cx, |view, _| assert!(view.reply.is_some())); + cx.simulate_keystrokes("escape"); + view.read_with(cx, |view, _| assert!(view.reply.is_none())); + } + + #[gpui_kit::test] + fn a_resolved_thread_starts_folded_and_mounts_no_cards_but_the_root(cx: &mut TestAppContext) { + let mut resolved = thread("t1", &[("a", hours_ago(3)), ("b", hours_ago(2))]); + resolved.is_resolved = true; + let (view, cx) = card(resolved, cx); + view.read_with(cx, |view, cx| { + assert!(!view.is_expanded()); + // Only the root's card, for its reactions; its body is not parsed. + assert_eq!(view.cards.len(), 1); + assert!(!view.cards["a"].card.read(cx).body().is_mounted()); + }); + let expand = cx.debug_bounds("t-t1-expand").expect("folded"); + cx.simulate_click(expand.center(), gpui_kit::Modifiers::none()); + cx.run_until_parked(); + view.read_with(cx, |view, _| assert!(view.is_expanded())); + } + + #[gpui_kit::test] + fn add_to_agent_hands_the_whole_thread_over(cx: &mut TestAppContext) { + let (_, cx) = card( + thread("t1", &[("a", hours_ago(3)), ("b", hours_ago(2))]), + cx, + ); + let inbox = agent_inbox(cx); + click(cx, "t-t1-agent"); + let inbox = inbox.borrow(); + assert!(inbox[0].starts_with("Review thread on stippi/code-assistant#290, src/lib.rs:12")); + assert!(inbox[0].contains("b in a thread")); + } + + #[gpui_kit::test] + fn a_failed_resolve_leaves_the_thread_as_it_was(cx: &mut TestAppContext) { + let (view, cx) = card(thread("t1", &[("a", hours_ago(3))]), cx); + let resolve = cx.debug_bounds("t-t1-resolve").expect("a resolve button"); + cx.simulate_click(resolve.center(), gpui_kit::Modifiers::none()); + cx.run_until_parked(); + view.read_with(cx, |view, cx| { + assert!(!view.resolving); + assert!(!view.thread().is_resolved); + assert!(view.data.read(cx).error.is_some(), "the failure shows"); + }); + } + + #[gpui_kit::test] + fn more_comments_load_from_the_threads_cursor(cx: &mut TestAppContext) { + let mut long = thread("t1", &[("a", hours_ago(3))]); + long.comment_count = 4; + long.next_comments_cursor = Some("c1".into()); + let (view, cx) = card(long, cx); + view.update(cx, |view, cx| view.load_more(cx)); + view.read_with(cx, |view, _| assert!(view.loading_more)); + cx.run_until_parked(); + // The read failed (no service): the cursor stays for another try. + view.read_with(cx, |view, _| { + assert!(!view.loading_more); + assert_eq!(view.next_cursor().as_deref(), Some("c1")); + }); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/data.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/data.rs new file mode 100644 index 000000000..9cdcadda8 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/data.rs @@ -0,0 +1,380 @@ +//! What the detail page knows about one pull request, shared by the header +//! and the tabs, which observe it. +//! +//! Reads go through the session service's cache: a held value comes at +//! once (`refreshing` while a newer one is read), and the core announces +//! the newer one ([`PullRequestEvent::Changed`]), which is read again. The +//! checks are polled while the page is shown and the pull request open. +//! Writes run through [`PullRequestData::write`], which shows a failure. + +use super::bus::{PullRequestEvent, bus}; +use super::presentation::effective_checks_state; +use crate::Gpui; +use code_assistant_core::pull_requests::{Fetched, PullRequestPart}; +use code_assistant_core::session::SessionService; +use github::PullRequestRef; +use github::model::{Activity, Check, Checks, ChecksState, Detail, PullRequestState, Viewer}; +use gpui_kit::{App, Context, Entity, SharedString, Subscription, Task}; +use std::collections::{HashMap, HashSet}; +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; + +/// How often the checks are read while some are pending, and otherwise. +const PENDING_CHECKS_POLL: Duration = Duration::from_secs(45); +const CHECKS_POLL: Duration = Duration::from_secs(60); + +/// The session service, once the app installed it. +pub fn session_service(cx: &App) -> Option { + cx.try_global::() + .and_then(|gpui| gpui.session_service()) +} + +/// What the detail page knows about one pull request; the tabs observe it. +pub struct PullRequestData { + pub pr: PullRequestRef, + pub detail: Option>, + pub activity: Option>, + pub checks: Option>, + pub viewer: Option, + /// A background revalidation is under way for some part. + pub refreshing: bool, + /// The last failed read or write, shown by the page until the next success. + pub error: Option, + refreshing_parts: HashSet, + reads: HashMap>, + viewer_read: Option>, + /// Prefetching what the Code tab reads first. + warm: Option>, + poll: Option>, + _bus: Subscription, +} + +impl PullRequestData { + pub fn new(pr: PullRequestRef, cx: &mut Context) -> Self { + let events = bus(cx); + let bus = cx.subscribe(&events, |this, _, event: &PullRequestEvent, cx| { + if let PullRequestEvent::Changed { pr, part } = event + && *pr == this.pr + { + this.read(*part, cx); + } + }); + Self { + pr, + detail: None, + activity: None, + checks: None, + viewer: None, + refreshing: false, + error: None, + refreshing_parts: HashSet::new(), + reads: HashMap::new(), + viewer_read: None, + warm: None, + poll: None, + _bus: bus, + } + } + + /// Read everything the page shows: detail, conversation, checks and who + /// is signed in. + pub fn load(&mut self, cx: &mut Context) { + for part in [ + PullRequestPart::Detail, + PullRequestPart::Activity, + PullRequestPart::Checks, + ] { + self.read(part, cx); + } + self.read_viewer(cx); + self.warm_code(cx); + } + + /// Read the first diff page and the files viewed into the core's cache, + /// so the Code tab finds them there when it is opened. + fn warm_code(&mut self, cx: &mut Context) { + let Some(service) = session_service(cx) else { + return; + }; + let pr = self.pr.clone(); + self.warm = Some(cx.spawn(async move |_, _| { + let (diff, viewed) = futures::join!( + service.pull_request_diff(pr.clone(), None, None), + service.pull_request_files_viewed(pr), + ); + if let Err(error) = diff.map(drop).and(viewed.map(drop)) { + tracing::debug!("pull request code not prefetched: {error:#}"); + } + })); + } + + /// Read `part` (held value first). Parts other views keep (diff, files + /// viewed) are theirs to read. + pub fn read(&mut self, part: PullRequestPart, cx: &mut Context) { + match part { + PullRequestPart::Detail => { + self.fetch(part, cx, Self::store_detail, |s, pr| async move { + s.pull_request_detail(pr).await + }) + } + PullRequestPart::Activity => { + self.fetch(part, cx, Self::store_activity, |s, pr| async move { + s.pull_request_activity(pr).await + }) + } + PullRequestPart::Checks => { + self.fetch(part, cx, Self::store_checks, |s, pr| async move { + s.pull_request_checks(pr).await + }) + } + PullRequestPart::Diff | PullRequestPart::FilesViewed => {} + } + } + + fn store_detail(&mut self, detail: Detail) { + self.detail = Some(Arc::new(detail)); + } + + fn store_activity(&mut self, activity: Activity) { + self.activity = Some(Arc::new(activity)); + } + + fn store_checks(&mut self, checks: Checks) { + self.checks = Some(Arc::new(checks)); + } + + fn fetch( + &mut self, + part: PullRequestPart, + cx: &mut Context, + store: fn(&mut Self, T), + read: impl FnOnce(SessionService, PullRequestRef) -> Fut + 'static, + ) where + Fut: Future>> + 'static, + { + let Some(service) = session_service(cx) else { + return; + }; + let pr = self.pr.clone(); + let task = cx.spawn(async move |this, cx| { + let result = read(service, pr).await; + this.update(cx, |this, cx| { + this.reads.remove(&part); + match result { + Ok(fetched) => { + store(this, fetched.value); + this.error = None; + this.set_part_refreshing(part, fetched.refreshing); + } + Err(error) => { + this.error = Some(format!("{error:#}").into()); + this.set_part_refreshing(part, false); + } + } + cx.notify(); + }) + .ok(); + }); + self.reads.insert(part, task); + } + + fn set_part_refreshing(&mut self, part: PullRequestPart, refreshing: bool) { + if refreshing { + self.refreshing_parts.insert(part); + } else { + self.refreshing_parts.remove(&part); + } + self.refreshing = !self.refreshing_parts.is_empty(); + } + + fn read_viewer(&mut self, cx: &mut Context) { + let Some(service) = session_service(cx) else { + return; + }; + let host = self.pr.repo.host.clone(); + self.viewer_read = Some(cx.spawn(async move |this, cx| { + let viewer = service.github_viewer(host).await; + this.update(cx, |this, cx| match viewer { + Ok(viewer) => { + this.viewer = Some(viewer); + cx.notify(); + } + Err(error) => tracing::debug!("no GitHub viewer: {error:#}"), + }) + .ok(); + })); + } + + /// The checks to show: the polled ones once read, else the detail's. + pub fn current_checks(&self) -> &[Check] { + match (&self.checks, &self.detail) { + (Some(checks), _) => &checks.checks, + (None, Some(detail)) => &detail.checks, + (None, None) => &[], + } + } + + /// The verdict over [`Self::current_checks`]. + pub fn checks_state(&self) -> Option { + let rollup = match (&self.checks, &self.detail) { + (Some(checks), _) => checks.checks_state, + (None, Some(detail)) => detail.checks_state, + (None, None) => None, + }; + effective_checks_state(rollup, self.current_checks()) + } + + fn poll_interval(&self) -> Duration { + if self.checks_state() == Some(ChecksState::Pending) { + PENDING_CHECKS_POLL + } else { + CHECKS_POLL + } + } + + fn is_open(&self) -> bool { + self.detail + .as_ref() + .is_none_or(|detail| detail.state == PullRequestState::Open) + } + + /// Poll the checks while `polling` (the page is shown) and the pull + /// request open: every 45 s while some are pending, else every 60 s. + pub fn set_polling(&mut self, polling: bool, cx: &mut Context) { + if !polling { + self.poll = None; + return; + } + if self.poll.is_some() { + return; + } + self.poll = Some(cx.spawn(async move |this, cx| { + loop { + let Ok(interval) = this.read_with(cx, |data, _| data.poll_interval()) else { + return; + }; + cx.background_executor().timer(interval).await; + let polled = this.update(cx, |data, cx| { + if data.is_open() { + data.read(PullRequestPart::Checks, cx); + } + }); + if polled.is_err() { + return; + } + } + })); + } + + /// Run a write through the session service; on failure sets `error` + /// (and notifies), returns the result so callers can roll back. + pub fn write( + this: &Entity, + cx: &mut App, + f: F, + ) -> Task> + where + F: FnOnce(SessionService, PullRequestRef) -> Fut + 'static, + Fut: Future> + 'static, + { + let pr = this.read(cx).pr.clone(); + let data = this.downgrade(); + let service = session_service(cx); + cx.spawn(async move |cx| { + let result = match service { + Some(service) => f(service, pr).await, + None => Err(anyhow::anyhow!("GitHub is not available")), + }; + data.update(cx, |data, cx| { + data.error = result + .as_ref() + .err() + .map(|error| format!("{error:#}").into()); + cx.notify(); + }) + .ok(); + result + }) + } + + /// Read everything anew, past the cache. + pub fn refresh(this: &Entity, cx: &mut App) { + // The core announces each dropped part, which reads it again. + PullRequestData::write(this, cx, |service, pr| async move { + service.pull_request_refresh(pr).await + }) + .detach(); + } + + pub fn dismiss_error(&mut self, cx: &mut Context) { + if self.error.take().is_some() { + cx.notify(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::main_screen::right_panel::pull_request::fixtures::{check, detail_with, repo}; + use github::model::CheckStatus; + use gpui_kit::{AppContext, TestAppContext}; + + fn data(cx: &mut TestAppContext) -> Entity { + cx.update(|cx| cx.new(|cx| PullRequestData::new(repo().pull_request(290), cx))) + } + + #[gpui_kit::test] + fn polled_checks_replace_the_details(cx: &mut TestAppContext) { + let data = data(cx); + data.update(cx, |data, _| { + data.detail = Some(Arc::new(detail_with(290, |d| { + d.checks = vec![check("ci", CheckStatus::Pending)]; + }))); + }); + data.read_with(cx, |data, _| { + assert_eq!(data.checks_state(), Some(ChecksState::Pending)) + }); + data.update(cx, |data, _| { + data.checks = Some(Arc::new(Checks { + state: PullRequestState::Open, + head_sha: None, + checks: vec![check("ci", CheckStatus::Success)], + checks_state: None, + })); + }); + data.read_with(cx, |data, _| { + assert_eq!(data.current_checks().len(), 1); + assert_eq!(data.checks_state(), Some(ChecksState::Passing)); + }); + } + + #[gpui_kit::test] + fn pending_checks_are_polled_sooner(cx: &mut TestAppContext) { + let data = data(cx); + data.read_with(cx, |data, _| assert_eq!(data.poll_interval(), CHECKS_POLL)); + data.update(cx, |data, _| { + data.detail = Some(Arc::new(detail_with(290, |d| { + d.checks = vec![check("ci", CheckStatus::Pending)]; + }))); + }); + data.read_with(cx, |data, _| { + assert_eq!(data.poll_interval(), PENDING_CHECKS_POLL) + }); + } + + #[gpui_kit::test] + fn a_failed_write_shows_until_the_next_success(cx: &mut TestAppContext) { + let data = data(cx); + cx.update(|cx| { + PullRequestData::write(&data, cx, |_, _| async { Ok(()) }).detach(); + }); + cx.run_until_parked(); + // Without a session service nothing reaches GitHub. + let error = data.read_with(cx, |data, _| data.error.clone()); + assert_eq!(error.as_deref(), Some("GitHub is not available")); + data.update(cx, |data, cx| data.dismiss_error(cx)); + data.read_with(cx, |data, _| assert!(data.error.is_none())); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/detail.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/detail.rs new file mode 100644 index 000000000..28aa3d36d --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/detail.rs @@ -0,0 +1,439 @@ +//! One pull request: the header, the tab row and the shown tab's body. +//! What the page knows lives in [`PullRequestData`], which the header and +//! each tab body ([`SummaryTab`], [`TimelineTab`], [`CodeTab`]) observe; +//! the view passes their intents on and carries out the writes on the +//! pull request (actions, updates, refresh). + +use super::PullRequestIntent; +use super::code::CodeTab; +use super::composer::Composer; +use super::data::PullRequestData; +use super::header::DetailHeader; +use super::preferences; +use super::summary::SummaryTab; +use super::tab_bar::{checks_slot, render_tab_row, timeline_slot}; +use super::timeline::{OpenCommit, TimelineTab}; +use github::PullRequestRef; +use github::model::{Action, ListEntry, PullRequestUpdate}; +use gpui_kit::component::{ActiveTheme, Icon, Sizable, button::Button, button::ButtonVariants}; +use gpui_kit::{ + AnyElement, Context, Entity, EventEmitter, ListState, Pixels, Render, SharedString, + Subscription, Task, Window, div, prelude::*, px, +}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum DetailTab { + #[default] + Summary, + Timeline, + Code, +} + +impl DetailTab { + pub const ALL: &[(DetailTab, &'static str)] = &[ + (DetailTab::Summary, "Summary"), + (DetailTab::Timeline, "Timeline"), + (DetailTab::Code, "Code"), + ]; +} + +pub struct DetailView { + pr: PullRequestRef, + data: Entity, + tab: DetailTab, + /// The header is condensed (see [`condenses`]). + condensed: bool, + header: Entity, + summary: Entity, + timeline: Entity, + code: Entity, + composer: Entity, + _subscriptions: Vec, +} + +impl EventEmitter for DetailView {} + +impl DetailView { + /// `seed` is the listing row it was opened from; `show_back` whether + /// there is a list to go back to. Reads the pull request and polls its + /// checks while shown. + pub fn new( + pr: PullRequestRef, + seed: Option, + show_back: bool, + cx: &mut Context, + ) -> Self { + let data = cx.new(|cx| PullRequestData::new(pr.clone(), cx)); + data.update(cx, |data, cx| { + data.load(cx); + data.set_polling(true, cx); + }); + let merge_method = preferences::read(cx).merge_method(&pr.repo); + let header = cx.new(|_| DetailHeader::new(pr.clone(), seed, show_back, merge_method)); + let summary = cx.new(|cx| SummaryTab::new(data.clone(), cx)); + let timeline = cx.new(|_| TimelineTab::new(data.clone())); + let code = cx.new(|cx| CodeTab::new(data.clone(), cx)); + let composer = cx.new(|cx| Composer::new(data.clone(), cx)); + let subscriptions = vec![ + cx.observe(&data, |this, _, cx| this.sync_header(cx)), + cx.subscribe(&header, |_, _, intent: &PullRequestIntent, cx| { + cx.emit(intent.clone()) + }), + cx.subscribe(&summary, |_, _, intent: &PullRequestIntent, cx| { + cx.emit(intent.clone()) + }), + cx.subscribe(&timeline, |_, _, intent: &PullRequestIntent, cx| { + cx.emit(intent.clone()) + }), + // A commit clicked in the timeline: its diff in the Code tab. + cx.subscribe(&timeline, |this, _, OpenCommit(oid): &OpenCommit, cx| { + this.code + .update(cx, |code, cx| code.select_commit(Some(oid.clone()), cx)); + this.select_tab(DetailTab::Code, cx); + }), + cx.subscribe(&code, |_, _, intent: &PullRequestIntent, cx| { + cx.emit(intent.clone()) + }), + // The tab row shows the timeline's order. + cx.observe(&timeline, |_, _, cx| cx.notify()), + ]; + Self { + pr, + data, + tab: DetailTab::default(), + condensed: false, + header, + summary, + timeline, + code, + composer, + _subscriptions: subscriptions, + } + } + + pub fn pr(&self) -> &PullRequestRef { + &self.pr + } + + #[cfg(test)] + pub fn data(&self) -> &Entity { + &self.data + } + + fn sync_header(&mut self, cx: &mut Context) { + let (detail, checks_state, refreshing) = { + let data = self.data.read(cx); + (data.detail.clone(), data.checks_state(), data.refreshing) + }; + self.header.update(cx, |header, cx| { + header.set_data(detail, checks_state, refreshing, cx) + }); + cx.notify(); + } + + /// Shown again (read anew) or hidden (the polling pauses). + pub fn set_visible(&mut self, visible: bool, cx: &mut Context) { + self.data.update(cx, |data, cx| { + if visible { + data.load(cx); + } + data.set_polling(visible, cx); + }); + } + + pub fn select_tab(&mut self, tab: DetailTab, cx: &mut Context) { + if self.tab != tab { + self.tab = tab; + cx.notify(); + } + } + + #[cfg(test)] + pub fn tab(&self) -> DetailTab { + self.tab + } + + /// Merge, close, … the pull request; the primary control waits meanwhile. + /// Whether it went through comes out of the task. + pub fn act(&mut self, action: Action, cx: &mut Context) -> Task { + self.header + .update(cx, |header, cx| header.set_pending(true, cx)); + let write = PullRequestData::write(&self.data, cx, move |service, pr| async move { + service.pull_request_action(pr, action).await + }); + cx.spawn(async move |this, cx| { + let done = write.await.is_ok(); + this.update(cx, |this, cx| { + this.header + .update(cx, |header, cx| header.set_pending(false, cx)) + }) + .ok(); + done + }) + } + + /// Rewrite the title, body or maintainer edits; a refused title is + /// taken back. + pub fn update(&mut self, update: PullRequestUpdate, cx: &mut Context) { + let retitles = update.title.is_some(); + let write = PullRequestData::write(&self.data, cx, move |service, pr| async move { + service.pull_request_update(pr, update).await + }); + cx.spawn(async move |this, cx| { + if write.await.is_err() && retitles { + this.update(cx, |this, cx| { + this.header + .update(cx, |header, cx| header.title_rejected(cx)) + }) + .ok(); + } + }) + .detach(); + } + + pub fn refresh(&mut self, cx: &mut Context) { + PullRequestData::refresh(&self.data, cx); + } + + /// What the tab row shows on its right for the shown tab. + fn tab_slot(&self, cx: &mut Context) -> Option { + match self.tab { + DetailTab::Summary => { + let data = self.data.read(cx); + let checks = data.current_checks().to_vec(); + let state = data.checks_state(); + checks_slot(&checks, state, cx.entity().downgrade(), cx) + } + DetailTab::Timeline => { + let activity = self.data.read(cx).activity.clone(); + Some(timeline_slot(activity.as_deref(), &self.timeline, cx)) + } + DetailTab::Code => None, + } + } + + /// The last failure, until it is dismissed or something succeeds. + fn render_error(&self, error: SharedString, cx: &mut Context) -> AnyElement { + let danger = cx.theme().danger; + div() + .debug_selector(|| "pr-error".into()) + .flex_none() + .px_4() + .py_1p5() + .flex() + .items_center() + .gap_2() + .bg(danger.opacity(0.08)) + .border_b_1() + .border_color(danger.opacity(0.25)) + .text_xs() + .text_color(danger) + .child(div().flex_1().min_w_0().child(error)) + .child( + Button::new("pr-error-dismiss") + .ghost() + .xsmall() + .icon(Icon::default().path("icons/close.svg")) + .on_click(cx.listener(|this, _, _, cx| { + this.data.update(cx, |data, cx| data.dismiss_error(cx)) + })), + ) + .into_any_element() + } +} + +/// How far a tab's list is scrolled, and how far it could be. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TabScroll { + pub offset: Pixels, + pub max: Pixels, +} + +impl TabScroll { + pub fn of(list: &ListState) -> Self { + Self { + offset: -list.scroll_px_offset_for_scrollbar().y, + max: list.max_offset_for_scrollbar().y, + } + } +} + +/// Scrolled this far, the tab's content has left the header's room. +const CONDENSE_AFTER: Pixels = px(24.); +/// More than the header gives up when it condenses (title, meta, branch +/// and behind-by rows become one line). +const CONDENSE_ROOM: Pixels = px(160.); + +/// Whether the header is condensed next, given whether it is now. It +/// condenses past [`CONDENSE_AFTER`] only when the tab could stay scrolled +/// that far in the taller view condensing gives it: otherwise the list +/// clamps its scroll, the header expands, the view shrinks and the two +/// states alternate every frame. Condensed, it stays so until the tab is +/// back at its top. +fn condenses(now: bool, scroll: TabScroll) -> bool { + match now { + true => scroll.offset > px(0.), + false => scroll.offset > CONDENSE_AFTER && scroll.max > CONDENSE_AFTER + CONDENSE_ROOM, + } +} + +impl Render for DetailView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // The header condenses while the shown tab is scrolled; read each + // frame, it costs a field read per tab. + let scroll = match self.tab { + DetailTab::Summary => self.summary.read(cx).scroll(), + DetailTab::Timeline => self.timeline.read(cx).scroll(), + DetailTab::Code => self.code.read(cx).scroll(), + }; + self.condensed = condenses(self.condensed, scroll); + let condensed = self.condensed; + self.header + .update(cx, |header, cx| header.set_condensed(condensed, cx)); + let body = match self.tab { + DetailTab::Summary => self.summary.clone().into_any_element(), + DetailTab::Timeline => self.timeline.clone().into_any_element(), + DetailTab::Code => self.code.clone().into_any_element(), + }; + let error = self.data.read(cx).error.clone(); + let slot = self.tab_slot(cx); + div() + .relative() + .size_full() + .flex() + .flex_col() + .child(self.header.clone()) + .children(error.map(|error| self.render_error(error, cx))) + .child(render_tab_row(self.tab, slot, window, cx)) + .child(div().flex_1().min_h_0().child(body)) + .child( + div() + .absolute() + .bottom_4() + .right_4() + .child(self.composer.clone()), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::main_screen::right_panel::pull_request::conversation::fixtures::{click, commit}; + use crate::main_screen::right_panel::pull_request::fixtures::{detail, hours_ago, repo}; + use github::model::Activity; + use github::model::MergeMethod; + use gpui_kit::{TestAppContext, VisualTestContext}; + use std::sync::Arc; + + /// A detail page for #290 as if read; no session service behind it, + /// so every write fails. + fn page(cx: &mut TestAppContext) -> (Entity, &mut VisualTestContext) { + let window = cx.update(|cx| { + gpui_kit::component::init(cx); + cx.open_window(Default::default(), |_, cx| { + cx.new(|cx| DetailView::new(repo().pull_request(290), None, true, cx)) + }) + .unwrap() + }); + let view = window.root(cx).unwrap(); + let cx = VisualTestContext::from_window(window.into(), cx).into_mut(); + let data = view.read_with(cx, |view, _| view.data().clone()); + data.update(cx, |data, cx| { + data.detail = Some(Arc::new(detail(290))); + cx.notify(); + }); + cx.run_until_parked(); + (view, cx) + } + + fn scrolled(offset: f32, max: f32) -> TabScroll { + TabScroll { + offset: px(offset), + max: px(max), + } + } + + #[test] + fn a_long_tab_condenses_the_header_past_the_fold() { + assert!(!condenses(false, scrolled(20., 800.))); + assert!(condenses(false, scrolled(30., 800.))); + } + + #[test] + fn a_tab_the_taller_view_would_fit_never_condenses() { + // Condensing would give the list more room than it can scroll; its + // clamp would expand the header again, every frame. + assert!(!condenses(false, scrolled(30., 60.))); + assert!(!condenses(false, scrolled(60., 150.))); + } + + #[test] + fn a_condensed_header_stays_until_the_tab_is_back_at_its_top() { + assert!(condenses(true, scrolled(5., 40.))); + assert!(!condenses(true, scrolled(0., 800.))); + } + + #[gpui_kit::test] + fn a_commit_clicked_in_the_timeline_opens_the_code_tab_on_it(cx: &mut TestAppContext) { + let (view, cx) = page(cx); + let data = view.read_with(cx, |view, _| view.data().clone()); + data.update(cx, |data, cx| { + data.activity = Some(Arc::new(Activity { + author: None, + reviewers: Vec::new(), + comments: Vec::new(), + comment_count: 0, + comments_truncated: false, + review_threads: Vec::new(), + review_threads_truncated: false, + commits: vec![commit("d9aea40f", hours_ago(2))], + reactions: Vec::new(), + })); + cx.notify(); + }); + view.update(cx, |view, cx| view.select_tab(DetailTab::Timeline, cx)); + cx.run_until_parked(); + + click(cx, "pr-commit-d9aea40"); + view.read_with(cx, |view, cx| { + assert_eq!(view.tab, DetailTab::Code); + assert_eq!(view.code.read(cx).commit.as_deref(), Some("d9aea40f")); + }); + } + + #[gpui_kit::test] + fn a_failed_action_shows_and_frees_the_controls(cx: &mut TestAppContext) { + let (view, cx) = page(cx); + assert!(cx.debug_bounds("pr-error").is_none()); + view.update(cx, |view, cx| { + view.act(Action::Merge(MergeMethod::Merge), cx).detach() + }); + cx.run_until_parked(); + assert!(cx.debug_bounds("pr-error").is_some()); + let header = view.read_with(cx, |view, _| view.header.clone()); + header.read_with(cx, |header, _| assert!(!header.pending())); + } + + #[gpui_kit::test] + fn a_refused_title_is_taken_back(cx: &mut TestAppContext) { + let (view, cx) = page(cx); + let header = view.read_with(cx, |view, _| view.header.clone()); + header.update(cx, |header, cx| header.save_title("Renamed".into(), cx)); + cx.run_until_parked(); + // The header's intent reached the view as the panel would pass it. + view.update(cx, |view, cx| { + view.update( + PullRequestUpdate { + title: Some("Renamed".into()), + ..PullRequestUpdate::default() + }, + cx, + ) + }); + cx.run_until_parked(); + header.read_with(cx, |header, _| { + assert_eq!(header.shown_title().as_ref(), detail(290).title) + }); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/fixtures.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/fixtures.rs new file mode 100644 index 000000000..05c2a0d0e --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/fixtures.rs @@ -0,0 +1,148 @@ +//! Model values for the pull request view's tests: a plain open pull +//! request each, changed by the test where it matters. + +use chrono::{DateTime, Duration, TimeZone, Utc}; +use github::RepoRef; +use github::model::{ + Actor, BaseComparison, Check, CheckStatus, Detail, ListEntry, MergeCapabilities, Mergeability, + PullRequestAction, PullRequestState, ViewerPermissions, +}; + +/// The fixed "now" relative times are measured from. +pub fn now() -> DateTime { + Utc.with_ymd_and_hms(2026, 10, 30, 12, 0, 0).unwrap() +} + +pub fn hours_ago(hours: i64) -> DateTime { + now() - Duration::hours(hours) +} + +pub fn repo() -> RepoRef { + RepoRef::new("github.com", "stippi", "code-assistant") +} + +pub fn actor(login: &str) -> Actor { + Actor { + login: login.into(), + name: None, + avatar_url: None, + is_bot: false, + } +} + +/// An open, mergeable pull request by `author`, updated `number` hours ago. +pub fn entry(number: u64, title: &str, author: &str) -> ListEntry { + ListEntry { + number, + title: title.into(), + url: format!("https://github.com/stippi/code-assistant/pull/{number}"), + author: Some(actor(author)), + head_branch: format!("feature/{number}"), + base_branch: "main".into(), + state: PullRequestState::Open, + is_draft: false, + mergeability: Mergeability::Mergeable, + review_decision: None, + additions: 0, + deletions: 0, + created_at: hours_ago(100 + number as i64), + updated_at: hours_ago(number as i64), + review_request_logins: Vec::new(), + has_team_review_request: false, + labels: Vec::new(), + checks_state: None, + } +} + +/// `entry` changed by `change`. +pub fn entry_with( + number: u64, + title: &str, + author: &str, + change: impl FnOnce(&mut ListEntry), +) -> ListEntry { + let mut entry = entry(number, title, author); + change(&mut entry); + entry +} + +pub fn check(name: &str, status: CheckStatus) -> Check { + Check { + name: name.into(), + status, + description: None, + url: None, + required: None, + } +} + +/// An open, mergeable pull request the viewer may do everything with. +pub fn detail(number: u64) -> Detail { + Detail { + number, + title: "feat(files): show raster images in the Files view".into(), + body: "## What\n\nImages show as images.".into(), + url: format!("https://github.com/stippi/code-assistant/pull/{number}"), + author: Some(actor("daniel-kurzynski")), + state: PullRequestState::Open, + is_draft: false, + mergeability: Mergeability::Mergeable, + review_decision: None, + additions: 432, + deletions: 46, + changed_files: 6, + head_branch: "feat/image-file-renderer".into(), + head_sha: Some("d9aea40".into()), + head_repository: Some("stippi/code-assistant".into()), + head_repository_owner: Some("stippi".into()), + is_cross_repository: false, + base_branch: "main".into(), + created_at: hours_ago(18), + updated_at: hours_ago(9), + merged_at: None, + closed_at: None, + reviewers: Vec::new(), + labels: Vec::new(), + checks: Vec::new(), + checks_state: None, + checks_truncated: false, + merge_capabilities: MergeCapabilities { + merge: true, + squash: true, + rebase: true, + }, + auto_merge_allowed: true, + viewer_permissions: ViewerPermissions { + actions: vec![ + PullRequestAction::Merge, + PullRequestAction::Ready, + PullRequestAction::Draft, + PullRequestAction::Close, + PullRequestAction::Reopen, + PullRequestAction::DeleteBranch, + PullRequestAction::UpdateBranch, + PullRequestAction::EnableAutoMerge, + PullRequestAction::DisableAutoMerge, + ], + comment: true, + resolve: true, + verdicts: Vec::new(), + request_reviewers: true, + update_methods: Vec::new(), + labels: true, + maintainer_edits: false, + }, + base_comparison: BaseComparison::UpToDate, + behind_by: None, + auto_merge_enabled: false, + auto_merge_method: None, + maintainer_can_modify: false, + } +} + +/// `detail` changed by `change`. +pub fn detail_with(number: u64, change: impl FnOnce(&mut Detail)) -> Detail { + let mut detail = detail(number); + change(&mut detail); + detail +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/header.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/header.rs new file mode 100644 index 000000000..aa8345ea4 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/header.rs @@ -0,0 +1,866 @@ +//! The detail's header: the repository and number (back to the list, open +//! on GitHub), the actions, the title (a click edits it: Enter saves, +//! Escape cancels), author and last update, the checkout command, the +//! branches and the size of the change, and a warning when the branch fell +//! behind its base. +//! +//! Until the detail is read, a listing row (when the pull request was +//! opened from the list) fills in what it knows; the actions wait for the +//! detail. +//! +//! Once the tab below is scrolled, the header condenses: `#N Title` takes +//! the repository's place and one line sums up author, time, branches and +//! the change. + +use super::PullRequestIntent; +use super::actions::{ + self, Confirmable, InlineConfirm, allowed_merge_methods, branch_update, checkout_command, + resolve_merge_method, +}; +use super::avatar::avatar; +use super::preferences; +use super::presentation::{Tone, diff_stat, format_count, relative_time}; +use chrono::{DateTime, Utc}; +use github::PullRequestRef; +use github::model::{ + Action, Actor, ChecksState, Detail, ListEntry, MergeMethod, PullRequestUpdate, UpdateMethod, +}; +use gpui_kit::component::button::{Button, ButtonVariants}; +use gpui_kit::component::input::{Escape, Input, InputEvent, InputState}; +use gpui_kit::component::tooltip::Tooltip; +use gpui_kit::component::{ActiveTheme, Icon, Sizable}; +use gpui_kit::{ + AnyElement, Context, Entity, EventEmitter, FontWeight, Render, SharedString, Subscription, + Task, Window, div, prelude::*, px, svg, +}; +use std::sync::Arc; +use std::time::Duration; + +/// How long an armed control waits for its confirming click. +const CONFIRM_TIMEOUT: Duration = Duration::from_secs(3); + +struct TitleEditor { + input: Entity, + _subscription: Subscription, +} + +/// What the header shows, from the detail or a listing row. +struct Facts { + number: u64, + title: SharedString, + url: String, + author: Option, + tone: Tone, + base: SharedString, + head: SharedString, + additions: u64, + deletions: u64, + changed_files: Option, + updated_at: Option>, +} + +pub struct DetailHeader { + pr: PullRequestRef, + detail: Option>, + checks_state: Option, + /// A newer detail is being read. + refreshing: bool, + /// An action runs. + pending: bool, + seed: Option, + /// The list is there to go back to. + show_back: bool, + title_editor: Option, + /// A saved title, shown until the detail has it. + saved_title: Option, + /// The merge method the viewer chose in the ⋯ menu. + picked_merge_method: Option, + confirm: InlineConfirm, + _confirm_timeout: Option>, + /// The tab below is scrolled. + condensed: bool, + /// The copyable text just copied, ticked for [`COPIED_FOR`]. + copied: Option<&'static str>, + _copied_reset: Option>, +} + +/// How long a copied text shows its tick. +const COPIED_FOR: Duration = Duration::from_millis(1500); + +impl EventEmitter for DetailHeader {} + +impl DetailHeader { + /// `merge_method` is the one last used in the repository. + pub fn new( + pr: PullRequestRef, + seed: Option, + show_back: bool, + merge_method: Option, + ) -> Self { + Self { + pr, + detail: None, + checks_state: None, + refreshing: false, + pending: false, + seed, + show_back, + title_editor: None, + saved_title: None, + picked_merge_method: merge_method, + confirm: InlineConfirm::default(), + _confirm_timeout: None, + condensed: false, + copied: None, + _copied_reset: None, + } + } + + pub fn set_condensed(&mut self, condensed: bool, cx: &mut Context) { + if self.condensed != condensed { + self.condensed = condensed; + cx.notify(); + } + } + + /// What the page read: the detail, the checks' verdict, and whether a + /// newer state is being read. + pub fn set_data( + &mut self, + detail: Option>, + checks_state: Option, + refreshing: bool, + cx: &mut Context, + ) { + if let Some(detail) = &detail + && self.saved_title.as_deref() == Some(detail.title.as_str()) + { + self.saved_title = None; + } + self.detail = detail; + self.checks_state = checks_state; + self.refreshing = refreshing; + cx.notify(); + } + + pub fn checks_state(&self) -> Option { + self.checks_state + } + + #[cfg(test)] + pub fn shown_title(&self) -> SharedString { + self.facts().title + } + + pub fn pending(&self) -> bool { + self.pending + } + + /// While an action runs, the primary control waits. + pub fn set_pending(&mut self, pending: bool, cx: &mut Context) { + self.pending = pending; + cx.notify(); + } + + /// The saved title did not make it: show the detail's again. + pub fn title_rejected(&mut self, cx: &mut Context) { + if self.saved_title.take().is_some() { + cx.notify(); + } + } + + pub fn emit(&mut self, intent: PullRequestIntent, cx: &mut Context) { + cx.emit(intent); + } + + pub fn confirm(&self) -> InlineConfirm { + self.confirm + } + + pub fn merge_method(&self, detail: &Detail) -> MergeMethod { + resolve_merge_method(&allowed_merge_methods(detail), self.picked_merge_method) + } + + /// The method is remembered for the repository. + pub fn pick_merge_method(&mut self, method: MergeMethod, cx: &mut Context) { + self.picked_merge_method = Some(method); + let repo = self.pr.repo.to_string(); + preferences::update(cx, |preferences| { + preferences.merge_methods.insert(repo, method); + }); + cx.notify(); + } + + /// A click on a control taking two: the first arms it for a while, the + /// second acts. + pub fn click_confirmable(&mut self, key: Confirmable, action: Action, cx: &mut Context) { + if self.confirm.click(key) { + self._confirm_timeout = None; + cx.emit(PullRequestIntent::Act(action)); + } else { + self._confirm_timeout = Some(cx.spawn(async move |this, cx| { + cx.background_executor().timer(CONFIRM_TIMEOUT).await; + this.update(cx, |this, cx| this.disarm(cx)).ok(); + })); + } + cx.notify(); + } + + pub fn disarm(&mut self, cx: &mut Context) { + self._confirm_timeout = None; + if self.confirm.disarm() { + cx.notify(); + } + } + + fn facts(&self) -> Facts { + let number = self.pr.number; + if let Some(d) = &self.detail { + return Facts { + number, + title: self.saved_title.clone().unwrap_or(d.title.clone()).into(), + url: d.url.clone(), + author: d.author.clone(), + tone: Tone::of(d.state, d.is_draft), + base: d.base_branch.clone().into(), + head: d.head_branch.clone().into(), + additions: d.additions, + deletions: d.deletions, + changed_files: Some(d.changed_files), + updated_at: Some(d.updated_at), + }; + } + match &self.seed { + Some(e) => Facts { + number, + title: e.title.clone().into(), + url: e.url.clone(), + author: e.author.clone(), + tone: Tone::of(e.state, e.is_draft), + base: e.base_branch.clone().into(), + head: e.head_branch.clone().into(), + additions: e.additions, + deletions: e.deletions, + changed_files: None, + updated_at: Some(e.updated_at), + }, + None => Facts { + number, + title: SharedString::default(), + url: format!( + "https://{}/{}/pull/{number}", + self.pr.repo.host, + self.pr.repo.full_name() + ), + author: None, + tone: Tone::Open, + base: SharedString::default(), + head: SharedString::default(), + additions: 0, + deletions: 0, + changed_files: None, + updated_at: None, + }, + } + } + + fn start_editing(&mut self, title: SharedString, window: &mut Window, cx: &mut Context) { + let input = cx.new(|cx| InputState::new(window, cx).default_value(title)); + input.update(cx, |input, cx| input.focus(window, cx)); + let subscription = + cx.subscribe_in(&input, window, |this, input, event, _, cx| match event { + InputEvent::PressEnter { .. } => { + let title = input.read(cx).value().trim().to_owned(); + this.save_title(title, cx); + } + InputEvent::Blur => this.stop_editing(cx), + _ => {} + }); + self.title_editor = Some(TitleEditor { + input, + _subscription: subscription, + }); + cx.notify(); + } + + pub(super) fn save_title(&mut self, title: String, cx: &mut Context) { + let unchanged = self.facts().title.as_ref() == title; + if !title.is_empty() && !unchanged { + self.saved_title = Some(title.clone()); + cx.emit(PullRequestIntent::Update(PullRequestUpdate { + title: Some(title), + ..PullRequestUpdate::default() + })); + } + self.stop_editing(cx); + } + + fn stop_editing(&mut self, cx: &mut Context) { + if self.title_editor.take().is_some() { + cx.notify(); + } + } + + /// `owner/repo #290 ↗`, the back arrow before them when there is a list. + fn render_crumbs(&self, facts: &Facts, cx: &mut Context) -> impl IntoElement { + let condensed = self.condensed; + let repo = &self.pr.repo; + let repo_url = format!("https://{}/{}", repo.host, repo.full_name()); + let tone = facts.tone.color(cx); + let url = facts.url.clone(); + div() + .flex_1() + .min_w_0() + .flex() + .items_center() + .gap_1() + .text_sm() + .when(self.show_back, |row| { + row.child( + div().debug_selector(|| "pr-back".into()).child( + Button::new("pr-back") + .ghost() + .xsmall() + .icon(Icon::default().path("icons/arrow_left.svg")) + .tooltip("Back to pull requests") + .on_click( + cx.listener(|this, _, _, cx| { + this.emit(PullRequestIntent::Back, cx) + }), + ), + ), + ) + }) + .when(!condensed, |row| { + row.child( + div() + .id("pr-repo") + .min_w_0() + .truncate() + .cursor_pointer() + .text_color(cx.theme().muted_foreground) + .hover(|s| s.underline()) + .child(repo.full_name()) + .on_click(cx.listener(move |this, _, _, cx| { + this.emit(PullRequestIntent::OpenUrl(repo_url.clone()), cx) + })), + ) + }) + .child( + div() + .id("pr-number") + .flex_none() + .flex() + .items_center() + .gap_0p5() + .cursor_pointer() + .font_weight(FontWeight::MEDIUM) + .text_color(tone) + .hover(|s| s.underline()) + .child(format!("#{}", facts.number)) + .child( + svg() + .size(px(11.)) + .path("icons/external_link.svg") + .text_color(tone), + ) + .on_click(cx.listener(move |this, _, _, cx| { + this.emit(PullRequestIntent::OpenUrl(url.clone()), cx) + })), + ) + .when(condensed, |row| { + row.child( + div() + .debug_selector(|| "pr-condensed-title".into()) + .min_w_0() + .truncate() + .font_weight(FontWeight::MEDIUM) + .text_color(cx.theme().foreground) + .child(facts.title.clone()), + ) + }) + } + + /// The condensed header's one line: author, time, branches, change. + fn render_summary_line(&self, facts: &Facts, cx: &mut Context) -> impl IntoElement { + let muted = cx.theme().muted_foreground; + div() + .flex() + .items_center() + .gap_1p5() + .text_xs() + .text_color(muted) + .child(avatar(facts.author.as_ref(), px(14.), cx)) + .child( + facts + .author + .as_ref() + .map_or("ghost".to_string(), |a| a.login.clone()), + ) + .when_some(facts.updated_at, |row, updated| { + row.child(relative_time(updated, Utc::now())) + }) + .when(!facts.base.is_empty(), |row| { + row.child( + div() + .min_w_0() + .truncate() + .font_family(cx.theme().mono_font_family.clone()) + .child(format!("{} ← {}", facts.base, facts.head)), + ) + }) + .child(div().flex_1()) + .when_some(facts.changed_files, |row, files| { + row.child(format!( + "{} {}", + format_count(files), + if files == 1 { "file" } else { "files" } + )) + }) + .children(diff_stat(facts.additions, facts.deletions, cx)) + } + + fn render_title(&self, facts: &Facts, cx: &mut Context) -> AnyElement { + if let Some(editor) = &self.title_editor { + return div() + .capture_action(cx.listener(|this, _: &Escape, _, cx| this.stop_editing(cx))) + .child(Input::new(&editor.input).small()) + .into_any_element(); + } + let title = facts.title.clone(); + let editable = self.detail.is_some(); + div() + .id("pr-title") + .debug_selector(|| "pr-title".into()) + .text_lg() + .font_weight(FontWeight::SEMIBOLD) + .text_color(cx.theme().foreground) + .child(title.clone()) + .when(editable, |el| { + el.cursor_text() + .on_click(cx.listener(move |this, _, window, cx| { + this.start_editing(title.clone(), window, cx) + })) + }) + .into_any_element() + } + + /// Author · updated, and the checkout command. + fn render_meta(&self, facts: &Facts, cx: &mut Context) -> impl IntoElement { + let muted = cx.theme().muted_foreground; + let command = checkout_command(facts.number); + div() + .flex() + .items_center() + .gap_1p5() + .text_sm() + .child(avatar(facts.author.as_ref(), px(18.), cx)) + .child( + div() + .font_weight(FontWeight::MEDIUM) + .text_color(cx.theme().foreground) + .child( + facts + .author + .as_ref() + .map_or("ghost".to_string(), |a| a.login.clone()), + ), + ) + .when_some(facts.updated_at, |row, updated| { + row.child(div().text_color(muted.opacity(0.6)).child("·")) + .child( + div() + .text_color(muted) + .child(format!("updated {}", relative_time(updated, Utc::now()))), + ) + }) + .child(div().flex_1()) + .child(div().flex_none().text_xs().child(self.copyable( + "pr-checkout-command", + command, + "Copy the checkout command", + cx, + ))) + } + + /// Mono `text` that copies itself when clicked; a tick confirms it for + /// a moment. + fn copyable( + &self, + id: &'static str, + text: String, + tooltip: &'static str, + cx: &mut Context, + ) -> impl IntoElement { + let theme = cx.theme(); + div() + .id(id) + .debug_selector(move || id.into()) + .min_w_0() + .flex() + .items_center() + .gap_1() + .cursor_pointer() + .font_family(theme.mono_font_family.clone()) + .text_color(theme.muted_foreground) + .hover(|s| s.text_color(theme.foreground)) + .tooltip(move |window, cx| Tooltip::new(tooltip).build(window, cx)) + .child(div().min_w_0().truncate().child(text.clone())) + .when(self.copied == Some(id), |row| { + row.child( + svg() + .debug_selector(move || format!("{id}-copied")) + .flex_none() + .size(px(12.)) + .path("icons/check.svg") + .text_color(theme.success), + ) + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.emit(PullRequestIntent::Copy(text.clone()), cx); + this.copied = Some(id); + this._copied_reset = Some(cx.spawn(async move |this, cx| { + cx.background_executor().timer(COPIED_FOR).await; + this.update(cx, |this, cx| { + this.copied = None; + cx.notify(); + }) + .ok(); + })); + cx.notify(); + })) + } + + /// `base ← head`, and the files and lines changed. + fn render_branches(&self, facts: &Facts, cx: &mut Context) -> impl IntoElement { + let muted = cx.theme().muted_foreground; + div() + .flex() + .items_center() + .gap_2() + .text_xs() + .text_color(muted) + .child( + div() + .min_w_0() + .flex() + .items_center() + .gap_1p5() + .child( + div() + .flex_none() + .font_family(cx.theme().mono_font_family.clone()) + .child(format!("{} ←", facts.base)), + ) + .child(self.copyable( + "pr-head-branch", + facts.head.to_string(), + "Copy the branch name", + cx, + )), + ) + .child(div().flex_1()) + .when_some(facts.changed_files, |row, files| { + row.child( + div() + .flex_none() + .flex() + .items_center() + .gap_1() + .child( + svg() + .size(px(13.)) + .path("icons/file_diff.svg") + .text_color(muted), + ) + .child(format!( + "{} {}", + format_count(files), + if files == 1 { "file" } else { "files" } + )), + ) + }) + .children(diff_stat(facts.additions, facts.deletions, cx)) + } + + /// "3 commits behind main" with the button that brings the base in. + fn render_branch_update(&self, detail: &Detail, cx: &mut Context) -> Option { + let update = branch_update(detail)?; + let warning = cx.theme().warning; + let behind = match update.behind_by { + Some(1) => "1 commit".to_string(), + Some(n) => format!("{} commits", format_count(n)), + None => "Commits".to_string(), + }; + let method = update.methods.first().copied(); + Some( + div() + .flex() + .items_center() + .gap_1p5() + .text_xs() + .text_color(warning) + .child( + svg() + .size(px(13.)) + .path("icons/triangle_alert.svg") + .text_color(warning), + ) + .child(format!("{behind} behind {}", detail.base_branch)) + .child(div().flex_1()) + .when_some(method, |row, method| { + row.child( + Button::new("pr-update-branch") + .outline() + .xsmall() + .label(match method { + UpdateMethod::Merge => "Update branch", + UpdateMethod::Rebase => "Rebase branch", + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.emit(PullRequestIntent::Act(Action::UpdateBranch(method)), cx) + })), + ) + }) + .into_any_element(), + ) + } +} + +impl Render for DetailHeader { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let facts = self.facts(); + let detail = self.detail.clone(); + let weak = cx.entity().downgrade(); + let actions = detail.as_ref().map(|detail| { + div() + .flex_none() + .flex() + .items_center() + .gap_1() + .child(actions::render_checkout(facts.number, weak.clone())) + .children(actions::render_primary(self, detail, cx)) + .child(actions::render_more_menu( + detail, + self.refreshing, + weak.clone(), + )) + }); + div() + .flex_none() + .px_4() + .pt_2() + .map(|el| if self.condensed { el.pb_2() } else { el.pb_3() }) + .flex() + .flex_col() + .gap_1p5() + .border_b_1() + .border_color(cx.theme().border) + .child( + div() + .h(px(28.)) + .flex() + .items_center() + .gap_2() + .child(self.render_crumbs(&facts, cx)) + .children(actions), + ) + .map(|el| { + if self.condensed && self.title_editor.is_none() { + return el.child(self.render_summary_line(&facts, cx)); + } + el.child(self.render_title(&facts, cx)) + .child(self.render_meta(&facts, cx)) + .when(!facts.base.is_empty(), |el| { + el.child(self.render_branches(&facts, cx)) + }) + .children( + detail + .as_ref() + .and_then(|detail| self.render_branch_update(detail, cx)), + ) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::main_screen::right_panel::pull_request::fixtures::{ + detail, detail_with, entry, repo, + }; + use crate::main_screen::right_panel::pull_request::presentation::effective_checks_state; + use github::model::{ChecksState, PullRequestState}; + use gpui_kit::{TestAppContext, VisualTestContext}; + use std::cell::RefCell; + use std::rc::Rc; + + /// A window showing a header for `detail`, and the intents it emits. + fn header( + detail: Option, + cx: &mut TestAppContext, + ) -> ( + Entity, + Rc>>, + &mut VisualTestContext, + ) { + let window = cx.update(|cx| { + gpui_kit::component::init(cx); + cx.open_window(Default::default(), |_, cx| { + cx.new(|_| DetailHeader::new(repo().pull_request(290), None, true, None)) + }) + .unwrap() + }); + let view = window.root(cx).unwrap(); + let cx = VisualTestContext::from_window(window.into(), cx).into_mut(); + let intents = Rc::new(RefCell::new(Vec::new())); + let sink = intents.clone(); + cx.update(|_, cx| { + cx.subscribe(&view, move |_, intent: &PullRequestIntent, _| { + sink.borrow_mut().push(intent.clone()) + }) + .detach() + }); + if let Some(detail) = detail { + let checks = effective_checks_state(detail.checks_state, &detail.checks); + view.update(cx, |view, cx| { + view.set_data(Some(Arc::new(detail)), checks, false, cx) + }); + } + cx.run_until_parked(); + (view, intents, cx) + } + + fn click(cx: &mut VisualTestContext, selector: &'static str) { + let bounds = cx + .debug_bounds(selector) + .unwrap_or_else(|| panic!("nothing rendered as {selector}")); + cx.simulate_click(bounds.center(), gpui_kit::Modifiers::none()); + cx.run_until_parked(); + } + + #[gpui_kit::test] + fn merging_takes_a_confirming_click(cx: &mut TestAppContext) { + let (view, intents, cx) = header(Some(detail(290)), cx); + click(cx, "pr-primary"); + assert!(intents.borrow().is_empty(), "the first click only arms"); + view.read_with(cx, |view, _| { + assert!(view.confirm().is_armed(Confirmable::Merge)) + }); + click(cx, "pr-primary"); + assert_eq!( + intents.borrow().as_slice(), + [PullRequestIntent::Act(Action::Merge(MergeMethod::Merge))] + ); + } + + #[gpui_kit::test] + fn an_armed_merge_disarms_after_a_while(cx: &mut TestAppContext) { + let (view, intents, cx) = header(Some(detail(290)), cx); + click(cx, "pr-primary"); + cx.executor().advance_clock(CONFIRM_TIMEOUT); + cx.run_until_parked(); + view.read_with(cx, |view, _| { + assert!(!view.confirm().is_armed(Confirmable::Merge)) + }); + click(cx, "pr-primary"); + assert!(intents.borrow().is_empty()); + } + + #[gpui_kit::test] + fn the_merge_uses_the_picked_method(cx: &mut TestAppContext) { + let pending = detail_with(290, |d| d.checks_state = Some(ChecksState::Pending)); + let (view, intents, cx) = header(Some(pending), cx); + view.update(cx, |view, cx| { + view.pick_merge_method(MergeMethod::Squash, cx) + }); + click(cx, "pr-primary"); + click(cx, "pr-primary"); + assert_eq!( + intents.borrow().as_slice(), + [PullRequestIntent::Act(Action::EnableAutoMerge( + MergeMethod::Squash + ))] + ); + } + + #[gpui_kit::test] + fn a_merged_pull_request_shows_a_badge(cx: &mut TestAppContext) { + let merged = detail_with(290, |d| d.state = PullRequestState::Merged); + let (_, intents, cx) = header(Some(merged), cx); + click(cx, "pr-primary"); + assert!(intents.borrow().is_empty(), "a badge does nothing"); + } + + #[gpui_kit::test] + fn a_listing_row_fills_in_until_the_detail_arrives(cx: &mut TestAppContext) { + let (view, _, cx) = header(None, cx); + view.update(cx, |view, cx| { + view.seed = Some(entry(290, "From the list", "ann")); + cx.notify(); + }); + cx.run_until_parked(); + assert!(cx.debug_bounds("pr-title").is_some()); + assert!(cx.debug_bounds("pr-primary").is_none(), "no actions yet"); + view.read_with(cx, |view, _| { + assert_eq!(view.facts().title.as_ref(), "From the list") + }); + } + + #[gpui_kit::test] + fn a_scrolled_tab_condenses_the_header(cx: &mut TestAppContext) { + let (view, _, cx) = header(Some(detail(290)), cx); + assert!(cx.debug_bounds("pr-title").is_some()); + view.update(cx, |view, cx| view.set_condensed(true, cx)); + cx.run_until_parked(); + assert!(cx.debug_bounds("pr-title").is_none()); + assert!(cx.debug_bounds("pr-condensed-title").is_some()); + assert!(cx.debug_bounds("pr-primary").is_some(), "the actions stay"); + } + + #[gpui_kit::test] + fn the_head_branch_copies_its_name_and_ticks_for_a_moment(cx: &mut TestAppContext) { + let (_, intents, cx) = header(Some(detail(290)), cx); + assert!(cx.debug_bounds("pr-head-branch-copied").is_none()); + click(cx, "pr-head-branch"); + assert_eq!( + intents.borrow().as_slice(), + [PullRequestIntent::Copy("feat/image-file-renderer".into())] + ); + assert!(cx.debug_bounds("pr-head-branch-copied").is_some()); + cx.executor().advance_clock(COPIED_FOR); + cx.run_until_parked(); + assert!(cx.debug_bounds("pr-head-branch-copied").is_none()); + } + + #[gpui_kit::test] + fn the_back_arrow_asks_for_the_list(cx: &mut TestAppContext) { + let (_, intents, cx) = header(Some(detail(290)), cx); + click(cx, "pr-back"); + assert_eq!(intents.borrow().as_slice(), [PullRequestIntent::Back]); + } + + #[gpui_kit::test] + fn a_saved_title_is_sent_and_shown_until_the_detail_has_it(cx: &mut TestAppContext) { + let (view, intents, cx) = header(Some(detail(290)), cx); + click(cx, "pr-title"); + view.update(cx, |view, cx| view.save_title("A better title".into(), cx)); + assert_eq!( + intents.borrow().as_slice(), + [PullRequestIntent::Update(PullRequestUpdate { + title: Some("A better title".into()), + ..PullRequestUpdate::default() + })] + ); + view.read_with(cx, |view, _| { + assert!(view.title_editor.is_none()); + assert_eq!(view.facts().title.as_ref(), "A better title"); + }); + + // Refused: the old title is back. + view.update(cx, |view, cx| view.title_rejected(cx)); + view.read_with(cx, |view, _| { + assert_eq!(view.facts().title, detail(290).title.as_str()) + }); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/list.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/list.rs new file mode 100644 index 000000000..bd3558384 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/list.rs @@ -0,0 +1,1421 @@ +//! The repository's pull requests: a search field, the Sort and Filters +//! menus and a refresh button above rows grouped by the viewer's part in +//! them (Authored, Review requested, Others). +//! +//! State and involvement (Filters) and the search's free text go to the +//! host: changing them asks for a new listing ([`PullRequestIntent::List`], +//! the text after a pause in typing). Sorting, the other filters and the +//! search's qualifiers (`label:bug`) work on the loaded rows +//! ([`list_logic`](super::list_logic)). Sort, filters, state and +//! involvement are remembered ([`preferences`](super::preferences)). +//! +//! The next page is asked for when the end of the rows comes near, and line +//! counts for rows that came without them when such rows are drawn (with +//! the list's overdraw as margin). Holding Shift shows each row's quick +//! actions (close and merge take a confirming click); what they do shows at +//! once ([`list_overrides`](super::list_overrides)) and is taken back if +//! GitHub refuses. + +use super::PullRequestIntent; +use super::avatar::avatar; +use super::list_logic::{ + Arrangement, ChecksFilter, DraftFilter, Filters, GroupKey, ReviewFilter, SortMode, arrange, + facets, parse_query, +}; +use super::list_overrides::{ + QuickAction, RowOverride, apply_overrides, merge_stats, override_after_action, quick_actions, + settle_overrides, with_stats, +}; +use super::preferences; +use super::presentation::{ + GLYPH_SIZE, Tone, checks_glyph, conflict_glyph, diff_stat, relative_time, review_glyph, +}; +use chrono::Utc; +use github::RepoRef; +use github::model::{ + Action, DiffStat, Involvement, ListEntry, ListPage, ListQuery, ListState as ListedState, + MergeMethod, +}; +use gpui_kit::component::button::{Button, ButtonVariants}; +use gpui_kit::component::input::{Input, InputEvent, InputState}; +use gpui_kit::component::menu::{DropdownMenu, PopupMenu, PopupMenuItem}; +use gpui_kit::component::{ActiveTheme, Icon, Side, Sizable, Size}; +use gpui_kit::{ + AnyElement, App, Context, Entity, EventEmitter, ListAlignment, ListState, Pixels, Render, + SharedString, Subscription, Task, WeakEntity, Window, div, list, prelude::*, px, svg, +}; +use std::collections::{HashMap, HashSet}; +use std::time::Duration; + +/// Pull requests one listing asks for. +const PAGE_SIZE: u32 = 50; +/// Quiet typing time before the search text goes to the host. +const SEARCH_DEBOUNCE: Duration = Duration::from_millis(400); +const LIST_OVERDRAW: Pixels = px(400.); +/// Authors the Author filter offers at most. +const MAX_AUTHOR_CHOICES: usize = 15; +/// How long a quick action waits for its confirming click. +const CONFIRM_TIMEOUT: Duration = Duration::from_secs(3); + +/// One row of the list. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Row { + Group { + key: GroupKey, + count: usize, + }, + /// An index into the shown entries. + Entry(usize), +} + +pub struct PullRequestList { + repo: Option, + viewer: Option, + /// As GitHub listed them. + entries: Vec, + /// As shown: with overrides and read line counts. + shown: Vec, + overrides: HashMap, + last_token: u64, + /// Line counts read for rows that came without. + stats: HashMap, + stats_asked: HashSet, + /// Rows drawn without counts, to ask for at the end of the frame. + stats_wanted: Vec, + next_cursor: Option, + loading_more: bool, + /// Shift is held: rows show their quick actions. + speed: bool, + armed: Option<(u64, QuickAction)>, + _armed_timeout: Option>, + _flush: Option>, + loaded: bool, + /// The session's repository is still being looked for. + resolving: bool, + refreshing: bool, + error: Option, + state: ListedState, + involvement: Involvement, + sort: SortMode, + filters: Filters, + search: Entity, + /// The search field's text. + query: String, + /// The free text of the last listing asked for. + listed_text: String, + rows: Vec, + list: ListState, + _search_debounce: Option>, + _subscriptions: Vec, +} + +impl EventEmitter for PullRequestList {} + +impl PullRequestList { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let search = cx.new(|cx| { + InputState::new(window, cx).placeholder("Search pull requests, or label:bug") + }); + let subscriptions = vec![cx.subscribe_in(&search, window, Self::on_search_event)]; + let remembered = preferences::read(cx).list; + Self { + repo: None, + viewer: None, + entries: Vec::new(), + shown: Vec::new(), + overrides: HashMap::new(), + last_token: 0, + stats: HashMap::new(), + stats_asked: HashSet::new(), + stats_wanted: Vec::new(), + next_cursor: None, + loading_more: false, + speed: false, + armed: None, + _armed_timeout: None, + _flush: None, + loaded: false, + resolving: false, + refreshing: false, + error: None, + state: remembered.state, + involvement: remembered.involvement, + sort: remembered.sort, + filters: remembered.filters, + search, + query: String::new(), + listed_text: String::new(), + rows: Vec::new(), + list: ListState::new(0, ListAlignment::Top, LIST_OVERDRAW), + _search_debounce: None, + _subscriptions: subscriptions, + } + } + + /// The repository listed; another one drops the loaded rows. + pub fn set_repo(&mut self, repo: Option, cx: &mut Context) { + if self.repo == repo { + return; + } + self.repo = repo; + self.entries.clear(); + self.overrides.clear(); + self.stats.clear(); + self.stats_asked.clear(); + self.next_cursor = None; + self.loaded = false; + self.error = None; + self.rebuild_rows(); + cx.notify(); + } + + /// The signed-in login, which decides the groups. + pub fn set_viewer(&mut self, viewer: Option, cx: &mut Context) { + if self.viewer != viewer { + self.viewer = viewer; + self.rebuild_rows(); + cx.notify(); + } + } + + /// A first page: what is listed now. + pub fn set_page(&mut self, page: ListPage, cx: &mut Context) { + settle_overrides(&mut self.overrides, &page.entries, Utc::now()); + self.entries = page.entries; + self.next_cursor = page.next_cursor; + self.loading_more = false; + self.loaded = true; + self.error = None; + self.rebuild_rows(); + cx.notify(); + } + + /// The next page, after the rows held. + pub fn append_page(&mut self, page: ListPage, cx: &mut Context) { + let held: HashSet = self.entries.iter().map(|e| e.number).collect(); + settle_overrides(&mut self.overrides, &page.entries, Utc::now()); + let before = self.entries.len(); + self.entries.extend( + page.entries + .into_iter() + .filter(|e| !held.contains(&e.number)), + ); + // A page with nothing new (the listing moved under the cursor) ends + // the paging rather than asking again and again. + let grew = self.entries.len() > before; + self.next_cursor = page.next_cursor.filter(|_| grew); + self.loading_more = false; + self.rebuild_rows(); + cx.notify(); + } + + /// Reading the next page failed; it may be asked for again. + pub fn more_failed(&mut self, error: String, cx: &mut Context) { + self.loading_more = false; + self.set_error(Some(error), cx); + } + + /// Line counts read for rows that came without them. + pub fn set_stats(&mut self, stats: &[DiffStat], cx: &mut Context) { + merge_stats(&mut self.stats, stats); + self.rebuild_rows(); + cx.notify(); + } + + /// Show what `action` does to row `number` before GitHub confirms it; + /// the token takes it back ([`Self::revert`]). + pub fn override_row( + &mut self, + number: u64, + action: &Action, + cx: &mut Context, + ) -> Option { + let state = self.entry(number)?.state; + self.last_token += 1; + let token = self.last_token; + let row = override_after_action(state, action.kind(), Utc::now(), token)?; + self.overrides.insert(number, row); + self.rebuild_rows(); + cx.notify(); + Some(token) + } + + /// GitHub refused: the row shows what it listed again (unless a later + /// action overrode it since). + pub fn revert(&mut self, number: u64, token: u64, cx: &mut Context) { + if self + .overrides + .get(&number) + .is_some_and(|o| o.token == token) + { + self.overrides.remove(&number); + self.rebuild_rows(); + cx.notify(); + } + } + + pub fn set_resolving(&mut self, resolving: bool, cx: &mut Context) { + if self.resolving != resolving { + self.resolving = resolving; + cx.notify(); + } + } + + /// A newer listing is being read; the held one stays shown. + pub fn set_refreshing(&mut self, refreshing: bool, cx: &mut Context) { + if self.refreshing != refreshing { + self.refreshing = refreshing; + cx.notify(); + } + } + + pub fn set_error(&mut self, error: Option, cx: &mut Context) { + self.error = error; + cx.notify(); + } + + /// The listing after the rows held, while there is more. + pub fn next_query(&self) -> Option { + let cursor = self.next_cursor.clone()?; + Some(ListQuery { + cursor: Some(cursor), + ..self.query() + }) + } + + /// The listing the current state, involvement and search ask for. + pub fn query(&self) -> ListQuery { + let text = parse_query(&self.query).text; + ListQuery { + state: self.state, + involvement: self.involvement, + text: (!text.trim().is_empty()).then_some(text), + limit: PAGE_SIZE, + cursor: None, + } + } + + /// A row as shown. + pub fn entry(&self, number: u64) -> Option<&ListEntry> { + self.shown + .iter() + .chain(&self.entries) + .find(|e| e.number == number) + } + + fn remember(&self, cx: &mut Context) { + let list = preferences::ListPreferences { + sort: self.sort, + filters: self.filters.clone(), + state: self.state, + involvement: self.involvement, + }; + preferences::update(cx, |preferences| preferences.list = list); + } + + fn set_sort(&mut self, sort: SortMode, cx: &mut Context) { + self.sort = sort; + self.remember(cx); + self.rebuild_rows(); + cx.notify(); + } + + fn set_filters(&mut self, change: impl FnOnce(&mut Filters), cx: &mut Context) { + change(&mut self.filters); + self.remember(cx); + self.rebuild_rows(); + cx.notify(); + } + + fn set_speed(&mut self, speed: bool, cx: &mut Context) { + if self.speed != speed { + self.speed = speed; + if !speed { + self.armed = None; + } + cx.notify(); + } + } + + /// A quick action's click: one taking two arms it first. + fn click_quick(&mut self, number: u64, quick: QuickAction, cx: &mut Context) { + if quick.confirms() && self.armed != Some((number, quick)) { + self.armed = Some((number, quick)); + self._armed_timeout = Some(cx.spawn(async move |this, cx| { + cx.background_executor().timer(CONFIRM_TIMEOUT).await; + this.update(cx, |this, cx| { + this.armed = None; + cx.notify(); + }) + .ok(); + })); + cx.notify(); + return; + } + self.armed = None; + self._armed_timeout = None; + let method = self + .repo + .as_ref() + .and_then(|repo| preferences::read(cx).merge_method(repo)) + .unwrap_or(MergeMethod::Merge); + let action = quick.action(method); + if let Some(token) = self.override_row(number, &action, cx) { + cx.emit(PullRequestIntent::ActOn { + number, + action, + token, + }); + } + } + + /// Ask, after this frame, for what drawing the rows found missing. + fn schedule_flush(&mut self, cx: &mut Context) { + if self._flush.is_none() { + self._flush = Some(cx.spawn(async move |this, cx| { + this.update(cx, |this, cx| this.flush(cx)).ok(); + })); + } + } + + fn flush(&mut self, cx: &mut Context) { + self._flush = None; + let wanted = std::mem::take(&mut self.stats_wanted); + if !wanted.is_empty() { + cx.emit(PullRequestIntent::ListStats(wanted)); + } + if self.loading_more + && let Some(query) = self.next_query() + { + cx.emit(PullRequestIntent::ListMore(query)); + } + } + + fn set_listing( + &mut self, + state: ListedState, + involvement: Involvement, + cx: &mut Context, + ) { + if (self.state, self.involvement) != (state, involvement) { + self.state = state; + self.involvement = involvement; + self.remember(cx); + cx.emit(PullRequestIntent::List(self.query())); + cx.notify(); + } + } + + fn on_search_event( + &mut self, + search: &Entity, + event: &InputEvent, + _window: &mut Window, + cx: &mut Context, + ) { + if !matches!(event, InputEvent::Change) { + return; + } + self.query = search.read(cx).value().to_string(); + self.rebuild_rows(); + cx.notify(); + let text = parse_query(&self.query).text; + if text == self.listed_text { + self._search_debounce = None; + return; + } + self._search_debounce = Some(cx.spawn(async move |this, cx| { + cx.background_executor().timer(SEARCH_DEBOUNCE).await; + this.update(cx, |this, cx| { + this.listed_text = text; + cx.emit(PullRequestIntent::List(this.query())); + }) + .ok(); + })); + } + + fn rebuild_rows(&mut self) { + let repository = self + .repo + .as_ref() + .map(RepoRef::full_name) + .unwrap_or_default(); + self.shown = apply_overrides(&self.entries, &self.overrides, self.state); + with_stats(&mut self.shown, &self.stats); + let index: HashMap = self + .shown + .iter() + .enumerate() + .map(|(i, e)| (e.number, i)) + .collect(); + let groups = arrange( + &self.shown, + &Arrangement { + sort: self.sort, + query: &self.query, + menu_filters: &self.filters, + involvement: self.involvement, + viewer: self.viewer.as_deref(), + repository: &repository, + }, + ); + self.rows = groups + .iter() + .flat_map(|group| { + std::iter::once(Row::Group { + key: group.key, + count: group.entries.len(), + }) + .chain(group.entries.iter().map(|e| Row::Entry(index[&e.number]))) + }) + .collect(); + self.list.reset(self.rows.len()); + } + + /// Whether row `number` is shown. + #[cfg(test)] + pub fn shows(&self, number: u64) -> bool { + self.rows + .iter() + .any(|row| matches!(row, Row::Entry(i) if self.shown[*i].number == number)) + } + + #[cfg(test)] + fn shown(&self) -> Vec { + self.rows + .iter() + .map(|row| match row { + Row::Group { key, count } => format!("{} {count}", key.label()), + Row::Entry(i) => format!("#{}", self.shown[*i].number), + }) + .collect() + } + + /// Drawing a row is what asks for its line counts and, near the end, + /// for the next page. + fn render_row(&mut self, ix: usize, cx: &mut Context) -> AnyElement { + if let Some(Row::Entry(i)) = self.rows.get(ix) { + let entry = &self.shown[*i]; + if entry.additions == 0 && entry.deletions == 0 && self.stats_asked.insert(entry.number) + { + self.stats_wanted.push(entry.number); + self.schedule_flush(cx); + } + } + if ix + 1 >= self.rows.len() && self.next_cursor.is_some() && !self.loading_more { + self.loading_more = true; + self.schedule_flush(cx); + } + match self.rows.get(ix) { + Some(Row::Group { key, count }) => render_group_header(*key, *count, cx), + Some(Row::Entry(i)) => self.render_entry(*i, cx), + None => div().into_any_element(), + } + } + + fn render_entry(&self, index: usize, cx: &mut Context) -> AnyElement { + let entry = &self.shown[index]; + let theme = cx.theme(); + let muted = theme.muted_foreground; + let number = entry.number; + let tone = Tone::of(entry.state, entry.is_draft); + let conflict = conflict_glyph( + entry.state, + entry.is_draft, + entry.mergeability, + &entry.base_branch, + cx, + ); + let glyph = div() + .relative() + .flex_none() + .mt(px(2.)) + .child( + svg() + .size(px(16.)) + .path(tone.icon()) + .text_color(tone.color(cx)), + ) + .when_some(conflict, |el, conflict| { + el.child( + div() + .absolute() + .right(px(-4.)) + .bottom(px(-4.)) + .rounded_full() + .bg(cx.theme().background) + .child(conflict.render(px(10.))), + ) + }); + let signals = div() + .flex_none() + .flex() + .items_center() + .gap_1() + .children( + entry + .checks_state + .map(|s| checks_glyph(s, cx).render(GLYPH_SIZE)), + ) + .children( + entry + .review_decision + .map(|d| review_glyph(d, cx).render(GLYPH_SIZE)), + ); + let line_one = div() + .flex() + .items_center() + .gap_1p5() + .child( + div() + .flex_none() + .font_family(theme.mono_font_family.clone()) + .text_xs() + .text_color(muted) + .child(format!("#{number}")), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_sm() + .text_color(theme.foreground) + .child(entry.title.clone()), + ) + .child(signals) + .child(div().flex_none().text_xs().children(diff_stat( + entry.additions, + entry.deletions, + cx, + ))) + .when(self.speed, |line| { + line.child(self.render_quick_actions(entry, cx)) + }); + let line_two = div() + .flex() + .items_center() + .gap_1p5() + .text_xs() + .text_color(muted) + .child(avatar(entry.author.as_ref(), px(14.), cx)) + .child( + div().min_w_0().truncate().child( + entry + .author + .as_ref() + .map_or("ghost".to_string(), |a| a.login.clone()), + ), + ) + .children( + self.repo + .as_ref() + .map(|repo| div().min_w_0().truncate().child(repo.full_name())), + ) + .child(div().flex_1()) + .child( + div() + .flex_none() + .child(relative_time(entry.updated_at, Utc::now())), + ); + div() + .px_2() + .py_0p5() + .child( + div() + .id(("pr-row", number)) + .debug_selector(move || format!("pr-row-{number}")) + .px_2() + .py_1p5() + .flex() + .gap_2() + .rounded_md() + .cursor_pointer() + .hover(|s| s.bg(cx.theme().muted.opacity(0.6))) + .child(glyph) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .gap_0p5() + .child(line_one) + .child(line_two), + ) + .on_click( + cx.listener(move |_, _, _, cx| cx.emit(PullRequestIntent::Open(number))), + ), + ) + .into_any_element() + } + + /// The row's quick actions, while Shift is held. + fn render_quick_actions(&self, entry: &ListEntry, cx: &mut Context) -> AnyElement { + let number = entry.number; + div() + .flex_none() + .flex() + .gap_1() + .children( + quick_actions(entry.state, entry.is_draft) + .iter() + .map(|&quick| { + let armed = self.armed == Some((number, quick)); + let label = if armed { + format!("Confirm {}?", quick.label().to_lowercase()) + } else { + quick.label().to_string() + }; + let button = Button::new(SharedString::from(format!( + "pr-quick-{number}-{}", + quick.label() + ))) + .xsmall() + .label(label) + .on_click(cx.listener(move |this, _, _, cx| { + cx.stop_propagation(); + this.click_quick(number, quick, cx) + })); + let button = match quick { + QuickAction::Close => button.outline().danger(), + QuickAction::Merge => button.primary(), + QuickAction::Ready | QuickAction::Reopen => button.outline(), + }; + div() + .debug_selector(move || format!("pr-quick-{number}-{}", quick.label())) + .child(button) + }), + ) + .into_any_element() + } + + /// Search field, then Sort, Filters and refresh. + fn render_toolbar(&self, cx: &mut Context) -> impl IntoElement { + let list = cx.entity().downgrade(); + let sort_list = list.clone(); + div() + .flex_none() + .px_4() + .pt_3() + .pb_2() + .flex() + .flex_col() + .gap_2() + .child( + Input::new(&self.search) + .with_size(Size::Small) + .prefix( + Icon::default() + .path(SharedString::from("icons/magnifying_glass.svg")) + .with_size(Size::XSmall) + .text_color(cx.theme().muted_foreground), + ) + .cleanable(true), + ) + .child( + div() + .flex() + .items_center() + .gap_1p5() + .child( + Button::new("pr-sort") + .outline() + .small() + .icon(Icon::default().path("icons/arrow_up_down.svg")) + .label("Sort") + .dropdown_menu(move |menu, _, cx| sort_menu(menu, &sort_list, cx)), + ) + .child( + Button::new("pr-filters") + .outline() + .small() + .icon(Icon::default().path("icons/list_filter.svg")) + .label("Filters") + .dropdown_menu(move |menu, window, cx| { + filters_menu(menu, &list, window, cx) + }), + ) + .child( + div().debug_selector(|| "pr-list-refresh".into()).child( + Button::new("pr-list-refresh") + .outline() + .small() + .icon(Icon::default().path("icons/rotate_cw.svg")) + .loading(self.refreshing) + .tooltip("Refresh") + .on_click( + cx.listener(|_, _, _, cx| cx.emit(PullRequestIntent::Refresh)), + ), + ), + ), + ) + } + + /// What shows instead of rows. + fn render_note(&self, cx: &App) -> Option { + let text = if let Some(error) = &self.error { + error.clone() + } else if self.repo.is_none() && self.resolving { + "Looking for the session's repository…".into() + } else if self.repo.is_none() { + "No GitHub repository for this session.".into() + } else if !self.loaded { + "Loading pull requests…".into() + } else if self.rows.is_empty() { + "No pull requests match.".into() + } else { + return None; + }; + Some( + div() + .px_4() + .py_3() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(text) + .into_any_element(), + ) + } +} + +fn render_group_header(key: GroupKey, count: usize, cx: &App) -> AnyElement { + let muted = cx.theme().muted_foreground; + let icon = match key { + GroupKey::Authored => "icons/pen_line.svg", + GroupKey::ReviewRequested => "icons/eye.svg", + GroupKey::Others => "icons/users.svg", + }; + div() + .px_4() + .pt_2() + .pb_1() + .flex() + .items_center() + .gap_1p5() + .text_xs() + .text_color(muted) + .child(svg().size(px(13.)).path(icon).text_color(muted)) + .child(key.label()) + .child( + div() + .text_color(muted.opacity(0.7)) + .child(count.to_string()), + ) + .child(div().flex_1().h(px(1.)).ml_1().bg(cx.theme().border)) + .into_any_element() +} + +fn icon(path: &'static str) -> Icon { + Icon::default().path(SharedString::from(path)) +} + +fn sort_icon(sort: SortMode) -> &'static str { + match sort { + SortMode::MergeReadiness => "icons/list_checks.svg", + SortMode::BlockedOnMe => "icons/user_lock.svg", + SortMode::RecentlyUpdated => "icons/clock.svg", + SortMode::Newest => "icons/calendar_arrow_down.svg", + SortMode::Oldest => "icons/calendar_arrow_up.svg", + SortMode::Largest => "icons/maximize_2.svg", + SortMode::Smallest => "icons/minimize_2.svg", + } +} + +fn sort_menu(menu: PopupMenu, list: &WeakEntity, cx: &App) -> PopupMenu { + let current = list.upgrade().map(|l| l.read(cx).sort); + SortMode::ALL + .into_iter() + .fold(menu.check_side(Side::Right), |menu, sort| { + let list = list.clone(); + menu.item( + PopupMenuItem::new(sort.label()) + .icon(icon(sort_icon(sort))) + .checked(current == Some(sort)) + .on_click(move |_, _, cx| { + list.update(cx, |l, cx| l.set_sort(sort, cx)).ok(); + }), + ) + }) +} + +/// A submenu of choices, the current one checked. +fn choices( + menu: PopupMenu, + list: &WeakEntity, + current: T, + options: &[(T, &'static str, &'static str)], + pick: fn(&mut PullRequestList, T, &mut Context), +) -> PopupMenu { + options.iter().fold( + menu.check_side(Side::Right), + |menu, &(value, label, path)| { + let list = list.clone(); + menu.item( + PopupMenuItem::new(label) + .icon(icon(path)) + .checked(value == current) + .on_click(move |_, _, cx| { + list.update(cx, |l, cx| pick(l, value, cx)).ok(); + }), + ) + }, + ) +} + +const STATES: &[(ListedState, &str, &str)] = &[ + (ListedState::All, "All", "icons/layers.svg"), + (ListedState::Open, "Open", "icons/git_pull_request.svg"), + ( + ListedState::Closed, + "Closed", + "icons/git_pull_request_closed.svg", + ), + (ListedState::Merged, "Merged", "icons/git_merge.svg"), +]; + +const INVOLVEMENTS: &[(Involvement, &str, &str)] = &[ + (Involvement::All, "All", "icons/layers.svg"), + (Involvement::ReviewRequested, "Reviewing", "icons/eye.svg"), + (Involvement::Authored, "Authored", "icons/pen_line.svg"), +]; + +const DRAFTS: &[(Option, &str, &str)] = &[ + (None, "All", "icons/layers.svg"), + ( + Some(DraftFilter::Only), + "Drafts only", + "icons/git_pull_request_draft.svg", + ), + (Some(DraftFilter::Hide), "Hide drafts", "icons/eye_off.svg"), +]; + +const REVIEWS: &[(Option, &str, &str)] = &[ + (None, "All", "icons/layers.svg"), + ( + Some(ReviewFilter::Approved), + "Approved", + "icons/circle_check.svg", + ), + ( + Some(ReviewFilter::ChangesRequested), + "Changes requested", + "icons/circle_x.svg", + ), + ( + Some(ReviewFilter::ReviewRequired), + "Review required", + "icons/circle_dashed.svg", + ), + ( + Some(ReviewFilter::None), + "No reviews", + "icons/circle_slash.svg", + ), +]; + +const CHECKS: &[(Option, &str, &str)] = &[ + (None, "All", "icons/layers.svg"), + ( + Some(ChecksFilter::Passing), + "Passing", + "icons/circle_check.svg", + ), + (Some(ChecksFilter::Failing), "Failing", "icons/circle_x.svg"), +]; + +fn label_of(options: &[(T, &'static str, &'static str)], value: &T) -> &'static str { + options + .iter() + .find(|(v, _, _)| v == value) + .map_or("", |(_, label, _)| label) +} + +fn filters_menu( + menu: PopupMenu, + list: &WeakEntity, + window: &mut Window, + cx: &mut Context, +) -> PopupMenu { + let Some(this) = list.upgrade() else { + return menu; + }; + let this = this.read(cx); + let (state, involvement) = (this.state, this.involvement); + let filters = this.filters.clone(); + let viewer = this.viewer.clone(); + let facets = facets(&this.entries); + + let author_value = filters.author.clone().unwrap_or("Anyone".into()); + let labels_value = match filters.labels.first().map(Vec::len) { + None | Some(0) => "Any".to_string(), + Some(1) => filters.labels[0][0].clone(), + Some(n) => format!("{n} labels"), + }; + let submenu_label = |name: &str, value: &str| format!("{name}: {value}"); + + let l = list.clone(); + let menu = menu.min_w(px(220.)).submenu_with_icon( + Some(icon("icons/git_pull_request.svg")), + submenu_label("State", label_of(STATES, &state)), + window, + cx, + move |menu, _, _| { + choices(menu, &l, state, STATES, |l, state, cx| { + let involvement = l.involvement; + l.set_listing(state, involvement, cx) + }) + }, + ); + let l = list.clone(); + let menu = menu.submenu_with_icon( + Some(icon("icons/layers.svg")), + submenu_label("Involvement", label_of(INVOLVEMENTS, &involvement)), + window, + cx, + move |menu, _, _| { + choices(menu, &l, involvement, INVOLVEMENTS, |l, involvement, cx| { + let state = l.state; + l.set_listing(state, involvement, cx) + }) + }, + ); + + let l = list.clone(); + let current_author = filters.author.clone(); + let menu = menu.separator().submenu_with_icon( + Some(icon("icons/user_round.svg")), + submenu_label("Author", &author_value), + window, + cx, + move |menu, _, _| { + let mut logins: Vec<(Option, String)> = vec![(None, "Anyone".into())]; + if viewer.is_some() { + logins.push((Some("@me".into()), "Me".into())); + } + logins.extend( + facets + .authors + .iter() + .take(MAX_AUTHOR_CHOICES) + .map(|(login, count)| (Some(login.clone()), format!("{login} {count}"))), + ); + logins + .into_iter() + .fold(menu.check_side(Side::Right), |menu, (author, label)| { + let l = l.clone(); + let checked = author == current_author; + menu.item(PopupMenuItem::new(label).checked(checked).on_click( + move |_, _, cx| { + let author = author.clone(); + l.update(cx, |l, cx| l.set_filters(|f| f.author = author, cx)) + .ok(); + }, + )) + }) + }, + ); + + let l = list.clone(); + let selected: Vec = filters.labels.first().cloned().unwrap_or_default(); + let labels = facets.labels.clone(); + let menu = menu.submenu_with_icon( + Some(icon("icons/tag.svg")), + submenu_label("Labels", &labels_value), + window, + cx, + move |menu, _, _| { + let any = { + let l = l.clone(); + PopupMenuItem::new("Any") + .checked(selected.is_empty()) + .on_click(move |_, _, cx| { + l.update(cx, |l, cx| l.set_filters(|f| f.labels.clear(), cx)) + .ok(); + }) + }; + labels.iter().fold( + menu.check_side(Side::Right).item(any), + |menu, (label, count)| { + let l = l.clone(); + let name = label.name.clone(); + let checked = selected.iter().any(|s| s.eq_ignore_ascii_case(&name)); + menu.item( + PopupMenuItem::new(format!("{name} {count}")) + .checked(checked) + .on_click(move |_, _, cx| { + let name = name.clone(); + l.update(cx, |l, cx| l.set_filters(|f| toggle_label(f, name), cx)) + .ok(); + }), + ) + }, + ) + }, + ); + + let (draft, review, checks) = (filters.draft, filters.review, filters.checks); + let l = list.clone(); + let menu = menu.submenu_with_icon( + Some(icon("icons/git_pull_request_draft.svg")), + submenu_label("Draft", label_of(DRAFTS, &draft)), + window, + cx, + move |menu, _, _| { + choices(menu, &l, draft, DRAFTS, |l, draft, cx| { + l.set_filters(|f| f.draft = draft, cx) + }) + }, + ); + let l = list.clone(); + let menu = menu.submenu_with_icon( + Some(icon("icons/user_check.svg")), + submenu_label("Review", label_of(REVIEWS, &review)), + window, + cx, + move |menu, _, _| { + choices(menu, &l, review, REVIEWS, |l, review, cx| { + l.set_filters(|f| f.review = review, cx) + }) + }, + ); + let l = list.clone(); + menu.submenu_with_icon( + Some(icon("icons/circle_check.svg")), + submenu_label("Checks", label_of(CHECKS, &checks)), + window, + cx, + move |menu, _, _| { + choices(menu, &l, checks, CHECKS, |l, checks, cx| { + l.set_filters(|f| f.checks = checks, cx) + }) + }, + ) +} + +/// Labels picked in the menu form one group: any of them matches. +fn toggle_label(filters: &mut Filters, name: String) { + let group = match filters.labels.first_mut() { + Some(group) => group, + None => { + filters.labels.push(Vec::new()); + &mut filters.labels[0] + } + }; + match group + .iter() + .position(|held| held.eq_ignore_ascii_case(&name)) + { + Some(at) => { + group.remove(at); + } + None => group.push(name), + } + if group.is_empty() { + filters.labels.clear(); + } +} + +impl Render for PullRequestList { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let note = self.render_note(cx); + div() + .size_full() + .flex() + .flex_col() + // Shift reveals the quick actions, while the list has focus or + // the mouse moves over it. + .on_modifiers_changed(cx.listener( + |this, event: &gpui_kit::ModifiersChangedEvent, _, cx| { + this.set_speed(event.modifiers.shift, cx) + }, + )) + .on_mouse_move( + cx.listener(|this, event: &gpui_kit::MouseMoveEvent, _, cx| { + this.set_speed(event.modifiers.shift, cx) + }), + ) + .child(self.render_toolbar(cx)) + .children(note) + .child( + div().flex_1().min_h_0().child( + list( + self.list.clone(), + cx.processor(|this: &mut Self, ix: usize, _, cx| this.render_row(ix, cx)), + ) + .size_full(), + ), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::main_screen::right_panel::pull_request::fixtures::{entry, entry_with, repo}; + use gpui_kit::{TestAppContext, VisualTestContext}; + use std::cell::RefCell; + use std::rc::Rc; + + fn list_view( + cx: &mut TestAppContext, + ) -> ( + Entity, + Rc>>, + &mut VisualTestContext, + ) { + let window = cx.update(|cx| { + gpui_kit::component::init(cx); + cx.open_window(Default::default(), |window, cx| { + cx.new(|cx| PullRequestList::new(window, cx)) + }) + .unwrap() + }); + let view = window.root(cx).unwrap(); + let cx = VisualTestContext::from_window(window.into(), cx).into_mut(); + let intents = Rc::new(RefCell::new(Vec::new())); + let sink = intents.clone(); + cx.update(|_, cx| { + cx.subscribe(&view, move |_, intent: &PullRequestIntent, _| { + sink.borrow_mut().push(intent.clone()) + }) + .detach() + }); + view.update(cx, |view, cx| { + view.set_repo(Some(repo()), cx); + view.set_viewer(Some("me".into()), cx); + view.set_page( + ListPage { + entries: vec![ + entry(281, "feat(sub-agents): one kind of sub-agent", "ann"), + entry_with(107, "feat(execute_command): gate RTK", "bob", |e| { + e.review_request_logins = vec!["me".into()] + }), + entry_with(286, "Custom file renderers", "me", |e| { + e.additions = 3488; + e.deletions = 146; + }), + ], + next_cursor: None, + }, + cx, + ); + }); + cx.run_until_parked(); + // Drawing the rows asked for their line counts; tests start after. + intents.borrow_mut().clear(); + (view, intents, cx) + } + + fn page(entries: Vec, next_cursor: Option<&str>) -> ListPage { + ListPage { + entries, + next_cursor: next_cursor.map(str::to_owned), + } + } + + #[gpui_kit::test] + fn drawn_rows_without_line_counts_ask_for_them_once(cx: &mut TestAppContext) { + let (view, intents, cx) = list_view(cx); + view.update(cx, |view, cx| { + view.set_page(page(vec![entry(400, "new", "ann")], None), cx) + }); + cx.run_until_parked(); + assert_eq!( + intents.borrow().as_slice(), + [PullRequestIntent::ListStats(vec![400])] + ); + let stat = DiffStat { + pr: repo().pull_request(400), + additions: 7, + deletions: 2, + }; + view.update(cx, |view, cx| view.set_stats(&[stat], cx)); + cx.run_until_parked(); + view.read_with(cx, |view, _| { + let shown = view.entry(400).unwrap(); + assert_eq!((shown.additions, shown.deletions), (7, 2)); + }); + assert_eq!(intents.borrow().len(), 1, "asked once"); + } + + #[gpui_kit::test] + fn the_end_of_the_rows_asks_for_the_next_page(cx: &mut TestAppContext) { + let (view, intents, cx) = list_view(cx); + view.update(cx, |view, cx| { + view.set_page(page(vec![entry(1, "a", "ann")], Some("c2")), cx) + }); + cx.run_until_parked(); + let expected = ListQuery { + cursor: Some("c2".into()), + limit: PAGE_SIZE, + ..ListQuery::default() + }; + assert!( + intents + .borrow() + .contains(&PullRequestIntent::ListMore(expected)) + ); + view.update(cx, |view, cx| { + view.append_page( + page(vec![entry(1, "a", "ann"), entry(2, "b", "ann")], None), + cx, + ) + }); + cx.run_until_parked(); + view.read_with(cx, |view, _| { + assert_eq!(view.shown(), ["Others 2", "#1", "#2"]) + }); + let more = intents + .borrow() + .iter() + .filter(|i| matches!(i, PullRequestIntent::ListMore(_))) + .count(); + assert_eq!(more, 1, "nothing more to read"); + + // A page bringing nothing new ends the paging even with a cursor. + view.update(cx, |view, cx| { + view.set_page(page(vec![entry(1, "a", "ann")], Some("c2")), cx); + view.append_page(page(vec![entry(1, "a", "ann")], Some("c3")), cx); + }); + view.read_with(cx, |view, _| assert_eq!(view.next_query(), None)); + } + + #[gpui_kit::test] + fn a_quick_close_shows_at_once_and_is_taken_back_when_refused(cx: &mut TestAppContext) { + let (view, intents, cx) = list_view(cx); + view.update(cx, |view, cx| view.set_speed(true, cx)); + cx.run_until_parked(); + let click = |cx: &mut VisualTestContext| { + let bounds = cx.debug_bounds("pr-quick-281-Close").unwrap(); + cx.simulate_click(bounds.center(), gpui_kit::Modifiers::none()); + cx.run_until_parked(); + }; + click(cx); + assert!( + intents.borrow().is_empty(), + "closing takes a confirming click" + ); + click(cx); + let token = match intents.borrow().as_slice() { + [ + PullRequestIntent::ActOn { + number: 281, + action: Action::Close, + token, + }, + ] => *token, + other => panic!("unexpected {other:?}"), + }; + view.read_with(cx, |view, _| { + assert!( + !view.shown().contains(&"#281".to_string()), + "gone from Open" + ) + }); + view.update(cx, |view, cx| view.revert(281, token, cx)); + view.read_with(cx, |view, _| { + assert!(view.shown().contains(&"#281".to_string())) + }); + } + + #[gpui_kit::test] + fn the_list_starts_as_it_was_left(cx: &mut TestAppContext) { + cx.update(|cx| { + let mut settings = crate::shared::settings::UiSettings::default(); + settings.pull_requests.list.sort = SortMode::Newest; + settings.pull_requests.list.state = ListedState::Merged; + cx.set_global(crate::UiSettingsGlobal(settings)); + }); + let (view, _, cx) = list_view(cx); + view.read_with(cx, |view, _| { + assert_eq!(view.sort, SortMode::Newest); + assert_eq!(view.query().state, ListedState::Merged); + }); + } + + #[gpui_kit::test] + fn rows_show_in_their_groups(cx: &mut TestAppContext) { + let (view, _, cx) = list_view(cx); + view.read_with(cx, |view, _| { + assert_eq!( + view.shown(), + [ + "Authored 1", + "#286", + "Review requested 1", + "#107", + "Others 1", + "#281" + ] + ) + }); + assert!(cx.debug_bounds("pr-row-281").is_some(), "rows are drawn"); + } + + #[gpui_kit::test] + fn a_click_on_a_row_opens_it(cx: &mut TestAppContext) { + let (_, intents, cx) = list_view(cx); + let bounds = cx.debug_bounds("pr-row-107").unwrap(); + cx.simulate_click(bounds.center(), gpui_kit::Modifiers::none()); + assert_eq!(intents.borrow().as_slice(), [PullRequestIntent::Open(107)]); + } + + #[gpui_kit::test] + fn qualifiers_filter_at_once_and_text_lists_after_a_pause(cx: &mut TestAppContext) { + let (view, intents, cx) = list_view(cx); + view.update_in(cx, |view, window, cx| { + view.search.update(cx, |s, cx| s.focus(window, cx)) + }); + cx.simulate_input("author:ann"); + cx.run_until_parked(); + view.read_with(cx, |view, _| assert_eq!(view.shown(), ["Others 1", "#281"])); + assert!(intents.borrow().is_empty(), "a qualifier stays here"); + + // Setting the value says nothing; typing does. + view.update_in(cx, |view, window, cx| { + view.search.update(cx, |s, cx| s.set_value("", window, cx)) + }); + cx.simulate_input("renderers"); + cx.run_until_parked(); + assert!(intents.borrow().is_empty(), "waits for a pause"); + cx.executor().advance_clock(SEARCH_DEBOUNCE); + cx.run_until_parked(); + assert_eq!( + intents.borrow().as_slice(), + [PullRequestIntent::List(ListQuery { + text: Some("renderers".into()), + limit: PAGE_SIZE, + ..ListQuery::default() + })] + ); + } + + #[gpui_kit::test] + fn another_state_asks_for_another_listing(cx: &mut TestAppContext) { + let (view, intents, cx) = list_view(cx); + view.update(cx, |view, cx| { + view.set_listing(ListedState::Merged, Involvement::All, cx) + }); + let query = view.read_with(cx, |view, _| view.query()); + assert_eq!(query.state, ListedState::Merged); + assert_eq!( + intents.borrow().as_slice(), + [PullRequestIntent::List(query)] + ); + } + + #[test] + fn menu_labels_form_one_group() { + let mut filters = Filters::default(); + toggle_label(&mut filters, "bug".into()); + toggle_label(&mut filters, "ui".into()); + assert_eq!( + filters.labels, + vec![vec!["bug".to_string(), "ui".to_string()]] + ); + toggle_label(&mut filters, "BUG".into()); + toggle_label(&mut filters, "ui".into()); + assert!(filters.labels.is_empty()); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/list_logic.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/list_logic.rs new file mode 100644 index 000000000..8d2d65d9a --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/list_logic.rs @@ -0,0 +1,964 @@ +//! How the list arranges a repository's pull requests, apart from any view: +//! what the search field's qualifiers mean, which rows the filters keep, +//! how rows group by the viewer's part in them and how each group sorts. +//! Ported from T3 Code's `pullRequestList.logic.ts` and `pullRequestQuery.ts`. +//! +//! State and involvement narrow the listing on the host ([`ListQuery`]); +//! the free text is searched there too, so a row may match by its +//! description and is only ranked here, never dropped. Everything else +//! (`label:`, `author:`, `draft:`, `review:`, `checks:` and the Filters +//! menu) filters the loaded rows. +//! +//! [`ListQuery`]: github::model::ListQuery + +use github::model::{ + ChecksState, Involvement, Label, ListEntry, Mergeability, PullRequestState, ReviewDecision, +}; +use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; + +/// Qualifier values (labels in one `label:`, label groups) read at most. +const MAX_QUALIFIER_VALUES: usize = 10; +/// Characters of one qualifier value kept. +const MAX_QUALIFIER_LENGTH: usize = 200; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SortMode { + #[default] + MergeReadiness, + BlockedOnMe, + RecentlyUpdated, + Newest, + Oldest, + Largest, + Smallest, +} + +impl SortMode { + pub const ALL: [SortMode; 7] = [ + SortMode::MergeReadiness, + SortMode::BlockedOnMe, + SortMode::RecentlyUpdated, + SortMode::Newest, + SortMode::Oldest, + SortMode::Largest, + SortMode::Smallest, + ]; + + pub fn label(self) -> &'static str { + match self { + SortMode::MergeReadiness => "Merge readiness", + SortMode::BlockedOnMe => "Blocked on me", + SortMode::RecentlyUpdated => "Recently updated", + SortMode::Newest => "Newest shown", + SortMode::Oldest => "Oldest shown", + SortMode::Largest => "Largest shown", + SortMode::Smallest => "Smallest shown", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum DraftFilter { + Only, + Hide, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ReviewFilter { + Approved, + ChangesRequested, + ReviewRequired, + /// No review decision at all. + None, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ChecksFilter { + Passing, + Failing, +} + +/// What the loaded rows are narrowed to, from the Filters menu or typed +/// qualifiers. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Filters { + pub draft: Option, + pub review: Option, + pub checks: Option, + /// Every group must match; any label of a group suffices. + pub labels: Vec>, + pub excluded_labels: Vec, + /// A login; `@me` and `me` name the viewer. + pub author: Option, +} + +impl Filters { + /// These filters with each one `typed` sets taken from it instead: + /// a qualifier in the search field outranks the menu. + pub fn overlaid(&self, typed: &Filters) -> Filters { + fn pick(typed: &[T], menu: &[T]) -> Vec { + if typed.is_empty() { menu } else { typed }.to_vec() + } + Filters { + draft: typed.draft.or(self.draft), + review: typed.review.or(self.review), + checks: typed.checks.or(self.checks), + labels: pick(&typed.labels, &self.labels), + excluded_labels: pick(&typed.excluded_labels, &self.excluded_labels), + author: typed.author.clone().or_else(|| self.author.clone()), + } + } +} + +/// The search field's text split into free text and qualifiers. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ParsedQuery { + pub text: String, + pub filters: Filters, +} + +pub fn parse_query(raw: &str) -> ParsedQuery { + let mut text: Vec<&str> = Vec::new(); + let mut filters = Filters::default(); + for token in query_tokens(raw) { + if !apply_qualifier(token, &mut filters) { + text.push(token); + } + } + filters.labels.truncate(MAX_QUALIFIER_VALUES); + filters.excluded_labels.truncate(MAX_QUALIFIER_VALUES); + ParsedQuery { + text: text.join(" "), + filters, + } +} + +/// Whitespace-separated tokens; a quoted stretch keeps its spaces. A quote +/// without its partner ends the token and is dropped. +fn query_tokens(raw: &str) -> Vec<&str> { + let mut tokens = Vec::new(); + let mut start: Option = None; + let mut chars = raw.char_indices().peekable(); + while let Some((i, c)) = chars.next() { + let closing = (c == '"').then(|| raw[i + 1..].find('"')).flatten(); + if c.is_whitespace() || (c == '"' && closing.is_none()) { + if let Some(s) = start.take() { + tokens.push(&raw[s..i]); + } + continue; + } + start.get_or_insert(i); + if let Some(offset) = closing { + let end = i + 1 + offset; + while chars.peek().is_some_and(|&(j, _)| j <= end) { + chars.next(); + } + } + } + if let Some(s) = start { + tokens.push(&raw[s..]); + } + tokens +} + +/// `-?key:value` with a key of letters, digits, `_` and `-`. +fn split_qualifier(token: &str) -> Option<(bool, &str, &str)> { + let (negated, rest) = match token.strip_prefix('-') { + Some(rest) => (true, rest), + None => (false, token), + }; + let (key, value) = rest.split_once(':')?; + let mut key_chars = key.chars(); + let valid = key_chars.next().is_some_and(|c| c.is_ascii_alphabetic()) + && key_chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'); + valid.then_some((negated, key, value)) +} + +fn unquoted(raw: &str) -> String { + raw.replace('"', "").trim().to_owned() +} + +/// The names of a comma-separated qualifier list, quotes keeping commas. +fn qualifier_names(raw: &str) -> Vec { + let mut names = Vec::new(); + let mut current = String::new(); + let mut quoted = false; + for c in raw.chars() { + match c { + '"' => quoted = !quoted, + ',' if !quoted => names.push(std::mem::take(&mut current)), + c => current.push(c), + } + } + names.push(current); + names + .into_iter() + .map(|name| { + name.trim() + .chars() + .take(MAX_QUALIFIER_LENGTH) + .collect::() + }) + .map(|name| name.trim().to_owned()) + .filter(|name| !name.is_empty()) + .take(MAX_QUALIFIER_VALUES) + .collect() +} + +/// Reads `token` into `filters` when it is a qualifier this list knows; +/// `false` leaves it free text. +fn apply_qualifier(token: &str, filters: &mut Filters) -> bool { + let Some((negated, key, raw_value)) = split_qualifier(token) else { + return false; + }; + let value = unquoted(raw_value); + if value.is_empty() { + return false; + } + let mut add_labels = |names: Vec| { + if names.is_empty() { + return false; + } + if negated { + filters.excluded_labels.extend(names); + } else { + filters.labels.push(names); + } + true + }; + match key.to_ascii_lowercase().as_str() { + "label" => add_labels(qualifier_names(raw_value)), + "author" if !negated => { + filters.author = Some(value.chars().take(MAX_QUALIFIER_LENGTH).collect()); + true + } + "draft" if !negated => match value.to_ascii_lowercase().as_str() { + "true" => { + filters.draft = Some(DraftFilter::Only); + true + } + "false" => { + filters.draft = Some(DraftFilter::Hide); + true + } + _ => false, + }, + "review" if !negated => { + let review = match value.to_ascii_lowercase().as_str() { + "approved" => ReviewFilter::Approved, + "changes_requested" | "changes-requested" => ReviewFilter::ChangesRequested, + "required" | "review-required" => ReviewFilter::ReviewRequired, + "none" => ReviewFilter::None, + _ => return false, + }; + filters.review = Some(review); + true + } + "status" | "checks" if !negated => { + let checks = match value.to_ascii_lowercase().as_str() { + "success" | "passing" => ChecksFilter::Passing, + "failure" | "failing" => ChecksFilter::Failing, + _ => return false, + }; + filters.checks = Some(checks); + true + } + "author" | "draft" | "review" | "status" | "checks" => false, + // Anything else is a scoped label (`area:ui`), unless it reads like a + // path or URL. + _ if !value.starts_with('/') => add_labels( + qualifier_names(raw_value) + .into_iter() + .map(|name| { + if name.contains(':') { + name + } else { + format!("{key}:{name}") + } + }) + .collect(), + ), + _ => false, + } +} + +/// Whether `entry` passes `filters`; `viewer` resolves `author:@me`. +pub fn matches_filters(entry: &ListEntry, filters: &Filters, viewer: Option<&str>) -> bool { + let holds = |name: &str| { + let name = name.trim(); + entry + .labels + .iter() + .any(|label| label.name.trim().eq_ignore_ascii_case(name)) + }; + let draft = match filters.draft { + None => true, + Some(DraftFilter::Only) => entry.is_draft, + Some(DraftFilter::Hide) => !entry.is_draft, + }; + let review = match filters.review { + None => true, + Some(ReviewFilter::None) => entry.review_decision.is_none(), + Some(ReviewFilter::Approved) => entry.review_decision == Some(ReviewDecision::Approved), + Some(ReviewFilter::ChangesRequested) => { + entry.review_decision == Some(ReviewDecision::ChangesRequested) + } + Some(ReviewFilter::ReviewRequired) => { + entry.review_decision == Some(ReviewDecision::ReviewRequired) + } + }; + let checks = match filters.checks { + None => true, + Some(ChecksFilter::Passing) => entry.checks_state == Some(ChecksState::Passing), + Some(ChecksFilter::Failing) => entry.checks_state == Some(ChecksState::Failing), + }; + let author = filters.author.as_deref().is_none_or(|author| { + let author = resolve_author(author, viewer); + entry + .author + .as_ref() + .is_some_and(|actor| actor.login.eq_ignore_ascii_case(author)) + }); + draft + && review + && checks + && author + && filters + .labels + .iter() + .all(|group| group.iter().any(|name| holds(name))) + && !filters.excluded_labels.iter().any(|name| holds(name)) +} + +/// `@me` and `me` name the viewer, when known. +fn resolve_author<'a>(author: &'a str, viewer: Option<&'a str>) -> &'a str { + let author = author.trim(); + let names_viewer = author.eq_ignore_ascii_case("me") || author.eq_ignore_ascii_case("@me"); + match viewer.map(str::trim).filter(|v| !v.is_empty()) { + Some(viewer) if names_viewer => viewer, + _ => author, + } +} + +/// How well `entry` matches the free text `query`; `repository` is the +/// listing's `owner/name`. Rows the host found by their description score +/// lowest but still count. +pub fn score_match(entry: &ListEntry, query: &str, repository: &str) -> u32 { + let needle = query.trim().to_lowercase(); + if needle.is_empty() { + return 0; + } + let number = needle.strip_prefix('#').unwrap_or(&needle); + if !number.is_empty() && number.bytes().all(|b| b.is_ascii_digit()) { + return if entry.number.to_string() == number { + 100 + } else { + 0 + }; + } + let title = entry.title.to_lowercase(); + let terms: Vec<&str> = needle.split_whitespace().collect(); + let author = entry + .author + .as_ref() + .map(|a| a.login.to_lowercase()) + .unwrap_or_default(); + if title == needle { + 90 + } else if title.contains(&needle) { + 80 + } else if terms.len() > 1 && terms.iter().all(|term| title.contains(term)) { + 70 + } else if entry.head_branch.to_lowercase().contains(&needle) { + 60 + } else if author.contains(&needle) { + 50 + } else if repository.to_lowercase().contains(&needle) { + 40 + } else if terms.iter().any(|term| title.contains(term)) { + 30 + } else { + 10 + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GroupKey { + Authored, + ReviewRequested, + Others, +} + +impl GroupKey { + pub fn label(self) -> &'static str { + match self { + GroupKey::Authored => "Authored", + GroupKey::ReviewRequested => "Review requested", + GroupKey::Others => "Others", + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Group<'a> { + pub key: GroupKey, + pub entries: Vec<&'a ListEntry>, +} + +/// Everything that decides the arrangement besides the rows. +#[derive(Clone, Debug)] +pub struct Arrangement<'a> { + pub sort: SortMode, + /// The search field as typed. + pub query: &'a str, + pub menu_filters: &'a Filters, + /// The involvement the host listed by. + pub involvement: Involvement, + /// The signed-in login. + pub viewer: Option<&'a str>, + /// `owner/name` of the listed repository. + pub repository: &'a str, +} + +/// The groups to show, in order, each sorted. `entries` come as the host +/// listed them, most recently updated first. +pub fn arrange<'a>(entries: &'a [ListEntry], arrangement: &Arrangement<'_>) -> Vec> { + let parsed = parse_query(arrangement.query); + let filters = arrangement.menu_filters.overlaid(&parsed.filters); + let mut kept: Vec<&ListEntry> = entries + .iter() + .filter(|entry| matches_filters(entry, &filters, arrangement.viewer)) + .collect(); + let searching = !parsed.text.trim().is_empty(); + if searching { + let score = |entry: &ListEntry| score_match(entry, &parsed.text, arrangement.repository); + kept.sort_by(|a, b| score(b).cmp(&score(a)).then(by_recency(a, b))); + } + let mut groups = group(kept, arrangement.viewer); + if !searching { + for group in &mut groups { + sort_group(group, arrangement.sort, arrangement.involvement); + } + } + groups +} + +fn by_recency(a: &ListEntry, b: &ListEntry) -> Ordering { + b.updated_at.cmp(&a.updated_at) +} + +fn group<'a>(entries: Vec<&'a ListEntry>, viewer: Option<&str>) -> Vec> { + let mut groups = [ + GroupKey::Authored, + GroupKey::ReviewRequested, + GroupKey::Others, + ] + .map(|key| Group { + key, + entries: Vec::new(), + }); + for entry in entries { + let index = match viewer { + Some(viewer) if is_author(entry, viewer) => 0, + Some(viewer) if is_review_requested(entry, viewer) => 1, + _ => 2, + }; + groups[index].entries.push(entry); + } + groups + .into_iter() + .filter(|g| !g.entries.is_empty()) + .collect() +} + +fn is_author(entry: &ListEntry, viewer: &str) -> bool { + entry + .author + .as_ref() + .is_some_and(|a| a.login.eq_ignore_ascii_case(viewer)) +} + +/// As the host's `review-requested:@me` reads it: a team request counts. +fn is_review_requested(entry: &ListEntry, viewer: &str) -> bool { + entry.has_team_review_request + || entry + .review_request_logins + .iter() + .any(|login| login.eq_ignore_ascii_case(viewer)) +} + +/// Rows with line counts come before rows still without them. +fn measured(entry: &ListEntry) -> bool { + entry.additions + entry.deletions > 0 +} + +fn size(entry: &ListEntry) -> u64 { + entry.additions + entry.deletions +} + +fn sort_group(group: &mut Group<'_>, sort: SortMode, involvement: Involvement) { + let entries = &mut group.entries; + match sort { + SortMode::MergeReadiness => entries.sort_by(|a, b| { + readiness_tier(a) + .cmp(&readiness_tier(b)) + .then(measured(b).cmp(&measured(a))) + .then(size(a).cmp(&size(b))) + .then(by_recency(a, b)) + }), + SortMode::BlockedOnMe => { + let role = match group.key { + GroupKey::Authored => Involvement::Authored, + GroupKey::ReviewRequested => Involvement::ReviewRequested, + GroupKey::Others => involvement, + }; + let tier: fn(&ListEntry) -> u8 = match role { + Involvement::All => return, + Involvement::Authored => blocked_on_author_tier, + Involvement::ReviewRequested => blocked_on_reviewer_tier, + }; + entries.sort_by(|a, b| tier(a).cmp(&tier(b)).then(by_recency(a, b))); + } + SortMode::RecentlyUpdated => {} + SortMode::Newest | SortMode::Oldest => { + let created = |e: &ListEntry| e.created_at; + entries.sort_by(|a, b| { + let order = created(a).cmp(&created(b)); + let order = if sort == SortMode::Newest { + order.reverse() + } else { + order + }; + order.then(by_recency(a, b)) + }) + } + SortMode::Largest | SortMode::Smallest => entries.sort_by(|a, b| { + let order = size(a).cmp(&size(b)); + let order = if sort == SortMode::Largest { + order.reverse() + } else { + order + }; + measured(b) + .cmp(&measured(a)) + .then(order) + .then(by_recency(a, b)) + }), + } +} + +fn readiness_tier(entry: &ListEntry) -> u8 { + let passing = entry.checks_state == Some(ChecksState::Passing); + if entry.mergeability == Mergeability::Conflicting { + 4 + } else if entry.state != PullRequestState::Open { + 3 + } else if entry.is_draft { + 2 + } else if passing && entry.review_decision == Some(ReviewDecision::Approved) { + 0 + } else if passing { + 1 + } else { + 2 + } +} + +/// What the author has to do first: conflicts, requested changes, failing +/// checks, a draft; finished work last. +fn blocked_on_author_tier(entry: &ListEntry) -> u8 { + if entry.state != PullRequestState::Open { + 6 + } else if entry.mergeability == Mergeability::Conflicting { + 0 + } else if entry.review_decision == Some(ReviewDecision::ChangesRequested) { + 1 + } else if entry.checks_state == Some(ChecksState::Failing) { + 2 + } else if entry.is_draft { + 3 + } else if entry.checks_state == Some(ChecksState::Passing) + && entry.review_decision == Some(ReviewDecision::Approved) + { + 5 + } else { + 4 + } +} + +fn blocked_on_reviewer_tier(entry: &ListEntry) -> u8 { + u8::from(entry.state != PullRequestState::Open) +} + +/// How many loaded rows each author and label has, most first; the Author +/// and Labels filters offer them. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Facets { + pub authors: Vec<(String, usize)>, + pub labels: Vec<(Label, usize)>, +} + +pub fn facets(entries: &[ListEntry]) -> Facets { + let mut authors: Vec<(String, usize)> = Vec::new(); + let mut labels: Vec<(Label, usize)> = Vec::new(); + for entry in entries { + if let Some(author) = &entry.author { + match authors + .iter_mut() + .find(|(login, _)| login.eq_ignore_ascii_case(&author.login)) + { + Some((_, count)) => *count += 1, + None => authors.push((author.login.clone(), 1)), + } + } + for label in &entry.labels { + match labels + .iter_mut() + .find(|(held, _)| held.name.trim().eq_ignore_ascii_case(label.name.trim())) + { + Some((_, count)) => *count += 1, + None => labels.push((label.clone(), 1)), + } + } + } + authors.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + labels.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.name.cmp(&b.0.name))); + Facets { authors, labels } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::main_screen::right_panel::pull_request::fixtures::{entry, entry_with, hours_ago}; + use github::model::Label; + + fn label(name: &str) -> Label { + Label { + name: name.into(), + color: None, + } + } + + fn numbers(groups: &[Group]) -> Vec<(GroupKey, Vec)> { + groups + .iter() + .map(|g| (g.key, g.entries.iter().map(|e| e.number).collect())) + .collect() + } + + fn arranged(entries: &[ListEntry], sort: SortMode, query: &str) -> Vec<(GroupKey, Vec)> { + let filters = Filters::default(); + numbers(&arrange( + entries, + &Arrangement { + sort, + query, + menu_filters: &filters, + involvement: Involvement::All, + viewer: Some("me"), + repository: "stippi/code-assistant", + }, + )) + } + + #[test] + fn plain_text_is_free_text() { + let parsed = parse_query(" image renderer "); + assert_eq!(parsed.text, "image renderer"); + assert_eq!(parsed.filters, Filters::default()); + } + + #[test] + fn qualifiers_become_filters() { + let parsed = parse_query( + "fix label:bug,\"good first issue\" -label:wontfix author:@me draft:false review:approved checks:failure", + ); + assert_eq!(parsed.text, "fix"); + assert_eq!( + parsed.filters, + Filters { + draft: Some(DraftFilter::Hide), + review: Some(ReviewFilter::Approved), + checks: Some(ChecksFilter::Failing), + labels: vec![vec!["bug".into(), "good first issue".into()]], + excluded_labels: vec!["wontfix".into()], + author: Some("@me".into()), + } + ); + } + + #[test] + fn invalid_qualifier_values_stay_text() { + let parsed = parse_query("draft:maybe review:soon -author:x checks: status:success"); + assert_eq!(parsed.text, "draft:maybe review:soon -author:x checks:"); + assert_eq!(parsed.filters.checks, Some(ChecksFilter::Passing)); + } + + #[test] + fn unknown_qualifiers_are_scoped_labels_except_paths() { + let parsed = parse_query("area:ui -kind:chore http://example.com/x"); + assert_eq!(parsed.filters.labels, vec![vec!["area:ui".to_string()]]); + assert_eq!( + parsed.filters.excluded_labels, + vec!["kind:chore".to_string()] + ); + assert_eq!(parsed.text, "http://example.com/x"); + } + + #[test] + fn quoted_values_keep_their_spaces() { + let parsed = parse_query("label:\"needs review\" \"exact phrase\""); + assert_eq!( + parsed.filters.labels, + vec![vec!["needs review".to_string()]] + ); + assert_eq!(parsed.text, "\"exact phrase\""); + } + + #[test] + fn typed_qualifiers_outrank_the_menu() { + let menu = Filters { + draft: Some(DraftFilter::Only), + review: Some(ReviewFilter::Approved), + ..Filters::default() + }; + let typed = Filters { + draft: Some(DraftFilter::Hide), + labels: vec![vec!["bug".into()]], + ..Filters::default() + }; + let merged = menu.overlaid(&typed); + assert_eq!(merged.draft, Some(DraftFilter::Hide)); + assert_eq!(merged.review, Some(ReviewFilter::Approved)); + assert_eq!(merged.labels, vec![vec!["bug".to_string()]]); + } + + #[test] + fn filters_narrow_rows() { + let draft = entry_with(1, "a", "ann", |e| e.is_draft = true); + let approved = entry_with(2, "b", "Me", |e| { + e.review_decision = Some(ReviewDecision::Approved); + e.checks_state = Some(ChecksState::Failing); + e.labels = vec![label("Bug"), label("ui")]; + }); + let only = |filters: Filters| { + [&draft, &approved] + .into_iter() + .filter(|e| matches_filters(e, &filters, Some("me"))) + .map(|e| e.number) + .collect::>() + }; + assert_eq!(only(Filters::default()), vec![1, 2]); + let draft_only = Filters { + draft: Some(DraftFilter::Only), + ..Filters::default() + }; + assert_eq!(only(draft_only), vec![1]); + let unreviewed = Filters { + review: Some(ReviewFilter::None), + ..Filters::default() + }; + assert_eq!(only(unreviewed), vec![1]); + let failing = Filters { + checks: Some(ChecksFilter::Failing), + ..Filters::default() + }; + assert_eq!(only(failing), vec![2]); + let bug_and_ui = Filters { + labels: vec![vec!["bug".into()], vec!["ui".into(), "docs".into()]], + ..Filters::default() + }; + assert_eq!(only(bug_and_ui), vec![2]); + let not_bug = Filters { + excluded_labels: vec!["BUG".into()], + ..Filters::default() + }; + assert_eq!(only(not_bug), vec![1]); + let mine = Filters { + author: Some("@me".into()), + ..Filters::default() + }; + assert_eq!(only(mine), vec![2]); + } + + #[test] + fn search_scores_number_title_branch_author() { + let e = entry_with(281, "Show raster images", "daniel", |e| { + e.head_branch = "feat/raster".into() + }); + let repo = "stippi/code-assistant"; + assert_eq!(score_match(&e, "#281", repo), 100); + assert_eq!(score_match(&e, "28", repo), 0); + assert_eq!(score_match(&e, "show raster images", repo), 90); + assert_eq!(score_match(&e, "raster", repo), 80); + assert_eq!(score_match(&e, "images show", repo), 70); + assert_eq!(score_match(&e, "feat/", repo), 60); + assert_eq!(score_match(&e, "dani", repo), 50); + assert_eq!(score_match(&e, "stippi", repo), 40); + assert_eq!(score_match(&e, "raster zebra", repo), 30); + assert_eq!(score_match(&e, "zebra", repo), 10); + } + + #[test] + fn rows_group_by_the_viewers_part() { + let entries = [ + entry(1, "others", "ann"), + entry_with(2, "asked", "bob", |e| { + e.review_request_logins = vec!["ME".into()] + }), + entry(3, "mine", "me"), + entry_with(4, "team", "cy", |e| e.has_team_review_request = true), + ]; + assert_eq!( + arranged(&entries, SortMode::RecentlyUpdated, ""), + vec![ + (GroupKey::Authored, vec![3]), + (GroupKey::ReviewRequested, vec![2, 4]), + (GroupKey::Others, vec![1]), + ] + ); + } + + #[test] + fn without_a_viewer_everything_is_others() { + let entries = [entry(1, "a", "me"), entry(2, "b", "ann")]; + let filters = Filters::default(); + let groups = arrange( + &entries, + &Arrangement { + sort: SortMode::RecentlyUpdated, + query: "", + menu_filters: &filters, + involvement: Involvement::All, + viewer: None, + repository: "stippi/code-assistant", + }, + ); + assert_eq!(numbers(&groups), vec![(GroupKey::Others, vec![1, 2])]); + } + + #[test] + fn merge_readiness_puts_ready_first_and_conflicts_last() { + let entries = [ + entry_with(1, "conflict", "ann", |e| { + e.mergeability = Mergeability::Conflicting + }), + entry_with(2, "draft", "ann", |e| e.is_draft = true), + entry_with(3, "passing", "ann", |e| { + e.checks_state = Some(ChecksState::Passing) + }), + entry_with(4, "ready", "ann", |e| { + e.checks_state = Some(ChecksState::Passing); + e.review_decision = Some(ReviewDecision::Approved); + }), + entry_with(5, "closed", "ann", |e| e.state = PullRequestState::Closed), + ]; + assert_eq!( + arranged(&entries, SortMode::MergeReadiness, ""), + vec![(GroupKey::Others, vec![4, 3, 2, 5, 1])] + ); + } + + #[test] + fn merge_readiness_prefers_small_measured_changes_within_a_tier() { + let entries = [ + entry(1, "unmeasured", "ann"), + entry_with(2, "large", "ann", |e| e.additions = 500), + entry_with(3, "small", "ann", |e| e.deletions = 5), + ]; + assert_eq!( + arranged(&entries, SortMode::MergeReadiness, ""), + vec![(GroupKey::Others, vec![3, 2, 1])] + ); + } + + #[test] + fn blocked_on_me_ranks_my_pull_requests_by_what_blocks_them() { + let entries = [ + entry(1, "waiting", "me"), + entry_with(2, "failing", "me", |e| { + e.checks_state = Some(ChecksState::Failing) + }), + entry_with(3, "changes", "me", |e| { + e.review_decision = Some(ReviewDecision::ChangesRequested) + }), + entry_with(4, "conflict", "me", |e| { + e.mergeability = Mergeability::Conflicting + }), + entry_with(5, "others keep order", "ann", |_| {}), + entry_with(6, "others keep order", "ann", |_| {}), + ]; + assert_eq!( + arranged(&entries, SortMode::BlockedOnMe, ""), + vec![ + (GroupKey::Authored, vec![4, 3, 2, 1]), + (GroupKey::Others, vec![5, 6]), + ] + ); + } + + #[test] + fn created_and_size_sorts() { + let entries = [ + entry_with(1, "a", "ann", |e| { + e.created_at = hours_ago(10); + e.additions = 10; + }), + entry_with(2, "b", "ann", |e| { + e.created_at = hours_ago(30); + e.additions = 300; + }), + entry_with(3, "c", "ann", |e| e.created_at = hours_ago(20)), + ]; + let order = |sort| arranged(&entries, sort, "")[0].1.clone(); + assert_eq!(order(SortMode::Newest), vec![1, 3, 2]); + assert_eq!(order(SortMode::Oldest), vec![2, 3, 1]); + // Unmeasured rows go last either way. + assert_eq!(order(SortMode::Largest), vec![2, 1, 3]); + assert_eq!(order(SortMode::Smallest), vec![1, 2, 3]); + } + + #[test] + fn a_search_ranks_by_match_and_overrides_readiness() { + let entries = [ + entry_with(1, "unrelated", "ann", |e| { + e.checks_state = Some(ChecksState::Passing); + e.review_decision = Some(ReviewDecision::Approved); + }), + entry(2, "raster images", "ann"), + entry(3, "show raster", "ann"), + ]; + assert_eq!( + arranged(&entries, SortMode::MergeReadiness, "raster label:x"), + Vec::<(GroupKey, Vec)>::new(), + "label:x keeps nothing" + ); + assert_eq!( + arranged(&entries, SortMode::MergeReadiness, "raster"), + vec![(GroupKey::Others, vec![2, 3, 1])] + ); + } + + #[test] + fn facets_count_authors_and_labels() { + let entries = [ + entry_with(1, "a", "ann", |e| e.labels = vec![label("bug")]), + entry_with(2, "b", "bob", |e| { + e.labels = vec![label("bug"), label("ui")] + }), + entry(3, "c", "bob"), + ]; + let facets = facets(&entries); + assert_eq!( + facets.authors, + vec![("bob".to_string(), 2), ("ann".to_string(), 1)] + ); + assert_eq!(facets.labels, vec![(label("bug"), 2), (label("ui"), 1)]); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/list_overrides.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/list_overrides.rs new file mode 100644 index 000000000..21b732a76 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/list_overrides.rs @@ -0,0 +1,298 @@ +//! What the list shows before GitHub confirms it: a row closed, merged, +//! reopened or marked ready shows so at once, and keeps that state until a +//! listing agrees (or a minute passed, when the host's answer wins). A +//! failed action takes its override back. Line counts read for rows that +//! came without them fill those rows in. Ported from T3 Code's +//! `pullRequestList.logic.ts` (`pullRequestOverrideAfterAction`, +//! `applyPullRequestOverrides`, `settlePullRequestOverrides`, +//! `pullRequestQuickActions`, `withDiffStat`). + +use chrono::{DateTime, Duration, Utc}; +use github::model::{ + Action, DiffStat, ListEntry, ListState, MergeMethod, PullRequestAction, PullRequestState, +}; +use std::collections::HashMap; + +/// How long an override outranks a listing that disagrees with it. +const OVERRIDE_TRUST: Duration = Duration::seconds(60); + +/// A row's state as the viewer's action left it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RowOverride { + pub state: PullRequestState, + /// Set by draft and ready; the others keep the row's. + pub is_draft: Option, + /// When the action ran; the row shows it as its update. + pub at: DateTime, + /// Tells this override from a later one on the same row. + pub token: u64, +} + +/// The override `action` leaves on a row in `state`; `None` for actions +/// that change nothing the list shows. +pub fn override_after_action( + state: PullRequestState, + action: PullRequestAction, + now: DateTime, + token: u64, +) -> Option { + let (state, is_draft) = match action { + PullRequestAction::Close => (PullRequestState::Closed, None), + PullRequestAction::Reopen => (PullRequestState::Open, None), + PullRequestAction::Merge => (PullRequestState::Merged, None), + PullRequestAction::Draft => (state, Some(true)), + PullRequestAction::Ready => (state, Some(false)), + _ => return None, + }; + Some(RowOverride { + state, + is_draft, + at: now, + token, + }) +} + +/// `entries` with their overrides applied; rows an override moved out of +/// the listed `state` leave. +pub fn apply_overrides( + entries: &[ListEntry], + overrides: &HashMap, + listed: ListState, +) -> Vec { + entries + .iter() + .filter_map(|entry| { + let mut entry = entry.clone(); + if let Some(o) = overrides.get(&entry.number) { + entry.state = o.state; + if let Some(is_draft) = o.is_draft { + entry.is_draft = is_draft; + } + entry.updated_at = o.at; + } + in_listed_state(entry.state, listed).then_some(entry) + }) + .collect() +} + +fn in_listed_state(state: PullRequestState, listed: ListState) -> bool { + match listed { + ListState::All => true, + ListState::Open => state == PullRequestState::Open, + ListState::Closed => state == PullRequestState::Closed, + ListState::Merged => state == PullRequestState::Merged, + } +} + +/// Retire the overrides a listing agrees with, and those it outranks by +/// now. A row missing from the listing proves nothing: its override stays. +pub fn settle_overrides( + overrides: &mut HashMap, + answered: &[ListEntry], + now: DateTime, +) { + overrides.retain(|number, o| { + let Some(row) = answered.iter().find(|e| e.number == *number) else { + return true; + }; + let agrees = row.state == o.state && o.is_draft.is_none_or(|d| d == row.is_draft); + !agrees && now - o.at <= OVERRIDE_TRUST + }); +} + +/// What a row offers while Shift is held. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum QuickAction { + Close, + Merge, + Ready, + Reopen, +} + +impl QuickAction { + pub fn label(self) -> &'static str { + match self { + QuickAction::Close => "Close", + QuickAction::Merge => "Merge", + QuickAction::Ready => "Ready", + QuickAction::Reopen => "Reopen", + } + } + + /// Closing and merging take a confirming click. + pub fn confirms(self) -> bool { + matches!(self, QuickAction::Close | QuickAction::Merge) + } + + pub fn action(self, method: MergeMethod) -> Action { + match self { + QuickAction::Close => Action::Close, + QuickAction::Merge => Action::Merge(method), + QuickAction::Ready => Action::Ready, + QuickAction::Reopen => Action::Reopen, + } + } +} + +pub fn quick_actions(state: PullRequestState, is_draft: bool) -> &'static [QuickAction] { + match state { + PullRequestState::Merged => &[], + PullRequestState::Closed => &[QuickAction::Reopen], + PullRequestState::Open if is_draft => &[QuickAction::Close, QuickAction::Ready], + PullRequestState::Open => &[QuickAction::Close, QuickAction::Merge], + } +} + +/// Fill in line counts for rows that came without them (0/0); counts a +/// row brought itself win. +pub fn with_stats(entries: &mut [ListEntry], stats: &HashMap) { + for entry in entries { + if entry.additions == 0 + && entry.deletions == 0 + && let Some(&(additions, deletions)) = stats.get(&entry.number) + { + entry.additions = additions; + entry.deletions = deletions; + } + } +} + +/// Merge newly read counts into those held. +pub fn merge_stats(held: &mut HashMap, read: &[DiffStat]) { + for stat in read { + held.insert(stat.pr.number, (stat.additions, stat.deletions)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::main_screen::right_panel::pull_request::fixtures::{entry, entry_with, now, repo}; + + fn numbers(entries: &[ListEntry]) -> Vec { + entries.iter().map(|e| e.number).collect() + } + + #[test] + fn actions_leave_overrides() { + let closed = + override_after_action(PullRequestState::Open, PullRequestAction::Close, now(), 1); + assert_eq!(closed.map(|o| o.state), Some(PullRequestState::Closed)); + let ready = + override_after_action(PullRequestState::Open, PullRequestAction::Ready, now(), 2) + .unwrap(); + assert_eq!( + (ready.state, ready.is_draft), + (PullRequestState::Open, Some(false)) + ); + let merged = + override_after_action(PullRequestState::Open, PullRequestAction::Merge, now(), 3); + assert_eq!(merged.map(|o| o.state), Some(PullRequestState::Merged)); + assert_eq!( + override_after_action( + PullRequestState::Open, + PullRequestAction::UpdateBranch, + now(), + 4 + ), + None + ); + } + + #[test] + fn overrides_show_at_once_and_move_rows_out_of_the_listed_state() { + let entries = [ + entry(1, "a", "ann"), + entry_with(2, "b", "bob", |e| e.is_draft = true), + ]; + let mut overrides = HashMap::new(); + overrides.insert( + 1, + override_after_action(PullRequestState::Open, PullRequestAction::Close, now(), 1) + .unwrap(), + ); + overrides.insert( + 2, + override_after_action(PullRequestState::Open, PullRequestAction::Ready, now(), 2) + .unwrap(), + ); + let open = apply_overrides(&entries, &overrides, ListState::Open); + assert_eq!(numbers(&open), [2]); + assert!(!open[0].is_draft); + assert_eq!(open[0].updated_at, now()); + let all = apply_overrides(&entries, &overrides, ListState::All); + assert_eq!(all[0].state, PullRequestState::Closed); + } + + #[test] + fn overrides_hold_until_a_listing_agrees_or_a_minute_passed() { + let mut overrides = HashMap::new(); + let close = + override_after_action(PullRequestState::Open, PullRequestAction::Close, now(), 1) + .unwrap(); + overrides.insert(1, close.clone()); + overrides.insert( + 2, + RowOverride { + token: 2, + ..close.clone() + }, + ); + overrides.insert(3, RowOverride { token: 3, ..close }); + + // #1 still open on the host, #2 closed, #3 not listed. + let answered = [ + entry(1, "a", "ann"), + entry_with(2, "b", "ann", |e| e.state = PullRequestState::Closed), + ]; + settle_overrides(&mut overrides, &answered, now() + Duration::seconds(10)); + let mut kept: Vec<_> = overrides.keys().copied().collect(); + kept.sort(); + assert_eq!(kept, [1, 3]); + + settle_overrides(&mut overrides, &answered, now() + Duration::seconds(61)); + let kept: Vec<_> = overrides.keys().copied().collect(); + assert_eq!(kept, [3], "the host wins after a minute"); + } + + #[test] + fn rows_offer_what_their_state_allows() { + use QuickAction::*; + assert_eq!(quick_actions(PullRequestState::Open, false), [Close, Merge]); + assert_eq!(quick_actions(PullRequestState::Open, true), [Close, Ready]); + assert_eq!(quick_actions(PullRequestState::Closed, false), [Reopen]); + assert!(quick_actions(PullRequestState::Merged, false).is_empty()); + assert!(Close.confirms() && Merge.confirms() && !Ready.confirms()); + assert_eq!( + Merge.action(MergeMethod::Squash), + Action::Merge(MergeMethod::Squash) + ); + } + + #[test] + fn read_counts_fill_rows_without_their_own() { + let mut held = HashMap::new(); + merge_stats( + &mut held, + &[ + DiffStat { + pr: repo().pull_request(1), + additions: 12, + deletions: 3, + }, + DiffStat { + pr: repo().pull_request(2), + additions: 99, + deletions: 99, + }, + ], + ); + let mut entries = [ + entry(1, "a", "ann"), + entry_with(2, "b", "ann", |e| e.additions = 5), + ]; + with_stats(&mut entries, &held); + assert_eq!((entries[0].additions, entries[0].deletions), (12, 3)); + assert_eq!((entries[1].additions, entries[1].deletions), (5, 0)); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/markdown.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/markdown.rs new file mode 100644 index 000000000..519d83548 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/markdown.rs @@ -0,0 +1,447 @@ +//! Pull request text (descriptions, comments) prepared for the Markdown +//! view, which knows CommonMark but not GitHub's additions: an alert +//! (`> [!NOTE]`) becomes a quote headed by its kind, and the references +//! GitHub links by itself (`#12`, `repo#12`, `owner/repo#12`, a commit SHA, +//! `repo@sha`, `@user`, `@org/team`) become links. Code (fenced and inline), +//! links, HTML tags and URLs stay as written. +//! +//! The alert keeps the quote's look: the Markdown view has no coloured +//! callouts, so the kind shows as an emoji and a bold title. + +use github::RepoRef; + +/// Hex digits of a SHA a link shows. +const SHORT_SHA: usize = 7; + +/// `text` with alerts and references rewritten for the Markdown view. +pub fn prepare(text: &str, repo: &RepoRef) -> String { + let links = Links::new(repo); + let mut out = String::with_capacity(text.len() + text.len() / 8); + let mut fence: Option = None; + let mut in_quote = false; + for line in text.split_inclusive('\n') { + let body = line.trim_end_matches(['\n', '\r']); + let eol = &line[body.len()..]; + if let Some(open) = &fence { + if open.is_closed_by(body) { + fence = None; + } + out.push_str(line); + continue; + } + if let Some(opened) = Fence::opened_by(body) { + fence = Some(opened); + in_quote = false; + out.push_str(line); + continue; + } + let quote_line = body.trim_start().starts_with('>'); + if !in_quote && let Some((prefix, title)) = alert_title(body) { + let bare = prefix.trim_end(); + let eol = if eol.is_empty() { "\n" } else { eol }; + out.push_str(&format!("{prefix}**{title}**{eol}{bare}{eol}")); + in_quote = true; + continue; + } + in_quote = quote_line; + links.rewrite_line(body, &mut out); + out.push_str(eol); + } + out +} + +/// An open code fence: its character and length. +struct Fence { + marker: char, + len: usize, +} + +impl Fence { + fn opened_by(line: &str) -> Option { + let trimmed = line.trim_start_matches(' '); + if line.len() - trimmed.len() > 3 { + return None; + } + let marker = trimmed.chars().next().filter(|c| matches!(c, '`' | '~'))?; + let len = trimmed.chars().take_while(|&c| c == marker).count(); + let info = &trimmed[len..]; + (len >= 3 && !(marker == '`' && info.contains('`'))).then_some(Fence { marker, len }) + } + + fn is_closed_by(&self, line: &str) -> bool { + let trimmed = line.trim_start_matches(' '); + let len = trimmed.chars().take_while(|&c| c == self.marker).count(); + line.len() - trimmed.len() <= 3 && len >= self.len && trimmed[len..].trim().is_empty() + } +} + +/// For the first line of an alert, its quote prefix (`> `) and the title +/// replacing `[!KIND]`. +fn alert_title(line: &str) -> Option<(&str, &'static str)> { + let trimmed = line.trim_start_matches(' '); + if line.len() - trimmed.len() > 3 { + return None; + } + let after_quote = trimmed.strip_prefix('>')?; + let marker = after_quote.strip_prefix(' ').unwrap_or(after_quote); + let kind = marker.strip_prefix("[!")?.split_once(']')?; + if !kind.1.trim().is_empty() { + return None; + } + let title = match kind.0.to_ascii_uppercase().as_str() { + "NOTE" => "ℹ️ Note", + "TIP" => "💡 Tip", + "IMPORTANT" => "❗ Important", + "WARNING" => "⚠️ Warning", + "CAUTION" => "🛑 Caution", + _ => return None, + }; + Some((&line[..line.len() - marker.len()], title)) +} + +fn is_word(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '_' +} + +/// A reference may not follow these: they would make it part of a word, a +/// path, an escape or a name. +fn is_boundary(previous: Option) -> bool { + previous.is_none_or(|c| !(is_word(c) || matches!(c, '.' | '/' | '\\' | '-'))) +} + +/// The length of the leading run of `s` whose characters pass `keep`. +fn run(s: &str, keep: impl Fn(char) -> bool) -> usize { + s.find(|c| !keep(c)).unwrap_or(s.len()) +} + +fn not_followed_by_word(rest: &str) -> bool { + !rest.chars().next().is_some_and(is_word) +} + +/// The links of one repository. +struct Links<'a> { + origin: String, + repo: &'a RepoRef, +} + +impl<'a> Links<'a> { + fn new(repo: &'a RepoRef) -> Self { + Self { + origin: format!("https://{}", repo.host), + repo, + } + } + + /// `owner/name` of a reference's qualifier: `owner/repo` as given, a + /// bare `repo` of the same owner, none this repository. + fn repository(&self, qualifier: Option<&str>) -> String { + match qualifier { + Some(q) if q.contains('/') => q.to_owned(), + Some(q) => format!("{q}/{}", self.repo.name), + None => self.repo.full_name(), + } + } + + /// Copies `line` to `out` with its references linked. + fn rewrite_line(&self, line: &str, out: &mut String) { + let mut i = 0; + while i < line.len() { + let rest = &line[i..]; + let verbatim = verbatim_len(line, i); + if verbatim > 0 { + out.push_str(&rest[..verbatim]); + i += verbatim; + continue; + } + if is_boundary(line[..i].chars().next_back()) + && let Some((len, label, url)) = self.reference(rest) + { + let label = label.replace('_', "\\_"); + out.push_str(&format!("[{label}]({url})")); + i += len; + continue; + } + let c = rest.chars().next().expect("i is inside the line"); + out.push(c); + i += c.len_utf8(); + } + } + + /// A reference at the start of `s`: its length, label and URL. + fn reference(&self, s: &str) -> Option<(usize, String, String)> { + if let Some(rest) = s.strip_prefix('@') { + return self.mention(rest); + } + if let Some(rest) = s.strip_prefix('#') { + let len = issue_number_len(rest)?; + let url = format!( + "{}/{}/issues/{}", + self.origin, + self.repository(None), + &rest[..len] + ); + return Some((len + 1, s[..len + 1].to_owned(), url)); + } + self.qualified(s).or_else(|| self.commit(s, None, 0)) + } + + fn mention(&self, rest: &str) -> Option<(usize, String, String)> { + let login = + rest[..run(rest, |c| c.is_ascii_alphanumeric() || c == '-')].trim_end_matches('-'); + if !login.starts_with(|c: char| c.is_ascii_alphanumeric()) { + return None; + } + let after = &rest[login.len()..]; + if let Some(team_rest) = after.strip_prefix('/') + && team_rest.starts_with(|c: char| c.is_ascii_alphanumeric()) + { + let team = &team_rest[..run(team_rest, |c| c.is_ascii_alphanumeric() || c == '-')]; + if not_followed_by_word(&team_rest[team.len()..]) { + let len = 1 + login.len() + 1 + team.len(); + let url = format!("{}/orgs/{login}/teams/{team}", self.origin); + return Some((len, format!("@{login}/{team}"), url)); + } + } + not_followed_by_word(after).then(|| { + ( + 1 + login.len(), + format!("@{login}"), + format!("{}/{login}", self.origin), + ) + }) + } + + /// `repo#12`, `owner/repo#12`, `repo@sha` or `owner/repo@sha`. + fn qualified(&self, s: &str) -> Option<(usize, String, String)> { + let part = |s: &str| run(s, |c| is_word(c) || c == '.' || c == '-'); + let owner = part(s); + let mut len = owner; + if let Some(name) = s[len..].strip_prefix('/').map(part).filter(|&n| n > 0) { + len += 1 + name; + } else if s[..owner].contains('.') { + return None; + } + if owner == 0 { + return None; + } + let qualifier = &s[..len]; + let rest = &s[len..]; + if let Some(number) = rest.strip_prefix('#') { + let digits = issue_number_len(number)?; + let end = len + 1 + digits; + let url = format!( + "{}/{}/issues/{}", + self.origin, + self.repository(Some(qualifier)), + &number[..digits] + ); + return Some((end, s[..end].to_owned(), url)); + } + let sha = rest.strip_prefix('@')?; + self.commit(sha, Some(qualifier), len + 1) + } + + /// A SHA at the start of `s`, `offset` bytes into the reference. + fn commit( + &self, + s: &str, + qualifier: Option<&str>, + offset: usize, + ) -> Option<(usize, String, String)> { + let len = run(s, |c| c.is_ascii_hexdigit()); + let sha = &s[..len]; + // A run of digits is a number far more often than a SHA. + let is_sha = (7..=40).contains(&len) + && not_followed_by_word(&s[len..]) + && sha.bytes().any(|b| b.is_ascii_alphabetic()); + is_sha.then(|| { + let label = match qualifier { + Some(q) => format!("{q}@{}", &sha[..SHORT_SHA]), + None => sha[..SHORT_SHA].to_owned(), + }; + let url = format!( + "{}/{}/commit/{sha}", + self.origin, + self.repository(qualifier) + ); + (offset + len, label, url) + }) + } +} + +/// `[1-9][0-9]*` not followed by a word character. +fn issue_number_len(s: &str) -> Option { + let len = run(s, |c| c.is_ascii_digit()); + (len > 0 && !s.starts_with('0') && not_followed_by_word(&s[len..])).then_some(len) +} + +/// How much of `line` from `i` must be copied untouched: a code span, an +/// HTML tag or autolink, a Markdown link or image, or a bare URL. +fn verbatim_len(line: &str, i: usize) -> usize { + let rest = &line[i..]; + match rest.as_bytes()[0] { + b'`' => { + let ticks = run(rest, |c| c == '`'); + let closing = "`".repeat(ticks); + let mut search = ticks; + while let Some(at) = rest[search..].find(&closing) { + let end = search + at + ticks; + // A longer run does not close it. + if !rest[end..].starts_with('`') { + return end; + } + search = end + run(&rest[end..], |c| c == '`'); + } + ticks + } + b'<' if rest[1..].starts_with(|c: char| c.is_ascii_alphabetic() || "/!?".contains(c)) => { + rest.find('>').map_or(0, |end| end + 1) + } + b'[' | b'!' => link_len(rest), + b'h' | b'w' if is_boundary(line[..i].chars().next_back()) => { + let is_url = ["https://", "http://", "www."] + .iter() + .any(|scheme| rest.starts_with(scheme)); + if is_url { + run(rest, |c| !c.is_whitespace() && c != '<') + } else { + 0 + } + } + _ => 0, + } +} + +/// `[text](target)`, `![alt](src)` or `[text][ref]` at the start of `s`. +fn link_len(s: &str) -> usize { + let start = usize::from(s.starts_with('!')); + if !s[start..].starts_with('[') { + return 0; + } + let Some(text_end) = closing(&s[start..], '[', ']').map(|end| start + end) else { + return 0; + }; + let after = &s[text_end + 1..]; + let target = match after.chars().next() { + Some('(') => closing(after, '(', ')'), + Some('[') => closing(after, '[', ']'), + _ => None, + }; + target.map_or(0, |end| text_end + 1 + end + 1) +} + +/// The index of the bracket closing the one `s` starts with. +fn closing(s: &str, open: char, close: char) -> Option { + let mut depth = 0usize; + for (i, c) in s.char_indices() { + if c == open { + depth += 1; + } else if c == close { + depth -= 1; + if depth == 0 { + return Some(i); + } + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::main_screen::right_panel::pull_request::fixtures::repo; + + fn prepared(text: &str) -> String { + prepare(text, &repo()) + } + + const ISSUES: &str = "https://github.com/stippi/code-assistant/issues"; + + #[test] + fn alerts_become_titled_quotes() { + assert_eq!( + prepared("> [!NOTE]\n> Images show as images.\n"), + "> **ℹ️ Note**\n>\n> Images show as images.\n" + ); + assert_eq!( + prepared(">[!warning] \n>Careful"), + ">**⚠️ Warning**\n>\n>Careful" + ); + } + + #[test] + fn only_a_quotes_first_line_is_an_alert() { + let text = "> Intro\n> [!TIP]\n\n[!NOTE] plain\n> [!NOTE] trailing text\n> [!UNKNOWN]"; + assert_eq!(prepared(text), text); + } + + #[test] + fn issue_references_link() { + assert_eq!( + prepared("Fixes #12, see foo/bar#3 and other#4."), + format!( + "Fixes [#12]({ISSUES}/12), see [foo/bar#3](https://github.com/foo/bar/issues/3) \ + and [other#4](https://github.com/other/code-assistant/issues/4)." + ) + ); + } + + #[test] + fn commits_link_shortened() { + assert_eq!( + prepared("In d9aea40f2b and foo/bar@abcdef12."), + "In [d9aea40](https://github.com/stippi/code-assistant/commit/d9aea40f2b) and \ + [foo/bar@abcdef1](https://github.com/foo/bar/commit/abcdef12)." + ); + } + + #[test] + fn mentions_link_to_profiles_and_teams() { + assert_eq!( + prepared("cc @octo-cat and @acme/core-team"), + "cc [@octo-cat](https://github.com/octo-cat) and \ + [@acme/core-team](https://github.com/orgs/acme/teams/core-team)" + ); + } + + #[test] + fn lookalikes_stay_text() { + let text = "mail me@example.com, #0, #12abc, x/y/#3, 1234567, deadbeefz, \\#5, a.b#3"; + assert_eq!(prepared(text), text); + } + + #[test] + fn code_links_tags_and_urls_stay_untouched() { + let text = "`#12` and ``a `#1` b`` [#12](https://x) ![#3](i.png) [#4][r] \ + https://github.com/o/r/pull/1#6 www.x.io/@me"; + assert_eq!(prepared(text), text); + } + + #[test] + fn fenced_code_stays_untouched() { + // A shorter fence does not close a longer one. + let text = "````rust\n// #12 @me\n```\nstill code #1\n````\nafter #2\n~~~\n#3\n~~~"; + assert_eq!( + prepared(text), + format!( + "````rust\n// #12 @me\n```\nstill code #1\n````\nafter [#2]({ISSUES}/2)\n~~~\n#3\n~~~" + ) + ); + } + + #[test] + fn brackets_without_a_target_are_text() { + assert_eq!( + prepared("[WIP] fixes #7"), + format!("[WIP] fixes [#7]({ISSUES}/7)") + ); + } + + #[test] + fn underscores_in_labels_are_escaped() { + assert_eq!( + prepared("my_org/my_repo#9"), + "[my\\_org/my\\_repo#9](https://github.com/my_org/my_repo/issues/9)" + ); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/mod.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/mod.rs new file mode 100644 index 000000000..382b17717 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/mod.rs @@ -0,0 +1,621 @@ +//! The "Pull request" view of the right panel (`docs/pull-request-view.md`): +//! the session's pull request, or the pull requests of its repository to +//! pick one from; a back arrow returns to the list. +//! +//! The views ([`list::PullRequestList`], [`detail::DetailView`] with its +//! header and tab bodies) render model values from the `github` crate and +//! say what the user asks for as [`PullRequestIntent`]s. The panel carries +//! them out: navigation itself, copying and opening links, reading and +//! writing GitHub through the `SessionService` (the detail's writes through +//! its [`data::PullRequestData`]). The core's announcements arrive on the +//! [`bus`]; the list is read again when its repository's lists changed. + +mod actions; +mod avatar; +pub mod bus; +mod code; +mod composer; +mod conversation; +pub mod data; +mod detail; +#[cfg(test)] +mod fixtures; +mod header; +mod list; +mod list_logic; +mod list_overrides; +mod markdown; +pub mod preferences; +mod presentation; +pub mod review_draft; +mod summary; +mod tab_bar; +mod timeline; + +use bus::{PullRequestEvent, bus}; +use data::session_service; +use detail::DetailView; +use github::RepoRef; +use github::model::{Action, ListQuery, PullRequestUpdate}; +use gpui_kit::{ + ClipboardItem, Context, Entity, Render, Subscription, Task, Window, div, prelude::*, +}; +use list::PullRequestList; + +/// How often relative times ("9h ago") are drawn anew. +const CLOCK_TICK: std::time::Duration = std::time::Duration::from_secs(60); + +/// What the user asks of the pull request view. +#[derive(Clone, Debug, PartialEq)] +pub enum PullRequestIntent { + /// Show a pull request of the listed repository. + Open(u64), + /// Back to the list. + Back, + /// List the repository's pull requests anew. + List(ListQuery), + /// Read the next page of the listing. + ListMore(ListQuery), + /// Read the line counts of these listed pull requests. + ListStats(Vec), + /// Act on a listed pull request (a quick action); the list shows the + /// outcome already and takes it back (`token`) if GitHub refuses. + ActOn { + number: u64, + action: Action, + token: u64, + }, + /// Read what is shown again, past the cache. + Refresh, + /// Merge, close, reopen, draft or ready, auto-merge, update or delete + /// the branch of the shown pull request. + Act(Action), + /// Rewrite the shown pull request's title, body or maintainer edits. + Update(PullRequestUpdate), + OpenUrl(String), + Copy(String), +} + +pub struct PullRequestPanel { + session_id: Option, + /// The tab is shown (the detail polls only then). + visible: bool, + repo: Option, + list: Entity, + detail: Option>, + /// Finding the session's repository and pull request. + resolve: Option>, + list_read: Option>, + more_read: Option>, + viewer_read: Option>, + /// Redraws once a minute while shown, so "9h ago" stays true. + _clock: Option>, + _list_subscription: Subscription, + _detail_subscription: Option, + _bus: Subscription, +} + +impl PullRequestPanel { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let list = cx.new(|cx| PullRequestList::new(window, cx)); + let list_subscription = cx.subscribe(&list, |this, _, intent: &PullRequestIntent, cx| { + this.handle(intent, cx) + }); + let events = bus(cx); + let bus = cx.subscribe(&events, |this, _, event: &PullRequestEvent, cx| { + if let PullRequestEvent::ListChanged { repo } = event + && this.visible + && this.repo.as_ref() == Some(repo) + { + this.load_list(cx); + } + }); + Self { + session_id: None, + visible: false, + repo: None, + list, + detail: None, + resolve: None, + list_read: None, + more_read: None, + viewer_read: None, + _clock: None, + _list_subscription: list_subscription, + _detail_subscription: None, + _bus: bus, + } + } + + /// Follow `session_id` while the tab is shown (`None` while hidden, + /// which keeps what is shown for its return). + pub fn set_session(&mut self, session_id: Option, cx: &mut Context) { + let visible = session_id.is_some(); + if visible != self.visible { + self.visible = visible; + if let Some(detail) = &self.detail { + detail.update(cx, |detail, cx| detail.set_visible(visible, cx)); + } + self._clock = visible.then(|| self.start_clock(cx)); + } + let Some(session_id) = session_id else { + return; + }; + if self.session_id.as_deref() == Some(session_id.as_str()) { + // Back from hiding: the listing may have changed meanwhile. + self.load_list(cx); + return; + } + self.session_id = Some(session_id.clone()); + self.set_repository(None, None, cx); + self.resolve_repository(session_id, cx); + } + + fn start_clock(&self, cx: &mut Context) -> Task<()> { + cx.spawn(async move |this, cx| { + loop { + cx.background_executor().timer(CLOCK_TICK).await; + let ticked = this.update(cx, |this, cx| { + this.list.update(cx, |_, cx| cx.notify()); + if let Some(detail) = &this.detail { + detail.update(cx, |_, cx| cx.notify()); + } + cx.notify(); + }); + if ticked.is_err() { + return; + } + } + }) + } + + /// Ask the core where the session's pull requests live. + fn resolve_repository(&mut self, session_id: String, cx: &mut Context) { + let Some(service) = session_service(cx) else { + return; + }; + self.list + .update(cx, |list, cx| list.set_resolving(true, cx)); + self.resolve = Some(cx.spawn(async move |this, cx| { + let found = service.session_pull_requests(session_id.clone()).await; + this.update(cx, |this, cx| { + if this.session_id.as_deref() != Some(session_id.as_str()) { + return; + } + this.list + .update(cx, |list, cx| list.set_resolving(false, cx)); + match found { + Ok(Some(found)) => this.set_repository(Some(found.repo), found.own, cx), + Ok(None) => {} + Err(error) => this.list.update(cx, |list, cx| { + list.set_error(Some(format!("{error:#}")), cx) + }), + } + }) + .ok(); + })); + } + + /// Where the session's pull requests live and which one is its own, + /// which is shown first. + pub fn set_repository( + &mut self, + repo: Option, + own: Option, + cx: &mut Context, + ) { + self.repo = repo.clone(); + self.close_detail(); + self.list.update(cx, |list, cx| list.set_repo(repo, cx)); + self.load_list(cx); + self.read_viewer(cx); + if let Some(number) = own { + self.open(number, cx); + } + cx.notify(); + } + + /// Read the listing the list asks for: the held one at once, a newer + /// one announced by the core. + fn load_list(&mut self, cx: &mut Context) { + let (Some(repo), Some(service)) = (self.repo.clone(), session_service(cx)) else { + return; + }; + let query = self.list.read(cx).query(); + self.list_read = Some(cx.spawn(async move |this, cx| { + let page = service.pull_request_list(repo.clone(), query).await; + this.update(cx, |this, cx| { + if this.repo.as_ref() != Some(&repo) { + return; + } + this.list.update(cx, |list, cx| match page { + Ok(fetched) => { + list.set_page(fetched.value, cx); + list.set_refreshing(fetched.refreshing, cx); + } + Err(error) => { + list.set_error(Some(format!("{error:#}")), cx); + list.set_refreshing(false, cx); + } + }) + }) + .ok(); + })); + } + + /// A quick action on a listed pull request. + fn act_on(&mut self, number: u64, action: Action, token: u64, cx: &mut Context) { + let (Some(repo), Some(service)) = (self.repo.clone(), session_service(cx)) else { + self.list + .update(cx, |list, cx| list.revert(number, token, cx)); + return; + }; + let list = self.list.downgrade(); + cx.spawn(async move |_, cx| { + let acted = service + .pull_request_action(repo.pull_request(number), action) + .await; + if let Err(error) = acted { + list.update(cx, |list, cx| { + list.revert(number, token, cx); + list.set_error(Some(format!("#{number}: {error:#}")), cx); + }) + .ok(); + } + }) + .detach(); + } + + /// Take the list's override back when `done` says the action failed. + fn revert_unless( + &mut self, + done: Task, + number: u64, + token: Option, + cx: &mut Context, + ) { + let Some(token) = token else { + done.detach(); + return; + }; + let list = self.list.downgrade(); + cx.spawn(async move |_, cx| { + if !done.await { + list.update(cx, |list, cx| list.revert(number, token, cx)) + .ok(); + } + }) + .detach(); + } + + /// The next page of the listing. + fn load_more(&mut self, query: ListQuery, cx: &mut Context) { + let (Some(repo), Some(service)) = (self.repo.clone(), session_service(cx)) else { + return; + }; + let list = self.list.downgrade(); + self.more_read = Some(cx.spawn(async move |_, cx| { + let page = service.pull_request_list(repo, query).await; + list.update(cx, |list, cx| match page { + Ok(fetched) => list.append_page(fetched.value, cx), + Err(error) => list.more_failed(format!("{error:#}"), cx), + }) + .ok(); + })); + } + + /// Line counts of listed pull requests that came without them. + fn load_stats(&mut self, numbers: Vec, cx: &mut Context) { + let (Some(repo), Some(service)) = (self.repo.clone(), session_service(cx)) else { + return; + }; + let prs = numbers.iter().map(|n| repo.pull_request(*n)).collect(); + let list = self.list.downgrade(); + cx.spawn( + async move |_, cx| match service.pull_request_list_stats(prs).await { + Ok(stats) => { + list.update(cx, |list, cx| list.set_stats(&stats, cx)).ok(); + } + Err(error) => tracing::debug!("no line counts: {error:#}"), + }, + ) + .detach(); + } + + /// Drop the repository's cached lists and read the listing anew. + fn refresh_list(&mut self, cx: &mut Context) { + let (Some(repo), Some(service)) = (self.repo.clone(), session_service(cx)) else { + return; + }; + self.list + .update(cx, |list, cx| list.set_refreshing(true, cx)); + self.list_read = Some(cx.spawn(async move |this, cx| { + let refreshed = service.pull_request_list_refresh(repo).await; + this.update(cx, |this, cx| match refreshed { + Ok(()) => this.load_list(cx), + Err(error) => this.list.update(cx, |list, cx| { + list.set_error(Some(format!("{error:#}")), cx); + list.set_refreshing(false, cx); + }), + }) + .ok(); + })); + } + + /// Who is signed in decides the list's groups. + fn read_viewer(&mut self, cx: &mut Context) { + let (Some(repo), Some(service)) = (self.repo.clone(), session_service(cx)) else { + return; + }; + self.viewer_read = Some(cx.spawn(async move |this, cx| { + let viewer = service.github_viewer(repo.host).await; + this.update(cx, |this, cx| match viewer { + Ok(viewer) => this + .list + .update(cx, |list, cx| list.set_viewer(Some(viewer.login), cx)), + Err(error) => tracing::debug!("no GitHub viewer: {error:#}"), + }) + .ok(); + })); + } + + /// Read what is shown again (the window became active): the held value + /// at once, a newer one where it is past its time. + pub fn reload(&mut self, cx: &mut Context) { + match &self.detail { + Some(detail) => detail.update(cx, |detail, cx| detail.set_visible(true, cx)), + None => self.load_list(cx), + } + } + + #[cfg(test)] + pub fn list(&self) -> &Entity { + &self.list + } + + #[cfg(test)] + pub fn detail(&self) -> Option<&Entity> { + self.detail.as_ref() + } + + fn handle(&mut self, intent: &PullRequestIntent, cx: &mut Context) { + match intent { + PullRequestIntent::Open(number) => self.open(*number, cx), + PullRequestIntent::Back => { + self.close_detail(); + cx.notify(); + } + PullRequestIntent::OpenUrl(url) => cx.open_url(url), + PullRequestIntent::Copy(text) => { + cx.write_to_clipboard(ClipboardItem::new_string(text.clone())) + } + PullRequestIntent::List(_) => self.load_list(cx), + PullRequestIntent::Refresh => match &self.detail { + Some(detail) => detail.update(cx, |detail, cx| detail.refresh(cx)), + None => self.refresh_list(cx), + }, + PullRequestIntent::Act(action) => { + if let Some(detail) = self.detail.clone() { + let action = *action; + let number = detail.read(cx).pr().number; + // The list shows the outcome already, as after a quick action. + let token = self + .list + .update(cx, |list, cx| list.override_row(number, &action, cx)); + let done = detail.update(cx, |detail, cx| detail.act(action, cx)); + self.revert_unless(done, number, token, cx); + } + } + PullRequestIntent::ActOn { + number, + action, + token, + } => self.act_on(*number, *action, *token, cx), + PullRequestIntent::ListMore(query) => self.load_more(query.clone(), cx), + PullRequestIntent::ListStats(numbers) => self.load_stats(numbers.clone(), cx), + PullRequestIntent::Update(update) => { + if let Some(detail) = &self.detail { + let update = update.clone(); + detail.update(cx, |detail, cx| detail.update(update, cx)); + } + } + } + } + + fn open(&mut self, number: u64, cx: &mut Context) { + let Some(repo) = &self.repo else { + return; + }; + let pr = repo.pull_request(number); + if self.detail.as_ref().is_some_and(|d| *d.read(cx).pr() == pr) { + return; + } + let seed = self.list.read(cx).entry(number).cloned(); + let visible = self.visible; + let detail = cx.new(|cx| { + let mut detail = DetailView::new(pr, seed, true, cx); + if !visible { + detail.set_visible(false, cx); + } + detail + }); + self._detail_subscription = Some( + cx.subscribe(&detail, |this, _, intent: &PullRequestIntent, cx| { + this.handle(intent, cx) + }), + ); + self.detail = Some(detail); + cx.notify(); + } + + fn close_detail(&mut self) { + self.detail = None; + self._detail_subscription = None; + } +} + +impl Render for PullRequestPanel { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let body = match &self.detail { + Some(detail) => detail.clone().into_any_element(), + None => self.list.clone().into_any_element(), + }; + div().size_full().child(body) + } +} + +#[cfg(test)] +mod tests { + use super::fixtures::{self, entry, repo}; + use super::*; + use detail::DetailTab; + use github::model::ListPage; + use gpui_kit::{TestAppContext, VisualTestContext}; + + fn panel(cx: &mut TestAppContext) -> (Entity, &mut VisualTestContext) { + let window = cx.update(|cx| { + gpui_kit::component::init(cx); + cx.open_window(Default::default(), |window, cx| { + cx.new(|cx| PullRequestPanel::new(window, cx)) + }) + .unwrap() + }); + let view = window.root(cx).unwrap(); + let cx = VisualTestContext::from_window(window.into(), cx).into_mut(); + view.update(cx, |panel, cx| panel.set_session(Some("a".into()), cx)); + (view, cx) + } + + /// The detail page as if `detail` was read. + fn show(view: &Entity, detail: github::model::Detail, cx: &mut VisualTestContext) { + let data = view.read_with(cx, |view, _| view.data().clone()); + data.update(cx, |data, cx| { + data.detail = Some(std::sync::Arc::new(detail)); + cx.notify(); + }); + cx.run_until_parked(); + } + + fn click(cx: &mut VisualTestContext, selector: &'static str) { + let bounds = cx + .debug_bounds(selector) + .unwrap_or_else(|| panic!("nothing rendered as {selector}")); + cx.simulate_click(bounds.center(), gpui_kit::Modifiers::none()); + cx.run_until_parked(); + } + + #[gpui_kit::test] + fn the_sessions_own_pull_request_shows_first(cx: &mut TestAppContext) { + let (panel, cx) = panel(cx); + panel.update(cx, |panel, cx| { + panel.set_repository(Some(repo()), Some(290), cx) + }); + let detail = panel.read_with(cx, |panel, _| panel.detail().cloned()); + let detail = detail.expect("the detail is shown"); + show(&detail, fixtures::detail(290), cx); + cx.run_until_parked(); + assert!(cx.debug_bounds("pr-primary").is_some()); + + click(cx, "pr-back"); + panel.read_with(cx, |panel, _| assert!(panel.detail().is_none())); + } + + #[gpui_kit::test] + fn a_row_opens_its_pull_request_seeded_from_the_list(cx: &mut TestAppContext) { + let (panel, cx) = panel(cx); + panel.update(cx, |panel, cx| { + panel.set_repository(Some(repo()), None, cx); + panel.list().update(cx, |list, cx| { + list.set_page( + ListPage { + entries: vec![entry(281, "One kind of sub-agent", "ann")], + next_cursor: None, + }, + cx, + ) + }); + }); + cx.run_until_parked(); + click(cx, "pr-row-281"); + let detail = panel + .read_with(cx, |panel, _| panel.detail().cloned()) + .expect("the row opened"); + assert!(cx.debug_bounds("pr-title").is_some()); + detail.read_with(cx, |view, _| { + assert_eq!(view.pr().number, 281); + assert_eq!(view.tab(), DetailTab::Summary); + }); + } + + #[gpui_kit::test] + fn the_tab_row_switches_tabs(cx: &mut TestAppContext) { + let (panel, cx) = panel(cx); + panel.update(cx, |panel, cx| { + panel.set_repository(Some(repo()), Some(290), cx) + }); + let detail = panel + .read_with(cx, |panel, _| panel.detail().cloned()) + .unwrap(); + show(&detail, fixtures::detail(290), cx); + cx.run_until_parked(); + click(cx, "pr-tab-Code"); + detail.read_with(cx, |view, _| assert_eq!(view.tab(), DetailTab::Code)); + } + + #[gpui_kit::test] + fn an_action_on_the_shown_pull_request_shows_in_the_list_until_refused( + cx: &mut TestAppContext, + ) { + let (panel, cx) = panel(cx); + panel.update(cx, |panel, cx| { + panel.set_repository(Some(repo()), None, cx); + panel.list().update(cx, |list, cx| { + list.set_page( + ListPage { + entries: vec![entry(290, "Raster images", "ann")], + next_cursor: None, + }, + cx, + ) + }); + panel.open(290, cx); + }); + let listed = |panel: &Entity, cx: &mut VisualTestContext| { + panel.read_with(cx, |panel, cx| panel.list().read(cx).shows(290)) + }; + assert!(listed(&panel, cx)); + panel.update(cx, |panel, cx| { + panel.handle(&PullRequestIntent::Act(Action::Close), cx) + }); + assert!(!listed(&panel, cx), "closed: out of the open ones at once"); + // No session service: GitHub never saw it, the row is taken back. + cx.run_until_parked(); + assert!(listed(&panel, cx)); + } + + #[gpui_kit::test] + fn copying_lands_on_the_clipboard(cx: &mut TestAppContext) { + let (panel, cx) = panel(cx); + panel.update(cx, |panel, cx| { + panel.handle(&PullRequestIntent::Copy("gh pr checkout 290".into()), cx) + }); + let copied = cx.update(|_, cx| cx.read_from_clipboard().and_then(|c| c.text())); + assert_eq!(copied.as_deref(), Some("gh pr checkout 290")); + } + + #[gpui_kit::test] + fn another_session_starts_over(cx: &mut TestAppContext) { + let (panel, cx) = panel(cx); + panel.update(cx, |panel, cx| { + panel.set_repository(Some(repo()), Some(290), cx) + }); + // Hiding the tab keeps the pull request shown. + panel.update(cx, |panel, cx| panel.set_session(None, cx)); + panel.read_with(cx, |panel, _| assert!(panel.detail().is_some())); + panel.update(cx, |panel, cx| panel.set_session(Some("b".into()), cx)); + panel.read_with(cx, |panel, _| { + assert!(panel.detail().is_none()); + assert!(panel.repo.is_none()); + }); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/preferences.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/preferences.rs new file mode 100644 index 000000000..eda1b1c31 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/preferences.rs @@ -0,0 +1,76 @@ +//! What the pull request view remembers across restarts: how the list is +//! sorted and filtered, and the merge method last used per repository. +//! Kept with the other UI settings ([`UiSettings`](crate::shared::settings::UiSettings)). + +use super::list_logic::{Filters, SortMode}; +use github::RepoRef; +use github::model::{Involvement, ListState, MergeMethod}; +use gpui_kit::App; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct PullRequestPreferences { + pub list: ListPreferences, + /// The merge method last used, by repository (`host/owner/name`). + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub merge_methods: BTreeMap, + /// The Summary's comment filter and order, the Timeline's order. + pub conversation: super::conversation::preferences::ConversationPreferences, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct ListPreferences { + pub sort: SortMode, + pub filters: Filters, + pub state: ListState, + pub involvement: Involvement, +} + +impl PullRequestPreferences { + pub fn merge_method(&self, repo: &RepoRef) -> Option { + self.merge_methods.get(&repo.to_string()).copied() + } +} + +/// The remembered preferences; the defaults before settings are loaded. +pub fn read(cx: &App) -> PullRequestPreferences { + cx.try_global::() + .map(|settings| settings.0.pull_requests.clone()) + .unwrap_or_default() +} + +/// Change the preferences; they are saved with the UI settings. +pub fn update(cx: &mut App, change: impl FnOnce(&mut PullRequestPreferences)) { + crate::update_ui_settings(cx, |settings| change(&mut settings.pull_requests)); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::main_screen::right_panel::pull_request::fixtures::repo; + use crate::main_screen::right_panel::pull_request::list_logic::DraftFilter; + + #[test] + fn preferences_survive_a_round_trip_and_default_when_missing() { + let mut preferences = PullRequestPreferences::default(); + preferences.list.sort = SortMode::Newest; + preferences.list.state = ListState::Merged; + preferences.list.filters.draft = Some(DraftFilter::Hide); + preferences + .merge_methods + .insert(repo().to_string(), MergeMethod::Squash); + let json = serde_json::to_string(&preferences).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + preferences + ); + assert_eq!(preferences.merge_method(&repo()), Some(MergeMethod::Squash)); + assert_eq!( + serde_json::from_str::("{}").unwrap(), + PullRequestPreferences::default() + ); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/presentation.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/presentation.rs new file mode 100644 index 000000000..50393bab3 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/presentation.rs @@ -0,0 +1,474 @@ +//! How pull requests look across the view: the colour and glyph of a state, +//! of checks and of a review decision, line counts (`+1,933 −179`) and +//! relative times ("9h ago"). Colours follow T3 Code: open emerald, draft +//! zinc, closed red, merged violet; approved emerald, changes requested +//! amber, pending checks amber. + +use crate::diff::render::{added_row_colors, deleted_row_colors}; +use crate::shared::theme::merged_violet; +use chrono::{DateTime, Utc}; +use github::model::{ + Check, CheckStatus, ChecksState, MergeMethod, Mergeability, PullRequestState, ReviewDecision, +}; +use gpui_kit::component::ActiveTheme; +use gpui_kit::{ + Animation, AnimationExt, AnyElement, App, Hsla, Pixels, SharedString, Transformation, div, + percentage, prelude::*, px, svg, +}; +use std::time::Duration; + +/// What a pull request's state is shown as: a draft is open but looks apart. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Tone { + Open, + Draft, + Closed, + Merged, +} + +impl Tone { + pub fn of(state: PullRequestState, is_draft: bool) -> Self { + match state { + PullRequestState::Open if is_draft => Tone::Draft, + PullRequestState::Open => Tone::Open, + PullRequestState::Closed => Tone::Closed, + PullRequestState::Merged => Tone::Merged, + } + } + + pub fn label(self) -> &'static str { + match self { + Tone::Open => "Open", + Tone::Draft => "Draft", + Tone::Closed => "Closed", + Tone::Merged => "Merged", + } + } + + pub fn icon(self) -> &'static str { + match self { + Tone::Open => "icons/git_pull_request.svg", + Tone::Draft => "icons/git_pull_request_draft.svg", + Tone::Closed => "icons/git_pull_request_closed.svg", + Tone::Merged => "icons/git_merge.svg", + } + } + + pub fn color(self, cx: &App) -> Hsla { + match self { + Tone::Open => cx.theme().success, + Tone::Draft => cx.theme().muted_foreground, + Tone::Closed => cx.theme().danger, + Tone::Merged => merged_violet(), + } + } +} + +/// An icon with its colour and what it means (the tooltip). +#[derive(Clone, Debug)] +pub struct Glyph { + pub icon: &'static str, + pub color: Hsla, + pub label: SharedString, + /// Turns while something runs; the id of its animation, unique among + /// the glyphs drawn beside it. + pub spin: Option, +} + +impl Glyph { + pub fn new(icon: &'static str, color: Hsla, label: impl Into) -> Self { + Self { + icon, + color, + label: label.into(), + spin: None, + } + } + + pub fn render(&self, size: Pixels) -> AnyElement { + let glyph = svg() + .flex_none() + .size(size) + .path(self.icon) + .text_color(self.color); + match &self.spin { + Some(id) => glyph + .with_animation( + id.clone(), + Animation::new(Duration::from_secs(1)).repeat(), + |svg, delta| svg.with_transformation(Transformation::rotate(percentage(delta))), + ) + .into_any_element(), + None => glyph.into_any_element(), + } + } +} + +/// The warning on an open pull request that conflicts with its base. +pub fn conflict_glyph( + state: PullRequestState, + is_draft: bool, + mergeability: Mergeability, + base_branch: &str, + cx: &App, +) -> Option { + (state == PullRequestState::Open && !is_draft && mergeability == Mergeability::Conflicting) + .then(|| { + Glyph::new( + "icons/triangle_alert.svg", + cx.theme().danger, + format!("Conflicts with {base_branch}"), + ) + }) +} + +pub fn checks_glyph(state: ChecksState, cx: &App) -> Glyph { + let (icon, color, label) = match state { + ChecksState::Passing => ( + "icons/circle_check.svg", + cx.theme().success, + "All checks have passed", + ), + ChecksState::Failing => ( + "icons/circle_x.svg", + cx.theme().danger, + "Some checks were not successful", + ), + ChecksState::Pending => ( + "icons/circle_dot.svg", + cx.theme().warning, + "Some checks haven't completed yet", + ), + }; + Glyph::new(icon, color, label) +} + +pub fn check_status_glyph(check: &Check, cx: &App) -> Glyph { + let muted = cx.theme().muted_foreground.opacity(0.7); + let (icon, color) = match check.status { + CheckStatus::Pending => ("icons/loader_circle.svg", cx.theme().warning), + CheckStatus::ActionRequired => ("icons/circle_dot.svg", cx.theme().warning), + CheckStatus::Success => ("icons/circle_check.svg", cx.theme().success), + CheckStatus::Failure | CheckStatus::Cancelled => ("icons/circle_x.svg", cx.theme().danger), + CheckStatus::Skipped => ("icons/circle_slash.svg", muted), + CheckStatus::Neutral => ("icons/circle_dashed.svg", muted), + }; + let glyph = Glyph::new(icon, color, check_status_label(check)); + match check.status { + CheckStatus::Pending => Glyph { + spin: Some(format!("check-spin-{}", check.name).into()), + ..glyph + }, + _ => glyph, + } +} + +pub fn review_glyph(decision: ReviewDecision, cx: &App) -> Glyph { + let (icon, color, label) = match decision { + ReviewDecision::Approved => ("icons/user_check.svg", cx.theme().success, "Approved"), + ReviewDecision::ChangesRequested => ( + "icons/user_round_x.svg", + cx.theme().warning, + "Changes requested", + ), + ReviewDecision::ReviewRequired => ( + "icons/user_round.svg", + cx.theme().muted_foreground.opacity(0.6), + "Awaiting review", + ), + }; + Glyph::new(icon, color, label) +} + +pub fn merge_method_label(method: MergeMethod) -> &'static str { + match method { + MergeMethod::Merge => "Merge", + MergeMethod::Squash => "Squash and merge", + MergeMethod::Rebase => "Rebase and merge", + } +} + +/// A workflow run of an outside contributor waits for a maintainer to let +/// it run. +fn is_workflow_approval(check: &Check) -> bool { + check.status == CheckStatus::ActionRequired + && check + .url + .as_deref() + .and_then(|url| url.split_once("/actions/runs/")) + .is_some_and(|(_, rest)| { + let digits = rest.bytes().take_while(u8::is_ascii_digit).count(); + digits > 0 && matches!(rest.as_bytes().get(digits), None | Some(b'/')) + }) +} + +pub fn check_status_label(check: &Check) -> &'static str { + if is_workflow_approval(check) { + return "Awaiting approval"; + } + match check.status { + CheckStatus::Pending => "Running", + CheckStatus::ActionRequired => "Awaiting action", + CheckStatus::Success => "Passed", + CheckStatus::Failure => "Failed", + CheckStatus::Cancelled => "Cancelled", + CheckStatus::Skipped => "Skipped", + CheckStatus::Neutral => "Neutral", + } +} + +/// The verdict over `checks`: any failure fails, anything unfinished is +/// pending; `None` without checks or without a single success. +pub fn checks_state(checks: &[Check]) -> Option { + let any = |statuses: &[CheckStatus]| checks.iter().any(|c| statuses.contains(&c.status)); + if any(&[CheckStatus::Failure, CheckStatus::Cancelled]) { + Some(ChecksState::Failing) + } else if any(&[CheckStatus::Pending, CheckStatus::ActionRequired]) { + Some(ChecksState::Pending) + } else if any(&[CheckStatus::Success]) { + Some(ChecksState::Passing) + } else { + None + } +} + +/// The verdict the controls go by: the host's rollup, else the one over +/// `checks`. A workflow waiting for approval keeps a pull request pending +/// even where the rollup forgot it. +pub fn effective_checks_state( + rollup: Option, + checks: &[Check], +) -> Option { + let state = rollup.or_else(|| checks_state(checks)); + let awaiting = checks + .iter() + .any(|check| check.status == CheckStatus::ActionRequired); + if awaiting && state != Some(ChecksState::Failing) { + Some(ChecksState::Pending) + } else { + state + } +} + +fn plural(count: usize, one: &str, many: &str) -> String { + format!("{count} {}", if count == 1 { one } else { many }) +} + +/// "5 of 6 passing", "1 of 6 failing", "2 of 6 running" and the like. +pub fn summarize_checks(checks: &[Check]) -> String { + let total = checks.len(); + if total == 0 { + return "No checks reported".into(); + } + let count = |statuses: &[CheckStatus]| { + checks + .iter() + .filter(|c| statuses.contains(&c.status)) + .count() + }; + let failed = count(&[CheckStatus::Failure, CheckStatus::Cancelled]); + let action_required = count(&[CheckStatus::ActionRequired]); + let approvals = checks.iter().filter(|c| is_workflow_approval(c)).count(); + let other_actions = action_required - approvals; + let pending = count(&[CheckStatus::Pending]); + let passed = count(&[CheckStatus::Success]); + if failed > 0 { + format!("{failed} of {total} failing") + } else if approvals > 0 && other_actions > 0 { + format!( + "{} and {} awaiting action", + plural(approvals, "workflow", "workflows"), + plural(other_actions, "check", "checks") + ) + } else if approvals > 0 { + format!( + "{} awaiting approval", + plural(approvals, "workflow", "workflows") + ) + } else if other_actions > 0 { + format!( + "{} awaiting action", + plural(other_actions, "check", "checks") + ) + } else if pending > 0 { + format!("{pending} of {total} running") + } else if passed == total { + "All checks passed".into() + } else { + format!("{passed} of {total} passing") + } +} + +/// "just now", "12m ago", "9h ago", "185d ago". +pub fn relative_time(then: DateTime, now: DateTime) -> String { + let seconds = (now - then).num_seconds(); + if seconds < 60 { + return "just now".into(); + } + let minutes = seconds / 60; + if minutes < 60 { + return format!("{minutes}m ago"); + } + let hours = minutes / 60; + if hours < 24 { + return format!("{hours}h ago"); + } + format!("{}d ago", hours / 24) +} + +/// `1933` → `1,933`. +pub fn format_count(count: u64) -> String { + let digits = count.to_string(); + let mut out = String::with_capacity(digits.len() + digits.len() / 3); + for (i, digit) in digits.chars().enumerate() { + if i > 0 && (digits.len() - i).is_multiple_of(3) { + out.push(','); + } + out.push(digit); + } + out +} + +/// `+1,933 −179` in the diff colours and the mono font; nothing while both +/// are zero (not counted yet). +pub fn diff_stat(additions: u64, deletions: u64, cx: &App) -> Option { + if additions == 0 && deletions == 0 { + return None; + } + let theme = cx.theme(); + Some( + div() + .flex_none() + .flex() + .items_baseline() + .gap_1() + .font_family(theme.mono_font_family.clone()) + .child( + div() + .text_color(added_row_colors(theme).1) + .child(format!("+{}", format_count(additions))), + ) + .child( + div() + .text_color(deleted_row_colors(theme).1) + .child(format!("−{}", format_count(deletions))), + ) + .into_any_element(), + ) +} + +/// A small glyph: `svg` sized for a row. +pub const GLYPH_SIZE: Pixels = px(14.); + +#[cfg(test)] +mod tests { + use super::*; + use crate::main_screen::right_panel::pull_request::fixtures::{check, hours_ago, now}; + use chrono::Duration; + + #[gpui_kit::test] + fn only_a_running_check_spins(cx: &mut gpui_kit::TestAppContext) { + cx.update(|cx| { + gpui_kit::component::init(cx); + let running = check_status_glyph(&check("build", CheckStatus::Pending), cx); + assert_eq!(running.spin.as_deref(), Some("check-spin-build")); + let done = check_status_glyph(&check("build", CheckStatus::Success), cx); + assert!(done.spin.is_none()); + }); + } + + #[test] + fn relative_times() { + assert_eq!( + relative_time(now() - Duration::seconds(59), now()), + "just now" + ); + assert_eq!( + relative_time(now() - Duration::minutes(12), now()), + "12m ago" + ); + assert_eq!(relative_time(hours_ago(9), now()), "9h ago"); + assert_eq!(relative_time(hours_ago(24 * 185 + 5), now()), "185d ago"); + // A clock running behind the host's. + assert_eq!( + relative_time(now() + Duration::minutes(3), now()), + "just now" + ); + } + + #[test] + fn counts_group_thousands() { + assert_eq!(format_count(0), "0"); + assert_eq!(format_count(179), "179"); + assert_eq!(format_count(1933), "1,933"); + assert_eq!(format_count(1_234_567), "1,234,567"); + } + + #[test] + fn checks_summaries() { + use CheckStatus::*; + let of = |statuses: &[CheckStatus]| { + let checks: Vec = statuses.iter().map(|s| check("ci", *s)).collect(); + (summarize_checks(&checks), checks_state(&checks)) + }; + assert_eq!(of(&[]), ("No checks reported".into(), None)); + assert_eq!( + of(&[Success, Success, Skipped]), + ("2 of 3 passing".into(), Some(ChecksState::Passing)) + ); + assert_eq!( + of(&[Success, Success]), + ("All checks passed".into(), Some(ChecksState::Passing)) + ); + assert_eq!( + of(&[Success, Failure, Pending]), + ("1 of 3 failing".into(), Some(ChecksState::Failing)) + ); + assert_eq!( + of(&[Success, Pending, Pending]), + ("2 of 3 running".into(), Some(ChecksState::Pending)) + ); + assert_eq!( + of(&[ActionRequired]), + ("1 check awaiting action".into(), Some(ChecksState::Pending)) + ); + assert_eq!(of(&[Neutral, Skipped]).1, None); + } + + #[test] + fn workflow_runs_awaiting_approval_are_told_apart() { + let mut run = check("build", CheckStatus::ActionRequired); + run.url = Some("https://github.com/o/r/actions/runs/123/job/4".into()); + assert_eq!(check_status_label(&run), "Awaiting approval"); + assert_eq!( + summarize_checks(&[run.clone()]), + "1 workflow awaiting approval" + ); + let other = check("deploy", CheckStatus::ActionRequired); + assert_eq!( + summarize_checks(&[run.clone(), run, other]), + "2 workflows and 1 check awaiting action" + ); + } + + #[test] + fn a_workflow_awaiting_approval_keeps_checks_pending() { + let awaiting = [check("build", CheckStatus::ActionRequired)]; + assert_eq!( + effective_checks_state(Some(ChecksState::Passing), &awaiting), + Some(ChecksState::Pending) + ); + assert_eq!( + effective_checks_state(Some(ChecksState::Failing), &awaiting), + Some(ChecksState::Failing) + ); + } + + #[test] + fn a_draft_is_its_own_tone() { + assert_eq!(Tone::of(PullRequestState::Open, true), Tone::Draft); + assert_eq!(Tone::of(PullRequestState::Open, false), Tone::Open); + // Closed and merged drafts show their state. + assert_eq!(Tone::of(PullRequestState::Merged, true), Tone::Merged); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/review_draft.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/review_draft.rs new file mode 100644 index 000000000..94894c034 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/review_draft.rs @@ -0,0 +1,219 @@ +//! The pending review of each pull request: line comments added "to +//! review" in the Code tab, which wait (as dashed cards on their lines) +//! until the review is submitted with a verdict and a summary. In memory +//! only, app-wide, like T3 Code's `pullRequestReviewStore`. +//! +//! One [`ReviewDrafts`] entity per app ([`review_drafts`]); views observe +//! it. Submitting, for the composer: +//! +//! ```ignore +//! let drafts = review_drafts(cx); +//! let (review, ids) = drafts.read(cx).submission(&pr, verdict, body); +//! // service.pull_request_submit_review(pr.clone(), review).await, then on success: +//! drafts.update(cx, |drafts, cx| drafts.remove_submitted(&pr, &ids, cx)); +//! ``` +//! +//! Only the submitted comments go: one added while the review was under +//! way stays pending. + +use github::PullRequestRef; +use github::model::{DiffSide, ReviewCommentDraft, ReviewSubmission, ReviewVerdict}; +use gpui_kit::{App, AppContext, Context, Entity, Global}; +use std::collections::HashMap; + +/// A line comment of the pending review, anchored like GitHub wants it: +/// `line` on `side` (LEFT the old file, RIGHT the new one), a range from +/// `start_line` on `start_side`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PendingComment { + /// Unique in the app; given by [`ReviewDrafts::add`]. + pub id: u64, + pub path: String, + pub side: DiffSide, + pub line: u64, + pub start_side: Option, + pub start_line: Option, + pub body: String, + /// The commented rows as shown, `+`/`-`/` ` prefixed. + pub excerpt: String, +} + +impl PendingComment { + pub fn draft(&self) -> ReviewCommentDraft { + ReviewCommentDraft { + path: self.path.clone(), + line: self.line, + side: self.side, + start_line: self.start_line, + start_side: self.start_side, + body: self.body.clone(), + } + } +} + +#[derive(Default)] +pub struct ReviewDrafts { + pending: HashMap>, + last_id: u64, +} + +struct ReviewDraftsGlobal(Entity); + +impl Global for ReviewDraftsGlobal {} + +/// The app's pending reviews, created with the first use. +pub fn review_drafts(cx: &mut App) -> Entity { + if let Some(global) = cx.try_global::() { + return global.0.clone(); + } + let drafts = cx.new(|_| ReviewDrafts::default()); + cx.set_global(ReviewDraftsGlobal(drafts.clone())); + drafts +} + +impl ReviewDrafts { + /// `pr`'s pending comments, in the order they were added. + pub fn comments(&self, pr: &PullRequestRef) -> &[PendingComment] { + self.pending.get(pr).map_or(&[], Vec::as_slice) + } + + pub fn count(&self, pr: &PullRequestRef) -> usize { + self.comments(pr).len() + } + + /// Add `comment` (its `id` is replaced) to `pr`'s review; returns the id. + pub fn add( + &mut self, + pr: &PullRequestRef, + mut comment: PendingComment, + cx: &mut Context, + ) -> u64 { + self.last_id += 1; + comment.id = self.last_id; + self.pending.entry(pr.clone()).or_default().push(comment); + cx.notify(); + self.last_id + } + + pub fn remove(&mut self, pr: &PullRequestRef, id: u64, cx: &mut Context) { + self.remove_submitted(pr, &[id], cx); + } + + /// The review to send: `verdict`, `body` and every pending comment, + /// plus the ids of those comments for [`Self::remove_submitted`]. + pub fn submission( + &self, + pr: &PullRequestRef, + verdict: ReviewVerdict, + body: String, + ) -> (ReviewSubmission, Vec) { + let comments = self.comments(pr); + ( + ReviewSubmission { + verdict, + body, + comments: comments.iter().map(PendingComment::draft).collect(), + }, + comments.iter().map(|c| c.id).collect(), + ) + } + + /// Drop the comments a submitted review carried. + pub fn remove_submitted(&mut self, pr: &PullRequestRef, ids: &[u64], cx: &mut Context) { + let Some(comments) = self.pending.get_mut(pr) else { + return; + }; + let before = comments.len(); + comments.retain(|c| !ids.contains(&c.id)); + if comments.len() != before { + if comments.is_empty() { + self.pending.remove(pr); + } + cx.notify(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use github::RepoRef; + use gpui_kit::TestAppContext; + + fn pr(number: u64) -> PullRequestRef { + RepoRef::new("github.com", "o", "r").pull_request(number) + } + + fn comment(path: &str, line: u64, body: &str) -> PendingComment { + PendingComment { + id: 0, + path: path.into(), + side: DiffSide::Right, + line, + start_side: None, + start_line: None, + body: body.into(), + excerpt: "+x".into(), + } + } + + #[gpui_kit::test] + fn comments_are_kept_per_pull_request(cx: &mut TestAppContext) { + let drafts = cx.update(review_drafts); + assert_eq!( + cx.update(review_drafts).entity_id(), + drafts.entity_id(), + "one per app" + ); + drafts.update(cx, |drafts, cx| { + let a = drafts.add(&pr(1), comment("a.rs", 3, "one"), cx); + let b = drafts.add(&pr(1), comment("b.rs", 4, "two"), cx); + drafts.add(&pr(2), comment("c.rs", 5, "elsewhere"), cx); + assert_ne!(a, b); + assert_eq!(drafts.count(&pr(1)), 2); + drafts.remove(&pr(1), a, cx); + let left: Vec<_> = drafts.comments(&pr(1)).iter().map(|c| c.id).collect(); + assert_eq!(left, [b]); + assert_eq!(drafts.count(&pr(2)), 1); + assert_eq!(drafts.count(&pr(3)), 0); + }); + } + + #[gpui_kit::test] + fn a_submission_carries_the_comments_and_removes_only_those(cx: &mut TestAppContext) { + let drafts = cx.update(|cx| cx.new(|_| ReviewDrafts::default())); + let notified = std::rc::Rc::new(std::cell::Cell::new(0)); + let _observer = cx.update(|cx| { + let notified = notified.clone(); + cx.observe(&drafts, move |_, _| notified.set(notified.get() + 1)) + }); + drafts.update(cx, |drafts, cx| { + let mut ranged = comment("a.rs", 9, "range"); + ranged.start_line = Some(7); + ranged.start_side = Some(DiffSide::Left); + drafts.add(&pr(1), ranged, cx); + let (review, ids) = + drafts.submission(&pr(1), ReviewVerdict::Approve, "Looks good".into()); + assert_eq!(review.verdict, ReviewVerdict::Approve); + assert_eq!(review.body, "Looks good"); + assert_eq!( + review.comments, + [ReviewCommentDraft { + path: "a.rs".into(), + line: 9, + side: DiffSide::Right, + start_line: Some(7), + start_side: Some(DiffSide::Left), + body: "range".into(), + }] + ); + // Added while the review was under way: stays. + let late = drafts.add(&pr(1), comment("b.rs", 1, "late"), cx); + drafts.remove_submitted(&pr(1), &ids, cx); + let left: Vec<_> = drafts.comments(&pr(1)).iter().map(|c| c.id).collect(); + assert_eq!(left, [late]); + }); + cx.run_until_parked(); + assert!(notified.get() > 0); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/summary.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/summary.rs new file mode 100644 index 000000000..4698edb1c --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/summary.rs @@ -0,0 +1,1297 @@ +//! The Summary tab (GitHub's Conversation): reviewers and labels with +//! their pickers, the description (edited in place), the checks (folded), +//! and the comments: loose comments and review threads in one sequence, +//! bots' chatter and settled items hidden by default (a bot's verdict +//! never), newest first, ten conversations at a time. +//! +//! Everything is one virtualized list ([`rows`]); comment and thread cards +//! are mounted when their row is first drawn and kept per id, so a refresh +//! of the conversation keeps parsed bodies, open editors and pending +//! reactions. + +mod candidates; +mod meta; +mod picker; +mod rows; + +use super::PullRequestIntent; +use super::conversation::add_to_agent_button; +use super::conversation::agent::check_text; +use super::conversation::logic::{ + CommentFilter, ConversationItem, ReviewOutcome, ReviewerEntry, can_edit_description, + comment_count, conversation_items, filter_items, is_verdict_stale, latest_review_outcomes, + newest_commit_at, reviewer_entries, splice_list, +}; +use super::conversation::reaction_bar::reaction_bar; +use super::conversation::reactions::PendingReactions; +use super::conversation::{ + CardStyle, CommentBody, CommentCard, EditorEvent, EditorOptions, Location, MarkdownEditor, + ThreadCard, +}; +use super::data::PullRequestData; +use super::detail::TabScroll; +use super::preferences; +use super::presentation::{GLYPH_SIZE, check_status_glyph}; +use candidates::{OpenPicker, PickerKind}; +use chrono::{DateTime, Utc}; +use github::model::{Activity, CheckStatus, Detail, PullRequestUpdate, Reaction, ReactionContent}; +use gpui_kit::component::button::{Button, ButtonVariants}; +use gpui_kit::component::menu::{DropdownMenu, PopupMenu, PopupMenuItem}; +use gpui_kit::component::{ActiveTheme, Icon, Side, Sizable}; +use gpui_kit::{ + AnyElement, App, Context, Entity, EventEmitter, FontWeight, ListAlignment, ListOffset, + ListState, Pixels, Render, SharedString, Subscription, WeakEntity, Window, div, list, + prelude::*, px, svg, +}; +use meta::{label_chip, meta_label, none_label, reviewer_avatars}; +use rows::{CommentsLayout, PAGE, Row, pinned_section, summary_rows}; +use std::collections::HashMap; +use std::sync::Arc; + +const LIST_OVERDRAW: Pixels = px(600.); + +enum Card { + Comment(Entity), + Thread(Entity), +} + +struct MountedCard { + card: Card, + _intents: Subscription, +} + +pub struct SummaryTab { + data: Entity, + /// What the rows were built from. + detail: Option>, + activity: Option>, + items: Vec, + index_of: HashMap, + reviewers: Vec, + newest_commit_at: Option>, + filter: CommentFilter, + newest_first: bool, + /// Conversations shown at most. + count: usize, + /// Comments the filter hides. + hidden_comments: usize, + description_open: bool, + checks_open: bool, + comments_open: bool, + description: Option, + description_editor: Option<(Entity, Subscription)>, + /// The viewer's reactions on the pull request not reported yet. + description_reactions: PendingReactions, + rows: Vec, + list: ListState, + cards: HashMap, + reviewer_picker: Option, + label_picker: Option, + _data: Subscription, +} + +impl EventEmitter for SummaryTab {} + +fn same(a: &Option>, b: &Option>) -> bool { + match (a, b) { + (Some(a), Some(b)) => Arc::ptr_eq(a, b), + (None, None) => true, + _ => false, + } +} + +impl SummaryTab { + /// How far the tab is scrolled; the header condenses once it is. + pub fn scroll(&self) -> TabScroll { + TabScroll::of(&self.list) + } + + pub fn new(data: Entity, cx: &mut Context) -> Self { + let observe = cx.observe(&data, |this, _, cx| this.sync(cx)); + let remembered = preferences::read(cx).conversation; + let mut this = Self { + data, + detail: None, + activity: None, + items: Vec::new(), + index_of: HashMap::new(), + reviewers: Vec::new(), + newest_commit_at: None, + filter: remembered.comment_filter(), + newest_first: !remembered.comments_oldest_first, + count: PAGE, + hidden_comments: 0, + description_open: true, + checks_open: false, + comments_open: true, + description: None, + description_editor: None, + description_reactions: PendingReactions::default(), + rows: Vec::new(), + list: ListState::new(0, ListAlignment::Top, LIST_OVERDRAW), + cards: HashMap::new(), + reviewer_picker: None, + label_picker: None, + _data: observe, + }; + // The sections show before anything is read. + this.rebuild_rows(); + this.sync(cx); + this + } + + /// Take over what the page knows now. + fn sync(&mut self, cx: &mut Context) { + let (detail, activity) = { + let data = self.data.read(cx); + (data.detail.clone(), data.activity.clone()) + }; + let detail_changed = !same(&detail, &self.detail); + let activity_changed = !same(&activity, &self.activity); + if detail_changed { + self.detail = detail; + self.sync_description(cx); + } + if activity_changed { + self.activity = activity; + self.sync_conversation(cx); + } + if detail_changed || activity_changed { + let outcomes = self.activity.as_ref().map_or_else(Vec::new, |a| { + latest_review_outcomes(&a.comments, &a.commits) + }); + let requested = self.detail.as_ref().map_or(&[][..], |d| &d.reviewers[..]); + self.reviewers = reviewer_entries(requested, &outcomes); + self.rebuild_rows(); + } + cx.notify(); + } + + fn sync_description(&mut self, cx: &mut Context) { + let Some(detail) = self.detail.clone() else { + return; + }; + let repo = self.data.read(cx).pr.repo.clone(); + match &mut self.description { + Some(body) => body.set_text(&detail.body, &repo, cx), + None => { + self.description = + Some(CommentBody::new("pr-description", &detail.body, &repo).unclipped()) + } + } + } + + fn sync_conversation(&mut self, cx: &mut Context) { + let Some(activity) = self.activity.clone() else { + return; + }; + self.items = conversation_items(&activity.comments, &activity.review_threads); + self.index_of = self + .items + .iter() + .enumerate() + .map(|(i, item)| (SharedString::from(item.id().to_owned()), i)) + .collect(); + self.newest_commit_at = newest_commit_at(&activity.commits); + // Cards of items gone go; the others take the new read. + let index_of = &self.index_of; + self.cards.retain(|id, _| index_of.contains_key(id)); + for (id, mounted) in &self.cards { + let item = &self.items[self.index_of[id]]; + let stale = self.is_stale(item); + match (&mounted.card, item) { + (Card::Comment(card), ConversationItem::Comment(comment)) => { + card.update(cx, |card, cx| { + card.set_comment(comment.clone(), cx); + card.set_stale(stale, cx); + }) + } + (Card::Thread(card), ConversationItem::Thread(thread)) => { + card.update(cx, |card, cx| card.set_item(thread.clone(), cx)) + } + _ => {} + } + } + } + + /// A verdict given before the newest commit. + fn is_stale(&self, item: &ConversationItem) -> bool { + let lead = item.lead(); + ReviewOutcome::of(lead.review_state).is_some() + && is_verdict_stale(lead.created_at, self.newest_commit_at) + } + + fn rebuild_rows(&mut self) { + let shown = filter_items(&self.items, self.filter); + self.hidden_comments = + comment_count(&self.items) - comment_count(shown.iter().map(|&i| &self.items[i])); + let ids: Vec = shown + .iter() + .map(|&i| SharedString::from(self.items[i].id().to_owned())) + .collect(); + let truncated = self.activity.as_ref().is_some_and(|a| a.comments_truncated); + let rows = summary_rows(CommentsLayout { + ids: &ids, + newest_first: self.newest_first, + count: self.count, + open: self.comments_open, + note: self.activity.is_none() || ids.is_empty() || truncated, + }); + splice_list(&self.list, &self.rows, &rows); + self.rows = rows; + } + + pub fn set_filter(&mut self, filter: CommentFilter, cx: &mut Context) { + if self.filter != filter { + self.filter = filter; + preferences::update(cx, |p| p.conversation.set_comment_filter(filter)); + self.rebuild_rows(); + cx.notify(); + } + } + + pub fn toggle_order(&mut self, cx: &mut Context) { + self.newest_first = !self.newest_first; + let oldest_first = !self.newest_first; + preferences::update(cx, |p| p.conversation.comments_oldest_first = oldest_first); + self.rebuild_rows(); + cx.notify(); + } + + /// The viewer (un)reacts on the pull request: shown at once, taken back + /// when the write fails. + fn react_on_description( + &mut self, + content: ReactionContent, + reacted: bool, + cx: &mut Context, + ) { + let reactions = self.description_host_reactions(); + self.description_reactions + .choose(&reactions, content, reacted); + self.remeasure(Row::Description); + cx.notify(); + let write = PullRequestData::write(&self.data, cx, move |service, pr| async move { + service.pull_request_react(pr, None, content, reacted).await + }); + cx.spawn(async move |this, cx| { + if write.await.is_err() { + this.update(cx, |this, cx| { + this.description_reactions.revert(content); + this.remeasure(Row::Description); + cx.notify(); + }) + .ok(); + } + }) + .detach(); + } + + /// The reactions on the pull request as the host reported them. + fn description_host_reactions(&self) -> Vec { + self.activity + .as_ref() + .map_or_else(Vec::new, |a| a.reactions.clone()) + } + + /// The section header pinned over the list, if any. + fn pinned(&self) -> Option { + let top = self.list.logical_scroll_top(); + pinned_section(&self.rows, top.item_ix, top.offset_in_item) + } + + fn show_more(&mut self, more: bool, cx: &mut Context) { + self.count = if more { self.count + PAGE } else { PAGE }; + self.rebuild_rows(); + cx.notify(); + } + + fn toggle_section(&mut self, row: Row, cx: &mut Context) { + // A section folded while scrolled under its pinned header keeps + // that header where it was: at the top. + let anchor = self.pinned().as_ref() == Some(&row); + match row { + Row::Description => self.description_open = !self.description_open, + Row::Checks => self.checks_open = !self.checks_open, + Row::CommentsHeader => { + self.comments_open = !self.comments_open; + self.rebuild_rows(); + } + _ => return, + } + // The section's own height changed. + if let Some(ix) = self.rows.iter().position(|r| *r == row) { + self.list.remeasure_items(ix..ix + 1); + if anchor { + self.list.scroll_to(ListOffset { + item_ix: ix, + offset_in_item: px(0.), + }); + } + } + cx.notify(); + } + + #[cfg(test)] + fn rows(&self) -> &[Row] { + &self.rows + } + + fn card(&mut self, id: &SharedString, cx: &mut Context) -> Option { + if let Some(mounted) = self.cards.get(id) { + return Some(match &mounted.card { + Card::Comment(card) => card.clone().into_any_element(), + Card::Thread(card) => card.clone().into_any_element(), + }); + } + let item = self.items.get(*self.index_of.get(id)?)?.clone(); + let stale = self.is_stale(&item); + let data = self.data.clone(); + let forward = |_: &mut Self, intent: &PullRequestIntent, cx: &mut Context| { + cx.emit(intent.clone()) + }; + let (card, intents, element) = match item { + ConversationItem::Comment(comment) => { + let location = comment.path.clone().map(|path| Location { + path, + line: None, + outdated: false, + }); + let dismissed = item_is_dismissed(&comment.review_state); + let card = cx.new(|cx| { + let mut card = CommentCard::new(data, comment, CardStyle::Card, cx) + .with_location(location) + .with_agent_button(true) + .collapsed(dismissed); + card.set_stale(stale, cx); + card + }); + let intents = + cx.subscribe(&card, move |this, _, intent, cx| forward(this, intent, cx)); + let element = card.clone().into_any_element(); + (Card::Comment(card), intents, element) + } + ConversationItem::Thread(thread) => { + let card = cx.new(|_| ThreadCard::new(data, thread).show_location(true)); + let intents = + cx.subscribe(&card, move |this, _, intent, cx| forward(this, intent, cx)); + let element = card.clone().into_any_element(); + (Card::Thread(card), intents, element) + } + }; + self.cards.insert( + id.clone(), + MountedCard { + card, + _intents: intents, + }, + ); + Some(element) + } + + fn edit_description(&mut self, window: &mut Window, cx: &mut Context) { + let Some(detail) = self.detail.clone() else { + return; + }; + let repo = self.data.read(cx).pr.repo.clone(); + let editor = cx.new(|cx| { + MarkdownEditor::new(repo, &detail.body, EditorOptions::DESCRIPTION, window, cx) + }); + let subscription = cx.subscribe( + &editor, + |this, editor, event: &EditorEvent, cx| match event { + EditorEvent::Submit(body) => this.save_description(editor, body.clone(), cx), + EditorEvent::Cancel => this.close_description_editor(cx), + }, + ); + self.description_editor = Some((editor, subscription)); + self.remeasure(Row::Description); + cx.notify(); + } + + fn close_description_editor(&mut self, cx: &mut Context) { + self.description_editor = None; + self.remeasure(Row::Description); + cx.notify(); + } + + fn save_description( + &mut self, + editor: Entity, + body: String, + cx: &mut Context, + ) { + editor.update(cx, |editor, cx| editor.set_busy(true, cx)); + let sent = body.clone(); + let write = PullRequestData::write(&self.data, cx, move |service, pr| async move { + let update = PullRequestUpdate { + body: Some(sent), + ..PullRequestUpdate::default() + }; + service.pull_request_update(pr, update).await + }); + cx.spawn(async move |this, cx| { + let saved = write.await.is_ok(); + this.update(cx, |this, cx| { + if saved { + // Shown until the detail is read anew. + let repo = this.data.read(cx).pr.repo.clone(); + if let Some(description) = &mut this.description { + description.set_text(&body, &repo, cx); + } + this.close_description_editor(cx); + } else { + editor.update(cx, |editor, cx| editor.set_busy(false, cx)); + } + }) + .ok(); + }) + .detach(); + } + + fn remeasure(&self, row: Row) { + if let Some(ix) = self.rows.iter().position(|r| *r == row) { + self.list.remeasure_items(ix..ix + 1); + } + } + + fn render_meta(&self, cx: &mut Context) -> AnyElement { + let permissions = self.detail.as_ref().map(|d| &d.viewer_permissions); + let can_request = permissions.is_some_and(|p| p.request_reviewers); + let can_label = permissions.is_some_and(|p| p.labels); + let labels = self.detail.as_ref().map_or(&[][..], |d| &d.labels[..]); + let reviewers = if self.reviewers.is_empty() { + none_label(cx) + } else { + reviewer_avatars(&self.reviewers, cx) + }; + let label_chips: Vec = if labels.is_empty() { + vec![none_label(cx)] + } else { + labels.iter().map(|label| label_chip(label, cx)).collect() + }; + let row = || div().min_h(px(28.)).flex().items_center().gap_2(); + div() + .px_4() + .pt_3() + .pb_1() + .flex() + .flex_col() + .gap_1() + .child( + row() + .child(meta_label("icons/users.svg", "Reviewers", cx)) + .child(reviewers) + .when(can_request, |row| { + row.child(self.picker_trigger(PickerKind::Reviewers, cx)) + }), + ) + .when(!labels.is_empty() || can_label, |el| { + el.child( + row() + .child(meta_label("icons/tag.svg", "Labels", cx)) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_wrap() + .items_center() + .gap_1() + .children(label_chips) + .when(can_label, |row| { + row.child(self.picker_trigger(PickerKind::Labels, cx)) + }), + ), + ) + }) + .into_any_element() + } + + /// "Description ▾" and the like; a click folds the section. + fn section_header( + &self, + row: Row, + title: SharedString, + open: bool, + actions: Option, + pinned: bool, + cx: &mut Context, + ) -> impl IntoElement + use<> { + let theme = cx.theme(); + let muted = theme.muted_foreground; + let id = SharedString::from(format!( + "pr-section-{title}{}", + if pinned { "-pinned" } else { "" } + )); + div() + .px_4() + .py_2() + .flex() + .items_center() + .gap_2() + .child( + div() + .id(id.clone()) + .debug_selector(move || id.to_string()) + .flex_1() + .min_w_0() + .flex() + .items_center() + .gap_1p5() + .cursor_pointer() + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(muted) + .hover(|s| s.text_color(theme.foreground)) + .child(title) + .child( + svg() + .size(px(13.)) + .path(if open { + "icons/chevron_down.svg" + } else { + "icons/chevron_right.svg" + }) + .text_color(muted.opacity(0.6)), + ) + .on_click( + cx.listener(move |this, _, _, cx| this.toggle_section(row.clone(), cx)), + ), + ) + .children(actions) + } + + fn description_header(&self, pinned: bool, cx: &mut Context) -> AnyElement { + let viewer = self.data.read(cx).viewer.as_ref().map(|v| v.login.clone()); + let editable = self.description_editor.is_none() + && self + .detail + .as_ref() + .is_some_and(|d| can_edit_description(d, viewer.as_deref())); + let muted = cx.theme().muted_foreground; + let id = if pinned { + "pr-description-edit-pinned" + } else { + "pr-description-edit" + }; + let pencil = editable.then(|| { + div() + .debug_selector(move || id.into()) + .child( + Button::new(id) + .ghost() + .xsmall() + .icon(Icon::default().path("icons/pencil.svg").text_color(muted)) + .tooltip("Edit description") + .on_click( + cx.listener(|this, _, window, cx| this.edit_description(window, cx)), + ), + ) + .into_any_element() + }); + self.section_header( + Row::Description, + "Description".into(), + self.description_open, + pencil, + pinned, + cx, + ) + .into_any_element() + } + + fn render_description(&mut self, cx: &mut Context) -> AnyElement { + let header = self.description_header(false, cx); + let muted = cx.theme().muted_foreground; + let body = if !self.description_open { + None + } else if let Some((editor, _)) = &self.description_editor { + Some(editor.clone().into_any_element()) + } else { + let background = cx.theme().background; + match &mut self.description { + Some(body) if !body.is_empty() => Some(body.render( + background, + |this: &mut Self| this.description.as_mut().expect("rendered"), + cx, + )), + Some(_) => Some( + div() + .text_sm() + .italic() + .text_color(muted) + .child("No description provided.") + .into_any_element(), + ), + None => None, + } + }; + let reactions = (self.description_open && self.activity.is_some()) + .then(|| { + let shown = self + .description_reactions + .shown(&self.description_host_reactions()); + let can_react = self.data.read(cx).viewer.is_some(); + reaction_bar( + "pr-description", + &shown, + can_react, + |this: &mut Self, content, reacted, cx| { + this.react_on_description(content, reacted, cx) + }, + cx, + ) + }) + .flatten(); + div() + .child(header) + .children(body.map(|body| div().px_4().pb_2().child(body))) + .children(reactions.map(|bar| div().px_4().pb_2().child(bar))) + .child(div().pb_2()) + .into_any_element() + } + + fn render_checks(&self, cx: &mut Context) -> AnyElement { + let header = self.checks_header(false, cx); + let checks = self.data.read(cx).current_checks().to_vec(); + let pr = self.data.read(cx).pr.clone(); + let theme = cx.theme(); + let muted = theme.muted_foreground; + let body = self.checks_open.then(|| { + if checks.is_empty() { + return div() + .text_xs() + .text_color(muted) + .child("No checks reported.") + .into_any_element(); + } + div() + .flex() + .flex_col() + .children(checks.iter().enumerate().map(|(i, check)| { + let glyph = check_status_glyph(check, cx); + let url = check.url.clone(); + let failed = + matches!(check.status, CheckStatus::Failure | CheckStatus::Cancelled); + let handed = check_text(&pr, check); + div() + .flex() + .items_center() + .gap_1() + .rounded_md() + .hover(|s| s.bg(theme.muted.opacity(0.5))) + .child( + div() + .id(("pr-summary-check", i)) + .flex_1() + .min_w_0() + .px_2() + .py_1p5() + .flex() + .items_center() + .gap_2() + .text_xs() + .child(glyph.render(GLYPH_SIZE)) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_color(theme.foreground) + .child(check.name.clone()), + ) + .child( + div() + .flex_none() + .text_color(muted) + .child(glyph.label.clone()), + ) + .when_some(url, |row, url| { + row.cursor_pointer().on_click(cx.listener( + move |_, _, _, cx| { + cx.emit(PullRequestIntent::OpenUrl(url.clone())) + }, + )) + }), + ) + .when(failed, |row| { + row.child( + div() + .debug_selector(move || format!("pr-check-agent-{i}")) + .child(add_to_agent_button( + SharedString::from(format!("pr-check-agent-{i}")), + handed, + )), + ) + }) + })) + .into_any_element() + }); + div() + .child(header) + .children(body.map(|body| div().px_4().pb_4().child(body))) + .into_any_element() + } + + fn checks_header(&self, pinned: bool, cx: &mut Context) -> AnyElement { + self.section_header( + Row::Checks, + "Checks".into(), + self.checks_open, + None, + pinned, + cx, + ) + .into_any_element() + } + + fn comments_header(&self, pinned: bool, cx: &mut Context) -> AnyElement { + let count = self.activity.as_ref().map_or(0, |a| a.comment_count); + let this = cx.entity().downgrade(); + let hidden = self.hidden_comments; + let suffix = if pinned { "-pinned" } else { "" }; + let order_id = format!("pr-comments-order{suffix}"); + let actions = div() + .flex_none() + .flex() + .items_center() + .gap_1() + .child( + Button::new(SharedString::from(format!("pr-comments-filter{suffix}"))) + .ghost() + .xsmall() + .icon(Icon::default().path("icons/list_filter.svg")) + .label(if hidden > 0 { + SharedString::from(format!("{hidden} hidden")) + } else { + "Filter".into() + }) + .dropdown_menu_with_anchor(gpui_kit::Anchor::TopRight, move |menu, _, cx| { + filter_menu(menu, &this, cx) + }), + ) + .child( + div() + .debug_selector({ + let order_id = order_id.clone(); + move || order_id.clone() + }) + .child( + Button::new(SharedString::from(order_id)) + .ghost() + .xsmall() + .icon(Icon::default().path("icons/arrow_up_down.svg")) + .label(if self.newest_first { + "Newest first" + } else { + "Oldest first" + }) + .on_click(cx.listener(|this, _, _, cx| this.toggle_order(cx))), + ), + ) + .into_any_element(); + self.section_header( + Row::CommentsHeader, + format!("Comments ({count})").into(), + self.comments_open, + Some(actions), + pinned, + cx, + ) + .into_any_element() + } + + /// The header of the section scrolled under the top edge, over the + /// list. + fn render_pinned(&self, cx: &mut Context) -> Option { + let header = match self.pinned()? { + Row::Description => self.description_header(true, cx), + Row::Checks => self.checks_header(true, cx), + Row::CommentsHeader => self.comments_header(true, cx), + _ => return None, + }; + let theme = cx.theme(); + Some( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .bg(theme.background) + .border_b_1() + .border_color(theme.border.opacity(0.6)) + .child(header) + .into_any_element(), + ) + } + + fn render_comments_note(&self, cx: &mut Context) -> AnyElement { + let theme = cx.theme(); + let muted = theme.muted_foreground; + let note = |text: String| div().py_2().text_xs().text_color(muted).child(text); + let Some(activity) = &self.activity else { + return div() + .px_4() + .child(note("Loading the conversation…".into())) + .into_any_element(); + }; + let read = comment_count(&self.items); + let shown = read - self.hidden_comments; + div() + .px_4() + .pb_2() + .flex() + .flex_col() + .gap_1() + .when(activity.comments_truncated, |el| { + el.child( + div() + .px_2() + .py_1p5() + .rounded_md() + .border_1() + .border_color(theme.warning.opacity(0.3)) + .bg(theme.warning.opacity(0.08)) + .text_xs() + .child(format!( + "This conversation is only partly read: {read} {} shown here. \ + Open it on GitHub to read the rest.", + if read == 1 { + "comment is" + } else { + "comments are" + } + )), + ) + }) + .when(self.items.is_empty(), |el| { + el.child(note("No comments yet.".into())) + }) + .when(!self.items.is_empty() && shown == 0, |el| { + el.child(note(if self.hidden_comments == 1 { + "The only comment is hidden by the filter.".into() + } else { + format!( + "All {} comments are hidden by the filter.", + self.hidden_comments + ) + })) + }) + .into_any_element() + } + + fn render_row(&mut self, ix: usize, cx: &mut Context) -> AnyElement { + let Some(row) = self.rows.get(ix).cloned() else { + return div().into_any_element(); + }; + match row { + Row::Meta => self.render_meta(cx), + Row::Description => self.render_description(cx), + Row::Checks => self.render_checks(cx), + Row::CommentsHeader => self.comments_header(false, cx), + Row::CommentsNote => self.render_comments_note(cx), + Row::ShowOlder { hidden } => { + let more = hidden.min(PAGE); + div() + .px_4() + .pb_3() + .child( + Button::new("pr-comments-older") + .outline() + .small() + .w_full() + .label(format!( + "Show {more} older {} ({hidden} hidden)", + if more == 1 { + "conversation" + } else { + "conversations" + } + )) + .on_click(cx.listener(|this, _, _, cx| this.show_more(true, cx))), + ) + .into_any_element() + } + Row::ShowRecent => div() + .px_4() + .pb_3() + .child( + Button::new("pr-comments-recent") + .ghost() + .small() + .w_full() + .label(format!("Show only the {PAGE} most recent conversations")) + .on_click(cx.listener(|this, _, _, cx| this.show_more(false, cx))), + ) + .into_any_element(), + Row::Item(id) => match self.card(&id, cx) { + Some(card) => div().px_4().pb_3().child(card).into_any_element(), + None => div().into_any_element(), + }, + } + } +} + +fn item_is_dismissed(state: &Option) -> bool { + ReviewOutcome::of(*state) == Some(ReviewOutcome::Dismissed) +} + +/// Show: bot comments, resolved or dismissed. +fn filter_menu(menu: PopupMenu, tab: &WeakEntity, cx: &App) -> PopupMenu { + let Some(filter) = tab.upgrade().map(|t| t.read(cx).filter) else { + return menu; + }; + let option = |label: &'static str, checked: bool, change: fn(&mut CommentFilter)| { + let tab = tab.clone(); + PopupMenuItem::new(label) + .checked(checked) + .on_click(move |_, _, cx| { + tab.update(cx, |tab, cx| { + let mut filter = tab.filter; + change(&mut filter); + tab.set_filter(filter, cx); + }) + .ok(); + }) + }; + menu.check_side(Side::Right) + .label("Show") + .item(option("Bot comments", filter.bots, |f| f.bots = !f.bots)) + .item(option("Resolved or dismissed", filter.resolved, |f| { + f.resolved = !f.resolved + })) +} + +impl Render for SummaryTab { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let pinned = self.render_pinned(cx); + div() + .relative() + .size_full() + .child( + list( + self.list.clone(), + cx.processor(|this: &mut Self, ix: usize, _, cx| this.render_row(ix, cx)), + ) + .size_full(), + ) + .children(pinned) + } +} + +#[cfg(test)] +mod tests { + use super::super::conversation::fixtures::{ + agent_inbox, bot, click, comment, data_with_detail, review, thread, window, + }; + use super::picker::{Candidate, Leading, PickerEntry, PickerToggle}; + use super::*; + use crate::main_screen::right_panel::pull_request::fixtures::{ + actor, check, detail_with, hours_ago, + }; + use github::model::ReviewState; + use gpui_kit::{TestAppContext, VisualTestContext}; + + fn activity(n: usize) -> Activity { + let mut comments: Vec<_> = (0..n) + .map(|i| comment(&format!("c{i}"), "ann", hours_ago(100 - i as i64))) + .collect(); + comments.push(review("ok", "bob", ReviewState::Approved, hours_ago(2))); + comments.push({ + let mut chatter = comment("bot", "x", hours_ago(1)); + chatter.author = Some(bot("ci-bot")); + chatter + }); + let mut resolved = thread("t-done", &[("r1", hours_ago(50))]); + resolved.is_resolved = true; + Activity { + author: Some(actor("ann")), + reviewers: Vec::new(), + comment_count: comments.len() as u64 + 1, + comments, + comments_truncated: false, + review_threads: vec![resolved], + review_threads_truncated: false, + commits: Vec::new(), + reactions: Vec::new(), + } + } + + fn summary( + activity: Activity, + cx: &mut TestAppContext, + ) -> (Entity, &mut VisualTestContext) { + let data = data_with_detail(cx, Some("ann")); + data.update(cx, |data, cx| { + data.activity = Some(Arc::new(activity)); + cx.notify(); + }); + window(cx, move |cx| SummaryTab::new(data, cx)) + } + + fn items(tab: &SummaryTab) -> Vec { + tab.rows() + .iter() + .filter_map(|row| match row { + Row::Item(id) => Some(id.to_string()), + Row::ShowOlder { hidden } => Some(format!("older {hidden}")), + _ => None, + }) + .collect() + } + + #[gpui_kit::test] + fn the_newest_conversations_show_without_bots_and_settled_threads(cx: &mut TestAppContext) { + let (tab, cx) = summary(activity(12), cx); + tab.read_with(cx, |tab, _| { + let items = items(tab); + // The approval (a verdict) shows; the bot's chatter and the + // resolved thread do not. + assert_eq!(items[0], "ok"); + assert!(!items.contains(&"bot".to_string())); + assert!(!items.contains(&"t-done".to_string())); + assert_eq!(items.len(), PAGE + 1); + assert_eq!(items.last().unwrap(), "older 3"); + assert_eq!(tab.hidden_comments, 2); + }); + } + + #[gpui_kit::test] + fn the_filter_and_the_order_rebuild_the_rows(cx: &mut TestAppContext) { + let (tab, cx) = summary(activity(2), cx); + tab.update(cx, |tab, cx| { + tab.set_filter( + CommentFilter { + bots: true, + resolved: true, + }, + cx, + ) + }); + tab.read_with(cx, |tab, _| { + assert_eq!(items(tab), ["bot", "ok", "t-done", "c1", "c0"]); + }); + let order = cx + .debug_bounds("pr-comments-order") + .expect("an order toggle"); + cx.simulate_click(order.center(), gpui_kit::Modifiers::none()); + tab.read_with(cx, |tab, _| { + assert_eq!(items(tab), ["c0", "c1", "t-done", "ok", "bot"]); + }); + } + + #[gpui_kit::test] + fn cards_mount_when_drawn_and_survive_a_refresh(cx: &mut TestAppContext) { + let (tab, cx) = summary(activity(2), cx); + let card = tab.read_with(cx, |tab, _| { + match &tab.cards.get("c1").expect("drawn").card { + Card::Comment(card) => card.clone(), + Card::Thread(_) => panic!("a comment"), + } + }); + let data = tab.read_with(cx, |tab, _| tab.data.clone()); + let mut refreshed = activity(2); + refreshed.comments[1].body = "edited".into(); + data.update(cx, |data, cx| { + data.activity = Some(Arc::new(refreshed)); + cx.notify(); + }); + cx.run_until_parked(); + tab.read_with(cx, |tab, cx| { + let Card::Comment(same) = &tab.cards["c1"].card else { + panic!("a comment") + }; + assert_eq!(same.entity_id(), card.entity_id()); + assert_eq!(same.read(cx).comment().body, "edited"); + }); + } + + #[gpui_kit::test] + fn the_author_edits_the_description_in_place(cx: &mut TestAppContext) { + let (tab, cx) = summary(activity(0), cx); + // ann is not the author; the fixture's viewer may merge. + let edit = cx.debug_bounds("pr-description-edit").expect("a pencil"); + cx.simulate_click(edit.center(), gpui_kit::Modifiers::none()); + cx.run_until_parked(); + tab.read_with(cx, |tab, _| assert!(tab.description_editor.is_some())); + cx.simulate_keystrokes("escape"); + tab.read_with(cx, |tab, _| assert!(tab.description_editor.is_none())); + } + + #[gpui_kit::test] + fn a_picker_reads_candidates_and_ticks_only_what_was_written(cx: &mut TestAppContext) { + let (tab, cx) = summary(activity(0), cx); + tab.update_in(cx, |tab, window, cx| { + tab.open_picker(PickerKind::Labels, window, cx) + }); + cx.run_until_parked(); + let picker = tab.read_with(cx, |tab, _| { + tab.label_picker.as_ref().expect("open").picker.clone() + }); + let bug = Candidate::Label("bug".into()); + picker.update(cx, |picker, cx| { + // No session service: the read failed. + assert!(picker.entries().is_empty()); + picker.set_entries( + vec![PickerEntry { + candidate: bug.clone(), + label: "bug".into(), + detail: None, + leading: Leading::Dot(None), + checked: false, + }], + false, + cx, + ); + cx.emit(PickerToggle(bug, true)); + }); + cx.run_until_parked(); + picker.read_with(cx, |picker, _| { + assert!(!picker.entries()[0].checked, "the write failed") + }); + tab.update(cx, |tab, cx| tab.close_picker(PickerKind::Labels, cx)); + tab.read_with(cx, |tab, _| assert!(tab.label_picker.is_none())); + } + + #[gpui_kit::test] + fn a_pull_request_opens_at_the_top(cx: &mut TestAppContext) { + // Nothing read yet when the tab first draws. + let data = super::super::conversation::fixtures::data(cx, Some("ann")); + let shown = data.clone(); + let (tab, cx) = window(cx, move |cx| SummaryTab::new(shown, cx)); + // The detail, then the conversation arrive while the tab shows. + let long = (0..80) + .map(|i| format!("Paragraph {i} of a long description.")) + .collect::>() + .join("\n\n"); + data.update(cx, |data, cx| { + data.detail = Some(Arc::new(detail_with(290, |d| d.body = long))); + cx.notify(); + }); + cx.run_until_parked(); + data.update(cx, |data, cx| { + data.activity = Some(Arc::new(activity(3))); + cx.notify(); + }); + cx.run_until_parked(); + tab.read_with(cx, |tab, _| { + let top = tab.list.logical_scroll_top(); + assert_eq!((top.item_ix, top.offset_in_item), (0, px(0.))); + }); + } + + #[gpui_kit::test] + fn the_remembered_filter_and_order_apply(cx: &mut TestAppContext) { + cx.update(|cx| { + let mut settings = crate::shared::settings::UiSettings::default(); + let conversation = &mut settings.pull_requests.conversation; + conversation.show_bot_comments = true; + conversation.comments_oldest_first = true; + cx.set_global(crate::UiSettingsGlobal(settings)); + }); + let (tab, cx) = summary(activity(2), cx); + tab.read_with(cx, |tab, _| { + assert_eq!(items(tab), ["c0", "c1", "ok", "bot"]); + }); + } + + #[gpui_kit::test] + fn the_description_carries_the_pull_requests_reactions(cx: &mut TestAppContext) { + let mut reacted = activity(0); + reacted.reactions = vec![github::model::Reaction { + content: ReactionContent::Rocket, + count: 2, + actors: vec!["bob".into(), "cid".into()], + viewer_has_reacted: false, + }]; + let (tab, cx) = summary(reacted, cx); + assert!(cx.debug_bounds("pr-description-reaction-Rocket").is_some()); + tab.update(cx, |tab, cx| { + tab.react_on_description(ReactionContent::Rocket, true, cx); + let shown = tab + .description_reactions + .shown(&tab.description_host_reactions()); + assert_eq!((shown[0].count, shown[0].viewer_has_reacted), (3, true)); + }); + // No session service: the write fails and the choice goes. + cx.run_until_parked(); + tab.read_with(cx, |tab, _| { + let shown = tab + .description_reactions + .shown(&tab.description_host_reactions()); + assert_eq!(shown[0].count, 2); + }); + } + + #[gpui_kit::test] + fn a_failing_check_goes_to_the_agent(cx: &mut TestAppContext) { + let data = data_with_detail(cx, Some("ann")); + data.update(cx, |data, _| { + data.detail = Some(Arc::new(detail_with(290, |d| { + d.checks = vec![ + check("lint", CheckStatus::Success), + check("test", CheckStatus::Failure), + ]; + }))); + }); + let (_, cx) = window(cx, move |cx| SummaryTab::new(data, cx)); + let inbox = agent_inbox(cx); + click(cx, "pr-section-Checks"); + assert!(cx.debug_bounds("pr-check-agent-0").is_none(), "it passed"); + click(cx, "pr-check-agent-1"); + assert_eq!( + inbox.borrow().as_slice(), + ["Check \"test\" on stippi/code-assistant#290: Failed"] + ); + } + + #[gpui_kit::test] + fn headers_pin_and_folding_keeps_the_header_in_place(cx: &mut TestAppContext) { + let data = data_with_detail(cx, Some("ann")); + let long = (0..60) + .map(|i| format!("Paragraph {i} of a long description.")) + .collect::>() + .join("\n\n"); + data.update(cx, |data, _| { + data.detail = Some(Arc::new(detail_with(290, |d| d.body = long))); + data.activity = Some(Arc::new(activity(30))); + }); + let (tab, cx) = window(cx, move |cx| SummaryTab::new(data, cx)); + let scroll = |tab: &Entity, item_ix: usize, cx: &mut VisualTestContext| { + tab.update(cx, |tab, cx| { + tab.list.scroll_to(ListOffset { + item_ix, + offset_in_item: px(300.), + }); + cx.notify(); + }); + cx.run_until_parked(); + }; + assert!(cx.debug_bounds("pr-section-Description-pinned").is_none()); + scroll(&tab, 6, cx); + assert!(cx.debug_bounds("pr-section-Comments (33)-pinned").is_some()); + + scroll(&tab, 1, cx); + click(cx, "pr-section-Description-pinned"); + tab.read_with(cx, |tab, _| { + assert!(!tab.description_open); + let top = tab.list.logical_scroll_top(); + assert_eq!( + (top.item_ix, top.offset_in_item), + (1, px(0.)), + "the header on top" + ); + }); + } + + #[gpui_kit::test] + fn folding_the_comments_drops_their_rows(cx: &mut TestAppContext) { + let (tab, cx) = summary(activity(2), cx); + let header = cx + .debug_bounds("pr-section-Comments (5)") + .expect("the comments header"); + cx.simulate_click(header.center(), gpui_kit::Modifiers::none()); + tab.read_with(cx, |tab, _| assert!(items(tab).is_empty())); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/summary/candidates.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/summary/candidates.rs new file mode 100644 index 000000000..2b7a67ff3 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/summary/candidates.rs @@ -0,0 +1,227 @@ +//! The Reviewers and Labels pickers wired to GitHub: opened from their +//! icon button, they read the candidates, and each toggle requests a +//! reviewer or applies a label (ticked once the write went through). + +use super::SummaryTab; +use super::meta::label_color; +use super::picker::{ + Candidate, CandidatePicker, LABEL_TEXTS, Leading, PickerEntry, PickerToggle, REVIEWER_TEXTS, +}; +use crate::main_screen::right_panel::pull_request::data::PullRequestData; +use github::model::{Actor, LabelCandidates, Reviewer, ReviewerCandidates, ReviewerKind}; +use gpui_kit::component::button::{Button, ButtonVariants}; +use gpui_kit::component::popover::Popover; +use gpui_kit::component::{ActiveTheme, Icon, Sizable}; +use gpui_kit::{ + AnyElement, Context, Entity, SharedString, Subscription, Task, WeakEntity, Window, div, + prelude::*, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum PickerKind { + Reviewers, + Labels, +} + +pub(super) struct OpenPicker { + pub(super) picker: Entity, + _toggle: Subscription, + _read: Task<()>, +} + +impl SummaryTab { + fn picker_slot(&mut self, kind: PickerKind) -> &mut Option { + match kind { + PickerKind::Reviewers => &mut self.reviewer_picker, + PickerKind::Labels => &mut self.label_picker, + } + } + + pub(super) fn open_picker( + &mut self, + kind: PickerKind, + window: &mut Window, + cx: &mut Context, + ) { + let texts = match kind { + PickerKind::Reviewers => REVIEWER_TEXTS, + PickerKind::Labels => LABEL_TEXTS, + }; + let picker = cx.new(|cx| CandidatePicker::new(texts, window, cx)); + let toggle = cx.subscribe(&picker, |this, picker, toggle: &PickerToggle, cx| { + this.toggle_candidate(picker, toggle.0.clone(), toggle.1, cx) + }); + let read = match kind { + PickerKind::Reviewers => self.read_reviewer_candidates(picker.downgrade(), cx), + PickerKind::Labels => self.read_label_candidates(picker.downgrade(), cx), + }; + *self.picker_slot(kind) = Some(OpenPicker { + picker, + _toggle: toggle, + _read: read, + }); + cx.notify(); + } + + pub(super) fn close_picker(&mut self, kind: PickerKind, cx: &mut Context) { + if self.picker_slot(kind).take().is_some() { + cx.notify(); + } + } + + fn read_reviewer_candidates( + &self, + picker: WeakEntity, + cx: &mut Context, + ) -> Task<()> { + let read = PullRequestData::write(&self.data, cx, |service, pr| async move { + service.pull_request_reviewer_candidates(pr).await + }); + cx.spawn(async move |_, cx| { + let read: anyhow::Result = read.await; + picker + .update(cx, |picker, cx| match read { + Ok(read) => picker.set_entries( + read.candidates + .into_iter() + .map(|c| PickerEntry { + label: c.login.clone().into(), + detail: match c.kind { + ReviewerKind::Team => Some("team".into()), + ReviewerKind::User => c.name.clone().map(Into::into), + }, + leading: Leading::Avatar(Actor { + login: c.login.clone(), + name: c.name.clone(), + avatar_url: c.avatar_url.clone(), + is_bot: false, + }), + checked: c.is_requested, + candidate: Candidate::Reviewer(Reviewer { + id: c.id, + kind: c.kind, + }), + }) + .collect(), + read.truncated, + cx, + ), + Err(_) => picker.set_failed(cx), + }) + .ok(); + }) + } + + fn read_label_candidates( + &self, + picker: WeakEntity, + cx: &mut Context, + ) -> Task<()> { + let read = PullRequestData::write(&self.data, cx, |service, pr| async move { + service.pull_request_label_candidates(pr).await + }); + cx.spawn(async move |_, cx| { + let read: anyhow::Result = read.await; + picker + .update(cx, |picker, cx| match read { + Ok(read) => picker.set_entries( + read.candidates + .into_iter() + .map(|c| PickerEntry { + label: c.name.clone().into(), + detail: c.description.clone().map(Into::into), + leading: Leading::Dot(label_color(c.color.as_deref())), + checked: c.is_applied, + candidate: Candidate::Label(c.name), + }) + .collect(), + read.truncated, + cx, + ), + Err(_) => picker.set_failed(cx), + }) + .ok(); + }) + } + + fn toggle_candidate( + &mut self, + picker: Entity, + candidate: Candidate, + on: bool, + cx: &mut Context, + ) { + picker.update(cx, |picker, cx| picker.set_busy(true, cx)); + let write = match candidate.clone() { + Candidate::Reviewer(reviewer) => { + PullRequestData::write(&self.data, cx, move |service, pr| async move { + service + .pull_request_request_reviewers(pr, vec![reviewer], on) + .await + }) + } + Candidate::Label(name) => { + PullRequestData::write(&self.data, cx, move |service, pr| async move { + service.pull_request_set_labels(pr, vec![name], on).await + }) + } + }; + cx.spawn(async move |_, cx| { + let done = write.await.is_ok(); + picker.update(cx, |picker, cx| { + picker.set_busy(false, cx); + if done { + picker.set_checked(&candidate, on, cx); + } + }); + }) + .detach(); + } + + /// The icon button opening a picker. + pub(super) fn picker_trigger(&self, kind: PickerKind, cx: &mut Context) -> AnyElement { + let (id, icon, tooltip, slot) = match kind { + PickerKind::Reviewers => ( + "pr-reviewers-picker", + "icons/user_plus.svg", + "Request a review", + &self.reviewer_picker, + ), + PickerKind::Labels => ( + "pr-labels-picker", + "icons/tag.svg", + "Change labels", + &self.label_picker, + ), + }; + let picker = slot.as_ref().map(|open| open.picker.clone()); + let this = cx.entity().downgrade(); + let muted = cx.theme().muted_foreground; + Popover::new(id) + .anchor(gpui_kit::Anchor::TopLeft) + .open(picker.is_some()) + .on_open_change(move |open, window, cx| { + let open = *open; + this.update(cx, |this, cx| { + if open { + this.open_picker(kind, window, cx) + } else { + this.close_picker(kind, cx) + } + }) + .ok(); + }) + .trigger( + Button::new(SharedString::from(format!("{id}-trigger"))) + .ghost() + .xsmall() + .icon(Icon::default().path(icon).text_color(muted)) + .tooltip(tooltip), + ) + .content(move |_, _, _| match &picker { + Some(picker) => picker.clone().into_any_element(), + None => div().into_any_element(), + }) + .into_any_element() + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/summary/meta.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/summary/meta.rs new file mode 100644 index 000000000..4f989ff96 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/summary/meta.rs @@ -0,0 +1,146 @@ +//! The rows above the description: Reviewers (overlapping avatars, each +//! ringed in its verdict's colour, faded when the verdict predates the +//! newest commit) and Labels (chips in the label's colour). + +use super::super::avatar::avatar; +use super::super::conversation::logic::ReviewerEntry; +use super::super::conversation::{outcome_color, outcome_icon}; +use github::model::Label; +use gpui_kit::component::ActiveTheme; +use gpui_kit::component::tooltip::Tooltip; +use gpui_kit::{AnyElement, App, Hsla, SharedString, div, prelude::*, px, rgb, svg}; + +const AVATAR: f32 = 20.; + +/// A label's colour from GitHub's hex (`d73a4a`, with or without `#`). +pub fn label_color(hex: Option<&str>) -> Option { + let hex = hex?.trim().trim_start_matches('#'); + if hex.len() != 6 { + return None; + } + u32::from_str_radix(hex, 16) + .ok() + .map(|value| rgb(value).into()) +} + +/// The left column of a meta row: icon and name. +pub fn meta_label(icon: &'static str, label: &'static str, cx: &App) -> impl IntoElement { + let muted = cx.theme().muted_foreground; + div() + .flex_none() + .w(px(96.)) + .flex() + .items_center() + .gap_1p5() + .text_xs() + .text_color(muted) + .child(svg().size(px(13.)).path(icon).text_color(muted)) + .child(label) +} + +pub fn none_label(cx: &App) -> AnyElement { + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("None") + .into_any_element() +} + +/// The reviewers' avatars, overlapping, each with its verdict. +pub fn reviewer_avatars(entries: &[ReviewerEntry], cx: &App) -> AnyElement { + let theme = cx.theme(); + let background = theme.background; + div() + .flex() + .items_center() + .children(entries.iter().enumerate().map(|(i, entry)| { + let login = entry + .actor + .as_ref() + .map_or("ghost".to_owned(), |a| a.login.clone()); + let named = match entry.actor.as_ref().and_then(|a| a.name.as_deref()) { + Some(name) if name != login => format!("{name} (@{login})"), + _ => login.clone(), + }; + let tip: SharedString = match entry.outcome { + Some(outcome) if entry.stale => format!("{named}: {}", outcome.stale_label()), + Some(outcome) => format!("{named}: {}", outcome.label()), + None => named, + } + .into(); + let ring = entry.outcome.map(|outcome| { + let color = outcome_color(outcome, cx); + if entry.stale { + color.opacity(0.35) + } else { + color + } + }); + div() + .id(("pr-reviewer", i)) + .relative() + .flex_none() + .when(i > 0, |el| el.ml(px(-4.))) + .rounded_full() + .border_2() + .border_color(ring.unwrap_or(background)) + .child(avatar(entry.actor.as_ref(), px(AVATAR), cx)) + .when_some(entry.outcome, |el, outcome| { + el.child( + div() + .absolute() + .right(px(-3.)) + .bottom(px(-3.)) + .rounded_full() + .bg(background) + .child( + svg() + .size(px(11.)) + .path(outcome_icon(outcome)) + .text_color(ring.unwrap_or(background)), + ), + ) + }) + .tooltip(move |window, cx| Tooltip::new(tip.clone()).build(window, cx)) + })) + .into_any_element() +} + +/// A label as a chip: tinted in its colour, with a dot of it. +pub fn label_chip(label: &Label, cx: &App) -> AnyElement { + let theme = cx.theme(); + let color = label_color(label.color.as_deref()); + let tint = color.unwrap_or(theme.muted_foreground); + div() + .flex_none() + .max_w(px(192.)) + .h(px(20.)) + .px_2() + .flex() + .items_center() + .gap_1() + .rounded_full() + .border_1() + .border_color(tint.opacity(0.45)) + .bg(tint.opacity(0.14)) + .text_xs() + .text_color(theme.foreground) + .child(div().flex_none().size_2().rounded_full().bg(tint)) + .child(div().min_w_0().truncate().child(label.name.clone())) + .into_any_element() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn label_colours_come_from_hex() { + let red: Hsla = rgb(0xd73a4a).into(); + assert_eq!(label_color(Some("d73a4a")), Some(red)); + assert_eq!(label_color(Some("#D73A4A")), Some(red)); + assert_eq!(label_color(Some("fff")), None); + assert_eq!(label_color(Some("zzzzzz")), None); + assert_eq!(label_color(None), None); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/summary/picker.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/summary/picker.rs new file mode 100644 index 000000000..22691cf8b --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/summary/picker.rs @@ -0,0 +1,295 @@ +//! The popover list behind the Reviewers and Labels rows: a search field +//! over the candidates the host offers, the requested or applied ones +//! checked. A click asks the Summary tab to toggle one +//! ([`PickerToggle`]); the tab writes and ticks it when that worked. + +use super::super::avatar::avatar; +use github::model::{Actor, Reviewer}; +use gpui_kit::component::input::{Input, InputEvent, InputState}; +use gpui_kit::component::{ActiveTheme, Sizable, Size}; +use gpui_kit::{ + Context, Entity, EventEmitter, Hsla, Render, SharedString, Subscription, Window, div, + prelude::*, px, svg, +}; + +/// What a picker row stands for. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Candidate { + Reviewer(Reviewer), + Label(String), +} + +/// What leads a row. +#[derive(Clone, Debug)] +pub enum Leading { + Avatar(Actor), + /// A label's colour; `None` for one without. + Dot(Option), +} + +#[derive(Clone, Debug)] +pub struct PickerEntry { + pub candidate: Candidate, + pub label: SharedString, + /// A name, a description, "team". + pub detail: Option, + pub leading: Leading, + pub checked: bool, +} + +impl PickerEntry { + /// The search matches the label or the detail, ignoring case. + pub fn matches(&self, query: &str) -> bool { + let query = query.trim().to_lowercase(); + query.is_empty() + || self.label.to_lowercase().contains(&query) + || self + .detail + .as_ref() + .is_some_and(|d| d.to_lowercase().contains(&query)) + } +} + +/// The texts of one kind of picker. +#[derive(Clone, Copy)] +pub struct PickerTexts { + pub search: &'static str, + pub empty: &'static str, + pub no_match: &'static str, + pub failed: &'static str, + pub truncated: &'static str, +} + +pub const REVIEWER_TEXTS: PickerTexts = PickerTexts { + search: "Search people with access", + empty: "Nobody else has access to this repository.", + no_match: "Nobody with access matches that.", + failed: "The people with access could not be read.", + truncated: "More people have access than are listed here.", +}; + +pub const LABEL_TEXTS: PickerTexts = PickerTexts { + search: "Search labels", + empty: "This repository has no labels.", + no_match: "No label matches that.", + failed: "The labels could not be read.", + truncated: "The repository has more labels than are listed here.", +}; + +/// Toggle `0`: request or apply it when `1`, else take it back. +pub struct PickerToggle(pub Candidate, pub bool); + +enum Candidates { + Loading, + Failed, + Read { + entries: Vec, + truncated: bool, + }, +} + +pub struct CandidatePicker { + texts: PickerTexts, + search: Entity, + query: String, + candidates: Candidates, + /// A toggle is being written. + busy: bool, + _search: Subscription, +} + +impl EventEmitter for CandidatePicker {} + +impl CandidatePicker { + pub fn new(texts: PickerTexts, window: &mut Window, cx: &mut Context) -> Self { + let search = cx.new(|cx| InputState::new(window, cx).placeholder(texts.search)); + search.update(cx, |search, cx| search.focus(window, cx)); + let subscription = cx.subscribe(&search, |this, search, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + this.query = search.read(cx).value().to_string(); + cx.notify(); + } + }); + Self { + texts, + search, + query: String::new(), + candidates: Candidates::Loading, + busy: false, + _search: subscription, + } + } + + pub fn set_entries( + &mut self, + entries: Vec, + truncated: bool, + cx: &mut Context, + ) { + self.candidates = Candidates::Read { entries, truncated }; + cx.notify(); + } + + #[cfg(test)] + pub fn entries(&self) -> &[PickerEntry] { + match &self.candidates { + Candidates::Read { entries, .. } => entries, + Candidates::Loading | Candidates::Failed => &[], + } + } + + pub fn set_failed(&mut self, cx: &mut Context) { + self.candidates = Candidates::Failed; + cx.notify(); + } + + pub fn set_busy(&mut self, busy: bool, cx: &mut Context) { + self.busy = busy; + cx.notify(); + } + + /// The write for `candidate` went through. + pub fn set_checked(&mut self, candidate: &Candidate, checked: bool, cx: &mut Context) { + if let Candidates::Read { entries, .. } = &mut self.candidates + && let Some(entry) = entries.iter_mut().find(|e| e.candidate == *candidate) + { + entry.checked = checked; + cx.notify(); + } + } + + fn render_entry( + &self, + ix: usize, + entry: &PickerEntry, + cx: &mut Context, + ) -> impl IntoElement + use<> { + let theme = cx.theme(); + let muted = theme.muted_foreground; + let leading = match &entry.leading { + Leading::Avatar(actor) => avatar(Some(actor), px(18.), cx), + Leading::Dot(color) => div() + .flex_none() + .size_2() + .rounded_full() + .bg(color.unwrap_or(muted)) + .into_any_element(), + }; + let candidate = entry.candidate.clone(); + let checked = entry.checked; + div() + .id(("picker-entry", ix)) + .px_2() + .py_1() + .rounded_md() + .flex() + .items_center() + .gap_2() + .text_xs() + .when(!self.busy, |row| { + row.cursor_pointer() + .hover(|s| s.bg(theme.muted)) + .on_click(cx.listener(move |_, _, _, cx| { + cx.emit(PickerToggle(candidate.clone(), !checked)) + })) + }) + .child(leading) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .gap_1() + .child( + div() + .flex_none() + .text_color(theme.foreground) + .child(entry.label.clone()), + ) + .when_some(entry.detail.clone(), |el, detail| { + el.child(div().min_w_0().truncate().text_color(muted).child(detail)) + }), + ) + .when(checked, |row| { + row.child( + svg() + .flex_none() + .size(px(13.)) + .path("icons/check.svg") + .text_color(theme.foreground), + ) + }) + } +} + +impl Render for CandidatePicker { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let muted = cx.theme().muted_foreground; + let note = |text: &'static str| div().px_2().py_1().text_xs().text_color(muted).child(text); + let body = match &self.candidates { + Candidates::Loading => note("Loading…").into_any_element(), + Candidates::Failed => note(self.texts.failed).into_any_element(), + Candidates::Read { entries, truncated } => { + let shown: Vec<(usize, PickerEntry)> = entries + .iter() + .enumerate() + .filter(|(_, e)| e.matches(&self.query)) + .map(|(i, e)| (i, e.clone())) + .collect(); + let truncated = *truncated; + div() + .flex() + .flex_col() + .when(entries.is_empty(), |el| el.child(note(self.texts.empty))) + .when(!entries.is_empty() && shown.is_empty(), |el| { + el.child(note(self.texts.no_match)) + }) + .children( + shown + .iter() + .map(|(ix, entry)| self.render_entry(*ix, entry, cx)), + ) + .when(truncated, |el| el.child(note(self.texts.truncated))) + .into_any_element() + } + }; + div() + .w(px(280.)) + .flex() + .flex_col() + .gap_1() + .child(Input::new(&self.search).with_size(Size::Small)) + .child( + div() + .id("picker-candidates") + .max_h(px(280.)) + .overflow_y_scroll() + .child(body), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(label: &str, detail: Option<&str>) -> PickerEntry { + PickerEntry { + candidate: Candidate::Label(label.into()), + label: label.to_owned().into(), + detail: detail.map(|d| d.to_owned().into()), + leading: Leading::Dot(None), + checked: false, + } + } + + #[test] + fn the_search_matches_label_or_detail() { + let bug = entry("bug", Some("Something isn't working")); + assert!(bug.matches("")); + assert!(bug.matches(" BU ")); + assert!(bug.matches("working")); + assert!(!bug.matches("docs")); + assert!(!entry("docs", None).matches("bug")); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/summary/rows.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/summary/rows.rs new file mode 100644 index 000000000..6f3447fd6 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/summary/rows.rs @@ -0,0 +1,197 @@ +//! The Summary tab as rows of one virtualized list: the meta rows, the +//! description, the checks, then the conversation, the newest ten +//! conversations first and older ones ten at a time. + +use super::super::conversation::logic::page; +use gpui_kit::{Pixels, SharedString}; + +/// Conversations shown at first, and added per "Show older". +pub const PAGE: usize = 10; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Row { + /// Reviewers and labels. + Meta, + Description, + Checks, + /// "Comments (N)" with the filter and the order. + CommentsHeader, + /// Loading, nothing to show, or the conversation read only in part. + CommentsNote, + /// Shows up to [`PAGE`] more of the `hidden` older conversations. + ShowOlder { + hidden: usize, + }, + /// A conversation item by id. + Item(SharedString), + /// Back to the newest [`PAGE`]. + ShowRecent, +} + +/// How the conversation part is laid out. +pub struct CommentsLayout<'a> { + /// The items the filter lets through, oldest first. + pub ids: &'a [SharedString], + pub newest_first: bool, + /// Conversations to show at most. + pub count: usize, + /// The Comments section is unfolded. + pub open: bool, + /// There is something to say above the items. + pub note: bool, +} + +pub fn summary_rows(layout: CommentsLayout) -> Vec { + let mut rows = vec![ + Row::Meta, + Row::Description, + Row::Checks, + Row::CommentsHeader, + ]; + if !layout.open { + return rows; + } + if layout.note { + rows.push(Row::CommentsNote); + } + let (range, hidden) = page(layout.ids.len(), layout.count); + let items = layout.ids[range].iter().cloned().map(Row::Item); + let older = (hidden > 0).then_some(Row::ShowOlder { hidden }); + if layout.newest_first { + rows.extend(items.rev()); + rows.extend(older); + } else { + rows.extend(older); + rows.extend(items); + } + if layout.count > PAGE { + rows.push(Row::ShowRecent); + } + rows +} + +/// The section header to pin above the list while its section scrolls +/// under the top edge (`top`: the row there and how far it is scrolled +/// into it): Description and Checks are one row each, the Comments header +/// heads every row after it. +pub fn pinned_section(rows: &[Row], top: usize, offset: Pixels) -> Option { + let row = rows.get(top)?; + let scrolled = offset > Pixels::ZERO; + match row { + Row::Description | Row::Checks | Row::CommentsHeader if scrolled => Some(row.clone()), + Row::Meta | Row::Description | Row::Checks | Row::CommentsHeader => None, + _ => Some(Row::CommentsHeader), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids(n: usize) -> Vec { + (0..n).map(|i| SharedString::from(i.to_string())).collect() + } + + fn shown(rows: &[Row]) -> Vec { + rows.iter() + .skip(4) + .map(|row| match row { + Row::Item(id) => id.to_string(), + Row::ShowOlder { hidden } => format!("older {hidden}"), + Row::ShowRecent => "recent".into(), + Row::CommentsNote => "note".into(), + other => format!("{other:?}"), + }) + .collect() + } + + #[test] + fn the_newest_ten_show_first() { + let ids = ids(12); + let rows = summary_rows(CommentsLayout { + ids: &ids, + newest_first: true, + count: PAGE, + open: true, + note: false, + }); + assert_eq!( + rows[..4], + [ + Row::Meta, + Row::Description, + Row::Checks, + Row::CommentsHeader + ] + ); + assert_eq!( + shown(&rows), + [ + "11", "10", "9", "8", "7", "6", "5", "4", "3", "2", "older 2" + ] + ); + } + + #[test] + fn oldest_first_puts_the_older_ones_on_top() { + let ids = ids(12); + let rows = summary_rows(CommentsLayout { + ids: &ids, + newest_first: false, + count: PAGE, + open: true, + note: true, + }); + assert_eq!(&shown(&rows)[..3], ["note", "older 2", "2"]); + assert_eq!(shown(&rows).last().unwrap(), "11"); + } + + #[test] + fn more_pages_can_go_back_to_the_recent() { + let ids = ids(12); + let rows = summary_rows(CommentsLayout { + ids: &ids, + newest_first: true, + count: 2 * PAGE, + open: true, + note: false, + }); + let shown = shown(&rows); + assert_eq!(shown.len(), 13); + assert_eq!(shown.last().unwrap(), "recent"); + } + + #[test] + fn folded_comments_show_no_items() { + let ids = ids(3); + let rows = summary_rows(CommentsLayout { + ids: &ids, + newest_first: true, + count: PAGE, + open: false, + note: true, + }); + assert_eq!(rows.len(), 4); + } + + #[test] + fn a_section_scrolled_under_the_top_pins_its_header() { + use gpui_kit::px; + let ids = ids(3); + let rows = summary_rows(CommentsLayout { + ids: &ids, + newest_first: true, + count: PAGE, + open: true, + note: false, + }); + assert_eq!(pinned_section(&rows, 0, px(30.)), None); + assert_eq!(pinned_section(&rows, 1, px(0.)), None, "its header shows"); + assert_eq!(pinned_section(&rows, 1, px(5.)), Some(Row::Description)); + assert_eq!(pinned_section(&rows, 2, px(5.)), Some(Row::Checks)); + assert_eq!(pinned_section(&rows, 3, px(0.)), None); + assert_eq!(pinned_section(&rows, 3, px(1.)), Some(Row::CommentsHeader)); + assert_eq!(pinned_section(&rows, 5, px(0.)), Some(Row::CommentsHeader)); + assert_eq!(pinned_section(&rows, 99, px(0.)), None); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/tab_bar.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/tab_bar.rs new file mode 100644 index 000000000..e135c152c --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/tab_bar.rs @@ -0,0 +1,177 @@ +//! The row under the header: Summary · Timeline · Code, and on its right +//! what belongs to the shown tab: the checks ("5 of 6 passing", a popover +//! lists them) on Summary, the comment and commit counts and the order on +//! Timeline. + +use super::PullRequestIntent; +use super::detail::{DetailTab, DetailView}; +use super::presentation::{GLYPH_SIZE, check_status_glyph, checks_glyph, summarize_checks}; +use super::timeline::TimelineTab; +use crate::shared::segmented_switch::segmented_switch; +use github::model::{Activity, Check, ChecksState}; +use gpui_kit::component::button::{Button, ButtonVariants}; +use gpui_kit::component::popover::Popover; +use gpui_kit::component::{ActiveTheme, Icon, Sizable}; +use gpui_kit::{ + AnyElement, App, Context, Entity, SharedString, WeakEntity, Window, div, prelude::*, px, svg, +}; + +pub fn render_tab_row( + tab: DetailTab, + right: Option, + window: &mut Window, + cx: &mut Context, +) -> impl IntoElement { + let view = cx.entity().downgrade(); + div() + .flex_none() + .px_4() + .py_2() + .flex() + .items_center() + .gap_2() + .border_b_1() + .border_color(cx.theme().border) + .child(segmented_switch( + "pr-tab", + DetailTab::ALL, + tab, + move |tab, _, cx| { + view.update(cx, |view, cx| view.select_tab(tab, cx)).ok(); + }, + window, + cx, + )) + .child(div().flex_1()) + .children(right) +} + +/// "✓ 5 of 6 passing"; a click lists the checks. +pub fn checks_slot( + checks: &[Check], + state: Option, + view: WeakEntity, + cx: &App, +) -> Option { + if checks.is_empty() { + return None; + } + let glyph = state.map(|state| checks_glyph(state, cx)); + let summary = summarize_checks(checks); + let checks = checks.to_vec(); + Some( + Popover::new("pr-checks") + .anchor(gpui_kit::Anchor::TopRight) + .trigger( + Button::new("pr-checks-trigger") + .ghost() + .xsmall() + .when_some(glyph, |button, glyph| { + button.icon(Icon::default().path(glyph.icon).text_color(glyph.color)) + }) + .label(summary), + ) + .content(move |_, _, cx| checks_list(&checks, &view, cx)) + .into_any_element(), + ) +} + +fn checks_list(checks: &[Check], view: &WeakEntity, cx: &App) -> AnyElement { + div() + .w(px(320.)) + .max_h(px(360.)) + .flex() + .flex_col() + .gap_0p5() + .children(checks.iter().enumerate().map(|(i, check)| { + let glyph = check_status_glyph(check, cx); + let url = check.url.clone(); + let view = view.clone(); + div() + .id(("pr-check", i)) + .px_1() + .py_0p5() + .rounded_sm() + .flex() + .items_center() + .gap_2() + .text_xs() + .child(glyph.render(GLYPH_SIZE)) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_color(cx.theme().foreground) + .child(check.name.clone()), + ) + .child( + div() + .flex_none() + .text_color(cx.theme().muted_foreground) + .child(glyph.label.clone()), + ) + .when_some(url, |row, url| { + row.cursor_pointer() + .hover(|s| s.bg(cx.theme().muted)) + .on_click(move |_, _, cx| { + view.update(cx, |_, cx| { + cx.emit(PullRequestIntent::OpenUrl(url.clone())) + }) + .ok(); + }) + }) + })) + .into_any_element() +} + +/// "💬 3 · ⟜ 1 · ⇅ Newest first"; the order toggles on a click. +pub fn timeline_slot( + activity: Option<&Activity>, + timeline: &Entity, + cx: &App, +) -> AnyElement { + let muted = cx.theme().muted_foreground; + let count = |icon: &'static str, count: usize| { + div() + .flex() + .items_center() + .gap_1() + .child(svg().size(px(13.)).path(icon).text_color(muted)) + .child(count.to_string()) + }; + let newest_first = timeline.read(cx).newest_first(); + let timeline = timeline.downgrade(); + div() + .flex() + .items_center() + .gap_2() + .text_xs() + .text_color(muted) + .when_some(activity, |row, activity| { + row.child(count( + "icons/message_square.svg", + activity.comment_count as usize, + )) + .child("·") + .child(count( + "icons/git_commit_horizontal.svg", + activity.commits.len(), + )) + }) + .child( + Button::new("pr-timeline-order") + .ghost() + .xsmall() + .icon(Icon::default().path("icons/arrow_up_down.svg")) + .label(SharedString::from(if newest_first { + "Newest first" + } else { + "Oldest first" + })) + .on_click(move |_, _, cx| { + timeline.update(cx, |t, cx| t.toggle_order(cx)).ok(); + }), + ) + .into_any_element() +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/timeline.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/timeline.rs new file mode 100644 index 000000000..93e439a09 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/timeline.rs @@ -0,0 +1,764 @@ +//! The Timeline tab: the pull request's history on a rail of 32 px +//! markers (icons or avatars): opened, commits (a click asks for the Code +//! tab scoped to that commit, [`OpenCommit`]), plain comments folded into +//! groups ("3 comments · 2 authors"), review verdicts (muted once newer +//! commits came), review threads, merged or closed. Newest first unless +//! the tab row's toggle says otherwise ([`TimelineTab::set_newest_first`]). +//! +//! One virtualized list; cards are mounted when first drawn and kept per +//! comment or thread id across refreshes. + +mod logic; + +use super::PullRequestIntent; +use super::avatar::avatar; +use super::conversation::logic::{ + ConversationItem, ReviewOutcome, conversation_items, is_verdict_stale, newest_commit_at, + splice_list, +}; +use super::conversation::parts::open_on_github; +use super::conversation::{CardStyle, CommentCard, Location, ThreadCard}; +use super::data::PullRequestData; +use super::detail::TabScroll; +use super::preferences; +use super::presentation::{Tone, diff_stat, relative_time}; +use chrono::{DateTime, Utc}; +use github::model::{Activity, Actor, Comment, Commit, Detail}; +use gpui_kit::component::ActiveTheme; +use gpui_kit::{ + AnyElement, Context, Entity, EventEmitter, FontWeight, Hsla, ListAlignment, ListState, Pixels, + Render, SharedString, Subscription, Window, div, list, prelude::*, px, svg, +}; +use logic::{TimelineEvent, TimelineRow, build_timeline, distinct_authors, timeline_rows}; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +const LIST_OVERDRAW: Pixels = px(600.); +const MARKER: Pixels = px(32.); +/// Left of the row text: the marker and a gap. +const RAIL_INDENT: Pixels = px(48.); + +/// Show the Code tab scoped to this commit (its oid). The detail view +/// subscribes and switches tabs. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OpenCommit(pub String); + +enum Card { + Comment(Entity), + Thread(Entity), +} + +struct MountedCard { + card: Card, + _intents: Subscription, +} + +pub struct TimelineTab { + data: Entity, + /// What the rows were built from. + detail: Option>, + activity: Option>, + newest_first: bool, + /// The remembered order was taken over. + order_read: bool, + /// Newest first. + events: Vec, + rows: Vec, + keys: Vec, + list: ListState, + newest_commit_at: Option>, + /// Comment groups unfolded, by their first comment's id. + open_groups: HashSet, + cards: HashMap, +} + +impl EventEmitter for TimelineTab {} +impl EventEmitter for TimelineTab {} + +fn same(a: &Option>, b: &Option>) -> bool { + match (a, b) { + (Some(a), Some(b)) => Arc::ptr_eq(a, b), + (None, None) => true, + _ => false, + } +} + +fn row_key(row: &TimelineRow) -> String { + match row { + TimelineRow::Event(event) => match event.as_ref() { + TimelineEvent::Opened { .. } => "opened".into(), + TimelineEvent::Commit(commit) => format!("commit:{}", commit.oid), + TimelineEvent::Comment(comment) => comment.id.clone(), + TimelineEvent::Thread(thread) => thread.thread.id.clone(), + TimelineEvent::Merged { .. } => "merged".into(), + TimelineEvent::Closed { .. } => "closed".into(), + }, + TimelineRow::Comments(comments) => { + let ids: Vec<&str> = comments.iter().map(|c| c.id.as_str()).collect(); + format!("group:{}", ids.join(",")) + } + } +} + +impl TimelineTab { + /// How far the tab is scrolled; the header condenses once it is. + pub fn scroll(&self) -> TabScroll { + TabScroll::of(&self.list) + } + + pub fn new(data: Entity) -> Self { + Self { + data, + detail: None, + activity: None, + // Taken from the preferences when first drawn. + newest_first: true, + order_read: false, + events: Vec::new(), + rows: Vec::new(), + keys: Vec::new(), + list: ListState::new(0, ListAlignment::Top, LIST_OVERDRAW), + newest_commit_at: None, + open_groups: HashSet::new(), + cards: HashMap::new(), + } + } + + pub fn newest_first(&self) -> bool { + self.newest_first + } + + pub fn set_newest_first(&mut self, newest_first: bool, cx: &mut Context) { + self.order_read = true; + if self.newest_first != newest_first { + self.newest_first = newest_first; + preferences::update(cx, |p| p.conversation.timeline_oldest_first = !newest_first); + self.rebuild_rows(); + cx.notify(); + } + } + + pub fn toggle_order(&mut self, cx: &mut Context) { + self.set_newest_first(!self.newest_first, cx); + } + + /// Take over what the page knows now (each frame; cheap unless it + /// changed). + fn sync(&mut self, cx: &mut Context) { + if !self.order_read { + self.order_read = true; + let newest_first = !preferences::read(cx).conversation.timeline_oldest_first; + if newest_first != self.newest_first { + self.newest_first = newest_first; + self.rebuild_rows(); + // The tab row shows the order. + cx.notify(); + } + } + let (detail, activity) = { + let data = self.data.read(cx); + (data.detail.clone(), data.activity.clone()) + }; + if same(&detail, &self.detail) && same(&activity, &self.activity) { + return; + } + self.detail = detail; + self.activity = activity; + let (Some(detail), Some(activity)) = (self.detail.clone(), self.activity.clone()) else { + return; + }; + let conversation = conversation_items(&activity.comments, &activity.review_threads); + self.newest_commit_at = newest_commit_at(&activity.commits); + self.refresh_cards(&activity, &conversation, cx); + self.events = build_timeline(&detail, &activity, &conversation); + self.rebuild_rows(); + } + + /// Mounted cards take the new read; those of items gone go. + fn refresh_cards( + &mut self, + activity: &Activity, + conversation: &[ConversationItem], + cx: &mut Context, + ) { + let comments: HashMap<&str, &Comment> = activity + .comments + .iter() + .map(|c| (c.id.as_str(), c)) + .collect(); + let threads: HashMap<&str, &ConversationItem> = conversation + .iter() + .filter(|item| item.thread().is_some()) + .map(|item| (item.id(), item)) + .collect(); + let newest = self.newest_commit_at; + self.cards.retain(|id, mounted| match &mounted.card { + Card::Comment(card) => match comments.get(id.as_str()) { + Some(comment) => { + let stale = is_stale(comment, newest); + card.update(cx, |card, cx| { + card.set_comment((*comment).clone(), cx); + card.set_stale(stale, cx); + }); + true + } + None => false, + }, + Card::Thread(card) => match threads.get(id.as_str()).and_then(|i| i.thread()) { + Some(item) => { + card.update(cx, |card, cx| card.set_item(item.clone(), cx)); + true + } + None => false, + }, + }); + } + + fn rebuild_rows(&mut self) { + let rows = timeline_rows(self.events.clone(), self.newest_first); + let keys: Vec = rows.iter().map(row_key).collect(); + splice_list(&self.list, &self.keys, &keys); + self.rows = rows; + self.keys = keys; + } + + fn toggle_group(&mut self, ix: usize, first: String, cx: &mut Context) { + if !self.open_groups.remove(&first) { + self.open_groups.insert(first); + } + self.list.remeasure_items(ix..ix + 1); + cx.notify(); + } + + fn comment_card(&mut self, comment: &Comment, cx: &mut Context) -> Entity { + if let Some(MountedCard { + card: Card::Comment(card), + .. + }) = self.cards.get(&comment.id) + { + return card.clone(); + } + let data = self.data.clone(); + let stale = is_stale(comment, self.newest_commit_at); + let location = comment.path.clone().map(|path| Location { + path, + line: None, + outdated: false, + }); + let comment = comment.clone(); + let id = comment.id.clone(); + let card = cx.new(|cx| { + let mut card = CommentCard::new(data, comment, CardStyle::Entry, cx) + .with_location(location) + .with_agent_button(true) + .with_github_link(true); + card.set_stale(stale, cx); + card + }); + let intents = cx.subscribe(&card, |_, _, intent: &PullRequestIntent, cx| { + cx.emit(intent.clone()) + }); + self.cards.insert( + id, + MountedCard { + card: Card::Comment(card.clone()), + _intents: intents, + }, + ); + card + } + + fn thread_card( + &mut self, + item: &super::conversation::logic::ThreadItem, + cx: &mut Context, + ) -> Entity { + if let Some(MountedCard { + card: Card::Thread(card), + .. + }) = self.cards.get(&item.thread.id) + { + return card.clone(); + } + let data = self.data.clone(); + let item = item.clone(); + let id = item.thread.id.clone(); + let card = cx.new(|_| { + ThreadCard::new(data, item) + .show_location(true) + .with_github_link(true) + }); + let intents = cx.subscribe(&card, |_, _, intent: &PullRequestIntent, cx| { + cx.emit(intent.clone()) + }); + self.cards.insert( + id, + MountedCard { + card: Card::Thread(card.clone()), + _intents: intents, + }, + ); + card + } + + #[cfg(test)] + fn row_keys(&self) -> &[String] { + &self.keys + } + + fn render_row(&mut self, ix: usize, cx: &mut Context) -> AnyElement { + let Some(row) = self.rows.get(ix).cloned() else { + return div().into_any_element(); + }; + let first = ix == 0; + let last = ix + 1 == self.rows.len(); + let (marker, content) = match row { + TimelineRow::Comments(comments) => self.render_group(ix, &comments, cx), + TimelineRow::Event(event) => self.render_event(*event, cx), + }; + rail_row(first, last, marker, content, cx) + } + + fn render_event( + &mut self, + event: TimelineEvent, + cx: &mut Context, + ) -> (AnyElement, AnyElement) { + let muted = cx.theme().muted_foreground; + match event { + TimelineEvent::Opened { at, author } => ( + icon_marker(Tone::Open.icon(), muted, cx), + lifecycle(author.as_ref(), "Pull request opened", at, cx), + ), + TimelineEvent::Merged { at } => ( + icon_marker(Tone::Merged.icon(), Tone::Merged.color(cx), cx), + lifecycle(None, "Pull request merged", at, cx), + ), + TimelineEvent::Closed { at } => ( + icon_marker(Tone::Closed.icon(), Tone::Closed.color(cx), cx), + lifecycle(None, "Pull request closed", at, cx), + ), + TimelineEvent::Commit(commit) => self.render_commit(&commit, cx), + TimelineEvent::Comment(comment) => { + let stale = is_stale(&comment, self.newest_commit_at); + let marker = actor_marker(comment.author.as_ref(), stale, cx); + let card = self.comment_card(&comment, cx); + (marker, div().py_1().child(card).into_any_element()) + } + TimelineEvent::Thread(item) => { + let card = self.thread_card(&item, cx); + let folded = !card.read(cx).is_expanded(); + let marker = actor_marker(item.root.author.as_ref(), folded, cx); + (marker, card.into_any_element()) + } + } + } + + fn render_commit(&self, commit: &Commit, cx: &mut Context) -> (AnyElement, AnyElement) { + let github_link = self.detail.as_ref().map(|detail| { + let url = format!("{}/commits/{}", detail.url, commit.oid); + open_on_github(format!("commit-{}-github", commit.oid).into(), url, cx) + }); + let theme = cx.theme(); + let muted = theme.muted_foreground; + let marker = match commit.authors.first() { + Some(author) => actor_marker(Some(author), false, cx), + None => icon_marker("icons/git_commit_horizontal.svg", muted, cx), + }; + let oid = commit.oid.clone(); + let short: String = commit.oid.chars().take(7).collect(); + let headline = if commit.message_headline.is_empty() { + "Untitled commit".to_owned() + } else { + commit.message_headline.clone() + }; + let stat = match (commit.additions, commit.deletions) { + (Some(additions), Some(deletions)) => diff_stat(additions, deletions, cx), + _ => None, + }; + let row = div() + .id(SharedString::from(format!("commit-{}", commit.oid))) + .flex_1() + .min_w_0() + .debug_selector({ + let selector = format!("pr-commit-{short}"); + move || selector.clone() + }) + .min_h(MARKER) + .flex() + .items_center() + .gap_2p5() + .cursor_pointer() + .group("commit") + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .gap_0p5() + .child( + div() + .truncate() + .text_xs() + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.foreground) + .group_hover("commit", |s| s.text_color(theme.primary)) + .child(headline), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .text_xs() + .text_color(muted) + .child( + div() + .font_family(theme.mono_font_family.clone()) + .child(short), + ) + .child(relative_time(commit.committed_date, Utc::now())), + ), + ) + .children(stat.map(|stat| div().flex_none().text_xs().child(stat))) + .on_click(cx.listener(move |_, _, _, cx| cx.emit(OpenCommit(oid.clone())))); + let content = div() + .flex() + .items_center() + .gap_1() + .child(row) + .children(github_link) + .into_any_element(); + (marker, content) + } + + fn render_group( + &mut self, + ix: usize, + comments: &[Comment], + cx: &mut Context, + ) -> (AnyElement, AnyElement) { + let first = comments[0].id.clone(); + let open = self.open_groups.contains(&first); + let authors = distinct_authors(comments); + let marker = actor_marker(authors.first(), !open, cx); + let cards: Vec> = if open { + comments.iter().map(|c| self.comment_card(c, cx)).collect() + } else { + Vec::new() + }; + let theme = cx.theme(); + let muted = theme.muted_foreground; + let count = comments.len(); + let header = div() + .id(SharedString::from(format!("group-{first}"))) + .debug_selector({ + let selector = format!("pr-group-{first}"); + move || selector.clone() + }) + .min_h(MARKER) + .flex() + .items_center() + .gap_3() + .cursor_pointer() + .when(!open, |el| el.opacity(0.6)) + .hover(|s| s.opacity(1.)) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .child( + div() + .text_xs() + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.foreground) + .child(format!( + "{count} {}", + if count == 1 { "comment" } else { "comments" } + )), + ) + .child(div().truncate().text_xs().text_color(muted).child(format!( + "{} {} · {}", + authors.len(), + if authors.len() == 1 { + "author" + } else { + "authors" + }, + relative_time(comments[0].created_at, Utc::now()) + ))), + ) + .child( + svg() + .flex_none() + .size(px(13.)) + .path(if open { + "icons/chevron_up.svg" + } else { + "icons/chevron_down.svg" + }) + .text_color(muted), + ) + .on_click(cx.listener(move |this, _, _, cx| this.toggle_group(ix, first.clone(), cx))); + let content = div() + .flex() + .flex_col() + .child(header) + .when(open, |el| { + el.child(div().pt_1().flex().flex_col().gap_3().children(cards)) + }) + .into_any_element(); + (marker, content) + } +} + +fn is_stale(comment: &Comment, newest_commit_at: Option>) -> bool { + ReviewOutcome::of(comment.review_state).is_some() + && is_verdict_stale(comment.created_at, newest_commit_at) +} + +/// One row of the rail: the line through the markers, the marker, then +/// the content. +fn rail_row( + first: bool, + last: bool, + marker: AnyElement, + content: AnyElement, + cx: &Context, +) -> AnyElement { + let line = cx.theme().border.opacity(0.6); + let center = MARKER / 2.; + div() + .px_4() + .pt(if first { px(16.) } else { px(0.) }) + .child( + div() + .relative() + .pl(RAIL_INDENT) + .pb_5() + .min_h(MARKER) + .when(!(first && last), |row| { + row.child( + div() + .absolute() + .left(center - px(0.5)) + .w(px(1.)) + .bg(line) + .top(if first { center } else { px(0.) }) + .map(|el| if last { el.h(center) } else { el.bottom_0() }), + ) + }) + .child(div().absolute().left_0().top_0().child(marker)) + .child(content), + ) + .into_any_element() +} + +fn marker_box(cx: &Context) -> gpui_kit::Div { + div() + .size(MARKER) + .flex() + .items_center() + .justify_center() + .bg(cx.theme().background) +} + +fn icon_marker(icon: &'static str, color: Hsla, cx: &Context) -> AnyElement { + marker_box(cx) + .child(svg().size(px(14.)).path(icon).text_color(color)) + .into_any_element() +} + +/// The actor's avatar; `muted` for something folded or outdated. +fn actor_marker(actor: Option<&Actor>, muted: bool, cx: &Context) -> AnyElement { + marker_box(cx) + .child( + div() + .when(muted, |el| el.opacity(0.45)) + .child(avatar(actor, px(28.), cx)), + ) + .into_any_element() +} + +/// "ann Pull request opened", the time under it. +fn lifecycle( + actor: Option<&Actor>, + label: &'static str, + at: DateTime, + cx: &Context, +) -> AnyElement { + let theme = cx.theme(); + div() + .min_h(MARKER) + .flex() + .flex_col() + .justify_center() + .text_xs() + .child( + div() + .flex() + .items_center() + .gap_1p5() + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.foreground) + .when_some(actor, |row, actor| row.child(actor.login.clone())) + .child(label), + ) + .child( + div() + .text_color(theme.muted_foreground) + .child(relative_time(at, Utc::now())), + ) + .into_any_element() +} + +impl Render for TimelineTab { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.sync(cx); + if self.rows.is_empty() { + return div() + .size_full() + .px_4() + .py_3() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("Loading the timeline…") + .into_any_element(); + } + div() + .size_full() + .child( + list( + self.list.clone(), + cx.processor(|this: &mut Self, ix: usize, _, cx| this.render_row(ix, cx)), + ) + .size_full(), + ) + .into_any_element() + } +} + +#[cfg(test)] +mod tests { + use super::super::conversation::fixtures::{ + click, comment, commit, data_with_detail, review, window, + }; + use super::*; + use crate::main_screen::right_panel::pull_request::fixtures::{actor, hours_ago}; + use github::model::ReviewState; + use gpui_kit::{TestAppContext, VisualTestContext}; + use std::cell::RefCell; + use std::rc::Rc; + + fn timeline(cx: &mut TestAppContext) -> (Entity, &mut VisualTestContext) { + let data = data_with_detail(cx, Some("me")); + data.update(cx, |data, cx| { + data.activity = Some(Arc::new(Activity { + author: Some(actor("daniel-kurzynski")), + reviewers: Vec::new(), + comments: vec![ + comment("a", "ann", hours_ago(16)), + comment("b", "bob", hours_ago(15)), + review("v", "cid", ReviewState::Approved, hours_ago(14)), + ], + comment_count: 3, + comments_truncated: false, + review_threads: Vec::new(), + review_threads_truncated: false, + commits: vec![commit("d9aea40f", hours_ago(13))], + reactions: Vec::new(), + })); + cx.notify(); + }); + window(cx, move |_| TimelineTab::new(data)) + } + + #[gpui_kit::test] + fn the_rail_runs_newest_first_and_turns_around(cx: &mut TestAppContext) { + let (tab, cx) = timeline(cx); + tab.read_with(cx, |tab, _| { + assert_eq!( + tab.row_keys(), + ["commit:d9aea40f", "v", "group:b,a", "opened"] + ) + }); + tab.update(cx, |tab, cx| tab.set_newest_first(false, cx)); + tab.read_with(cx, |tab, _| { + assert_eq!( + tab.row_keys(), + ["opened", "group:a,b", "v", "commit:d9aea40f"] + ) + }); + } + + #[gpui_kit::test] + fn a_commit_asks_for_the_code_tab(cx: &mut TestAppContext) { + let (tab, cx) = timeline(cx); + let opened = Rc::new(RefCell::new(Vec::new())); + let sink = opened.clone(); + cx.update(|_, cx| { + cx.subscribe(&tab, move |_, event: &OpenCommit, _| { + sink.borrow_mut().push(event.clone()) + }) + .detach() + }); + let row = cx.debug_bounds("pr-commit-d9aea40").expect("a commit row"); + cx.simulate_click(row.center(), gpui_kit::Modifiers::none()); + assert_eq!(opened.borrow().as_slice(), [OpenCommit("d9aea40f".into())]); + } + + #[gpui_kit::test] + fn events_open_on_github(cx: &mut TestAppContext) { + let (tab, cx) = timeline(cx); + let opened = Rc::new(RefCell::new(Vec::new())); + let sink = opened.clone(); + cx.update(|_, cx| { + cx.subscribe(&tab, move |_, intent: &PullRequestIntent, _| { + sink.borrow_mut().push(intent.clone()) + }) + .detach() + }); + click(cx, "commit-d9aea40f-github"); + click(cx, "c-v-github"); + assert_eq!( + opened.borrow().as_slice(), + [ + PullRequestIntent::OpenUrl( + "https://github.com/stippi/code-assistant/pull/290/commits/d9aea40f".into() + ), + PullRequestIntent::OpenUrl("https://github.com/c/v".into()), + ] + ); + } + + #[gpui_kit::test] + fn the_remembered_order_applies(cx: &mut TestAppContext) { + cx.update(|cx| { + let mut settings = crate::shared::settings::UiSettings::default(); + settings.pull_requests.conversation.timeline_oldest_first = true; + cx.set_global(crate::UiSettingsGlobal(settings)); + }); + let (tab, cx) = timeline(cx); + tab.read_with(cx, |tab, _| { + assert!(!tab.newest_first()); + assert_eq!(tab.row_keys()[0], "opened"); + }); + } + + #[gpui_kit::test] + fn a_group_unfolds_into_its_comments(cx: &mut TestAppContext) { + let (tab, cx) = timeline(cx); + tab.read_with(cx, |tab, _| { + assert!(!tab.cards.contains_key("a"), "folded groups mount nothing") + }); + let group = cx.debug_bounds("pr-group-b").expect("a group"); + cx.simulate_click(group.center(), gpui_kit::Modifiers::none()); + cx.run_until_parked(); + tab.read_with(cx, |tab, _| { + assert!(tab.cards.contains_key("a") && tab.cards.contains_key("b")) + }); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/timeline/logic.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/timeline/logic.rs new file mode 100644 index 000000000..3d8ef3f04 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/timeline/logic.rs @@ -0,0 +1,260 @@ +//! The pull request's history as the Timeline tab lists it, ported from +//! T3 Code (`buildPullRequestTimeline`, `groupPullRequestTimelineConversations`): +//! opened, commits, the conversation (threads at their first comment's +//! time), merged or closed; plain comments next to each other fold into one +//! row. + +use super::super::conversation::logic::{ConversationItem, ReviewOutcome, ThreadItem}; +use chrono::{DateTime, Utc}; +use github::model::{Activity, Actor, Comment, Commit, Detail}; + +#[derive(Clone, Debug, PartialEq)] +pub enum TimelineEvent { + Opened { + at: DateTime, + author: Option, + }, + Commit(Commit), + /// A comment or review outside a thread. + Comment(Comment), + Thread(Box), + Merged { + at: DateTime, + }, + Closed { + at: DateTime, + }, +} + +impl TimelineEvent { + pub fn at(&self) -> DateTime { + match self { + TimelineEvent::Opened { at, .. } + | TimelineEvent::Merged { at } + | TimelineEvent::Closed { at } => *at, + TimelineEvent::Commit(commit) => commit.committed_date, + TimelineEvent::Comment(comment) => comment.created_at, + TimelineEvent::Thread(thread) => thread.root.created_at, + } + } + + /// A comment without a verdict, which folds with its neighbours. + fn plain_comment(&self) -> Option<&Comment> { + match self { + TimelineEvent::Comment(comment) + if ReviewOutcome::of(comment.review_state).is_none() => + { + Some(comment) + } + _ => None, + } + } +} + +/// The events, newest first. `conversation` is the conversation's items +/// ([`conversation_items`](super::super::conversation::logic::conversation_items)). +/// Merged wins over closed: GitHub sets both on a merge. +pub fn build_timeline( + detail: &Detail, + activity: &Activity, + conversation: &[ConversationItem], +) -> Vec { + let opened = TimelineEvent::Opened { + at: detail.created_at, + author: detail.author.clone().or_else(|| activity.author.clone()), + }; + let ended = match (detail.merged_at, detail.closed_at) { + (Some(at), _) => Some(TimelineEvent::Merged { at }), + (None, Some(at)) => Some(TimelineEvent::Closed { at }), + (None, None) => None, + }; + let mut events: Vec = std::iter::once(opened) + .chain(activity.commits.iter().cloned().map(TimelineEvent::Commit)) + .chain(conversation.iter().map(|item| match item { + ConversationItem::Comment(comment) => TimelineEvent::Comment(comment.clone()), + ConversationItem::Thread(thread) => TimelineEvent::Thread(Box::new(thread.clone())), + })) + .chain(ended) + .collect(); + // Stable: what happened at the same moment keeps the order above. + events.sort_by_key(|event| std::cmp::Reverse(event.at())); + events +} + +/// One row of the rail. +#[derive(Clone, Debug, PartialEq)] +pub enum TimelineRow { + Event(Box), + /// Plain comments next to each other. + Comments(Vec), +} + +/// `events` (newest first) in the shown order, neighbouring plain comments +/// folded. +pub fn timeline_rows(events: Vec, newest_first: bool) -> Vec { + let ordered: Box> = if newest_first { + Box::new(events.into_iter()) + } else { + Box::new(events.into_iter().rev()) + }; + let mut rows: Vec = Vec::new(); + for event in ordered { + match (event.plain_comment(), rows.last_mut()) { + (Some(comment), Some(TimelineRow::Comments(group))) => group.push(comment.clone()), + (Some(comment), _) => rows.push(TimelineRow::Comments(vec![comment.clone()])), + (None, _) => rows.push(TimelineRow::Event(Box::new(event))), + } + } + rows +} + +/// The authors of `comments`, each once, in order. +pub fn distinct_authors(comments: &[Comment]) -> Vec { + let mut authors: Vec = Vec::new(); + for author in comments.iter().filter_map(|c| c.author.as_ref()) { + if !authors.iter().any(|a| a.login == author.login) { + authors.push(author.clone()); + } + } + authors +} + +#[cfg(test)] +mod tests { + use super::super::super::conversation::fixtures::{comment, commit, review, thread}; + use super::super::super::conversation::logic::{conversation_items, thread_item}; + use super::*; + use crate::main_screen::right_panel::pull_request::fixtures::{ + actor, detail, detail_with, hours_ago, + }; + use github::model::{PullRequestState, ReviewState}; + + fn activity(comments: Vec, threads: Vec) -> Activity { + Activity { + author: Some(actor("ann")), + reviewers: Vec::new(), + comment_count: comments.len() as u64, + comments, + comments_truncated: false, + review_threads: threads, + review_threads_truncated: false, + commits: vec![commit("c1", hours_ago(17)), commit("c2", hours_ago(12))], + reactions: Vec::new(), + } + } + + fn label(event: &TimelineEvent) -> String { + match event { + TimelineEvent::Opened { .. } => "opened".into(), + TimelineEvent::Commit(c) => format!("commit {}", c.oid), + TimelineEvent::Comment(c) => format!("comment {}", c.id), + TimelineEvent::Thread(t) => format!("thread {}", t.thread.id), + TimelineEvent::Merged { .. } => "merged".into(), + TimelineEvent::Closed { .. } => "closed".into(), + } + } + + fn row_label(row: &TimelineRow) -> String { + match row { + TimelineRow::Event(event) => label(event), + TimelineRow::Comments(comments) => format!( + "comments {}", + comments + .iter() + .map(|c| c.id.as_str()) + .collect::>() + .join(",") + ), + } + } + + #[test] + fn events_run_newest_first_and_merged_wins_over_closed() { + let detail = detail_with(290, |d| { + d.created_at = hours_ago(18); + d.state = PullRequestState::Merged; + d.merged_at = Some(hours_ago(9)); + d.closed_at = Some(hours_ago(9)); + }); + let activity = activity( + vec![comment("a", "bob", hours_ago(15))], + vec![thread("t", &[("t-root", hours_ago(11))])], + ); + let conversation = conversation_items(&activity.comments, &activity.review_threads); + let events = build_timeline(&detail, &activity, &conversation); + let labels: Vec<_> = events.iter().map(label).collect(); + assert_eq!( + labels, + [ + "merged", + "thread t", + "commit c2", + "comment a", + "commit c1", + "opened" + ] + ); + } + + #[test] + fn a_closed_pull_request_ends_closed() { + let closed = detail_with(290, |d| { + d.state = PullRequestState::Closed; + d.closed_at = Some(hours_ago(1)); + }); + let events = build_timeline(&closed, &activity(Vec::new(), Vec::new()), &[]); + assert_eq!(label(&events[0]), "closed"); + let open = build_timeline(&detail(290), &activity(Vec::new(), Vec::new()), &[]); + assert!(open.iter().all(|e| !matches!( + e, + TimelineEvent::Merged { .. } | TimelineEvent::Closed { .. } + ))); + } + + #[test] + fn neighbouring_plain_comments_fold() { + let events = vec![ + TimelineEvent::Comment(comment("c4", "ann", hours_ago(1))), + TimelineEvent::Comment(review("r3", "bob", ReviewState::Commented, hours_ago(2))), + TimelineEvent::Comment(review("v2", "cid", ReviewState::Approved, hours_ago(3))), + TimelineEvent::Comment(comment("c1", "ann", hours_ago(4))), + TimelineEvent::Thread(Box::new( + thread_item(&thread("t", &[("x", hours_ago(5))]), &Default::default()).unwrap(), + )), + TimelineEvent::Comment(comment("c0", "ann", hours_ago(6))), + TimelineEvent::Comment(review("d", "dan", ReviewState::Dismissed, hours_ago(7))), + ]; + let rows: Vec<_> = timeline_rows(events.clone(), true) + .iter() + .map(row_label) + .collect(); + assert_eq!( + rows, + [ + "comments c4,r3", + "comment v2", + "comments c1", + "thread t", + "comments c0", + "comment d" + ] + ); + let oldest_first: Vec<_> = timeline_rows(events, false).iter().map(row_label).collect(); + assert_eq!(oldest_first[0], "comment d"); + assert_eq!(oldest_first[5], "comments r3,c4"); + } + + #[test] + fn group_authors_count_once() { + let comments = [ + comment("a", "ann", hours_ago(3)), + comment("b", "bob", hours_ago(2)), + comment("c", "ann", hours_ago(1)), + ]; + let logins: Vec<_> = distinct_authors(&comments) + .into_iter() + .map(|a| a.login) + .collect(); + assert_eq!(logins, ["ann", "bob"]); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/review_rows.rs b/crates/ui_gpui/src/main_screen/right_panel/review_rows.rs index b7068ea30..e727629d9 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/review_rows.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/review_rows.rs @@ -93,7 +93,7 @@ pub(super) fn flatten(repos: &[RepoOutline]) -> Vec { /// The smallest splice turning `old` into `new`: the range of `old` to /// replace and the number of rows replacing it. `None` when nothing changed. -pub(super) fn changed_span(old: &[ReviewRow], new: &[ReviewRow]) -> Option<(Range, usize)> { +pub(super) fn changed_span(old: &[T], new: &[T]) -> Option<(Range, usize)> { let prefix = old.iter().zip(new).take_while(|(a, b)| a == b).count(); if prefix == old.len() && prefix == new.len() { return None; @@ -238,7 +238,7 @@ mod tests { fn changed_span_is_none_for_equal_rows() { let rows = vec![RepoHeader { repo: 0 }, FileHeader { repo: 0, file: 0 }]; assert_eq!(changed_span(&rows, &rows), None); - assert_eq!(changed_span(&[], &[]), None); + assert_eq!(changed_span::(&[], &[]), None); } #[test] diff --git a/crates/ui_gpui/src/main_screen/right_panel/review_view.rs b/crates/ui_gpui/src/main_screen/right_panel/review_view.rs index de2088eb8..d707b046b 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/review_view.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/review_view.rs @@ -35,9 +35,12 @@ use super::review_rows::{ }; use crate::comments::CommentChange; use crate::comments::editor::CommentEditor; +use crate::diff::prepare::PreparedFileDiff; +use crate::diff::render::{ + GutterNumbers, added_row_colors, deleted_row_colors, diff_body_bg, render_diff_chunk, +}; use crate::shared::file_icons; -use crate::tool_cards::diff_card::{added_row_colors, deleted_row_colors, render_diff_chunk}; -use crate::{Gpui, PreparedReviewDiff, RepoReviewData}; +use crate::{Gpui, RepoReviewData}; use code_assistant_core::line_comments::LineComment; use code_assistant_core::session::{ReviewMode, ReviewScanState}; use git::{ChangeStatus, ChangedFile}; @@ -169,7 +172,7 @@ const REVIEW_LIST_OVERDRAW: gpui_kit::Pixels = px(512.); /// fingerprint or status), the diff is stale. struct LoadedDiff { file: ChangedFile, - prepared: PreparedReviewDiff, + prepared: PreparedFileDiff, /// Unique per arrival; tells the list rows of a reloaded diff apart. stamp: u64, } @@ -448,7 +451,8 @@ impl ReviewView { if let Some((repo_root, base, file)) = next { self.in_flight = Some(((repo_root.clone(), file.path.clone()), file.clone())); if let Some(gpui) = cx.try_global::() { - gpui.cmd_get_review_file_diff(session_id, repo_root, self.mode, base, file); + let theme = cx.theme().highlight_theme.clone(); + gpui.cmd_get_review_file_diff(session_id, repo_root, self.mode, base, file, theme); } } } @@ -1150,7 +1154,7 @@ impl ReviewView { fn render_chunk( &self, key: &FileKey, - prepared: &PreparedReviewDiff, + prepared: &PreparedFileDiff, chunk_ix: usize, window: &Window, cx: &Context, @@ -1161,21 +1165,10 @@ impl ReviewView { }; let theme = cx.theme(); let rem_size = window.rem_size(); - let is_dark = theme.background.l < 0.5; - let body_bg = if is_dark { - gpui_kit::hsla(0.0, 0.0, 0.08, 1.0) - } else { - gpui_kit::hsla(0.0, 0.0, 0.97, 1.0) - }; let line_height_px = rems(1.25).to_pixels(rem_size).round(); - // Flat line index of this chunk's first row within the file's hunks, - // and the local rows (relative to this chunk) to paint as selected. - let base_line: usize = prepared.hunks[..chunk.hunk] - .iter() - .map(|h| h.lines.len()) - .sum::() - + chunk.lines.start; + // The local rows (relative to this chunk) to paint as selected. + let base_line = chunk.flat_start; let marks = ChunkMarks { highlight: self.selection.highlight(key, base_line, chunk.lines.len()), color: theme.selection, @@ -1191,7 +1184,7 @@ impl ReviewView { .w_full() .when(chunk_ix == 0, |d| d.pt_1()) .when(chunk_ix + 1 == chunks.len(), |d| d.pb_1()) - .bg(body_bg) + .bg(diff_body_bg(theme)) .flex() .flex_col() .text_size(rems(0.78125)) @@ -1199,10 +1192,9 @@ impl ReviewView { .font_family("Menlo") .font_weight(FontWeight(400.0)) .child(render_diff_chunk( - &prepared.hunks, + prepared, chunk, - prepared.chunked.gutter_width, - prepared.syntax.as_deref(), + GutterNumbers::New, theme, rem_size, Some(selection), @@ -1338,9 +1330,9 @@ mod tests { } /// A pure-add diff of `lines` lines. - fn prepared(lines: usize) -> PreparedReviewDiff { + fn prepared(lines: usize) -> PreparedFileDiff { let text: String = (0..lines).map(|i| format!("line {i}\n")).collect(); - PreparedReviewDiff::from_content( + PreparedFileDiff::from_content( "a.rs", &git::FileDiffContent { old_text: None, @@ -1348,6 +1340,7 @@ mod tests { is_binary: false, too_large: false, }, + None, ) } @@ -1389,7 +1382,7 @@ mod tests { #[gpui_kit::test] fn highlighted_rows_with_word_emphasis_render(cx: &mut TestAppContext) { let (view, cx) = view_with_files(vec![added_file("a.rs")], cx); - let prepared = PreparedReviewDiff::from_content( + let prepared = PreparedFileDiff::from_content( "a.rs", &git::FileDiffContent { old_text: Some("fn grüße() -> &'static str { \"hallo wält\" }\n".into()), @@ -1397,6 +1390,7 @@ mod tests { is_binary: false, too_large: false, }, + None, ); assert!(prepared.syntax.is_some()); assert!( diff --git a/crates/ui_gpui/src/main_screen/right_panel/review_view/comments.rs b/crates/ui_gpui/src/main_screen/right_panel/review_view/comments.rs index 5db3e7d30..8e6952410 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/review_view/comments.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/review_view/comments.rs @@ -12,25 +12,6 @@ use code_assistant_core::line_comments::LineComment; use similar::ChangeTag; use std::ops::Range; -/// One diff row's change tag and its 1-based line numbers in the old and -/// new file, for every row of the file's hunks in render order. -pub(super) fn row_numbers(prepared: &PreparedReviewDiff) -> Vec<(ChangeTag, usize, usize)> { - let mut rows = Vec::new(); - for hunk in &prepared.hunks { - let (mut old, mut new) = (hunk.old_start, hunk.new_start); - for line in &hunk.lines { - rows.push((line.tag, old, new)); - if line.tag != ChangeTag::Insert { - old += 1; - } - if line.tag != ChangeTag::Delete { - new += 1; - } - } - } - rows -} - /// Whether `comment` covers a row with these numbers. fn covers(comment: &LineComment, (tag, old, new): (ChangeTag, usize, usize)) -> bool { let lines = comment.start_line..=comment.end_line; @@ -57,7 +38,7 @@ impl ReviewView { pub(super) fn chunk_marks( &self, key: &FileKey, - prepared: &PreparedReviewDiff, + prepared: &PreparedFileDiff, base_line: usize, row_count: usize, ) -> Vec> { @@ -66,7 +47,7 @@ impl ReviewView { if comments.is_empty() { return Vec::new(); } - let numbers = row_numbers(prepared); + let numbers = prepared.row_numbers(); (0..row_count) .filter(|&row| { numbers @@ -82,7 +63,7 @@ impl ReviewView { fn selection_comment(&self) -> Option { let sel = self.selection.get()?; let prepared = &self.file_diffs.get(&sel.key)?.prepared; - let numbers = row_numbers(prepared); + let numbers = prepared.row_numbers(); let lines: Vec<_> = prepared.hunks.iter().flat_map(|h| h.lines.iter()).collect(); let (lo, hi) = sel.range(); let hi = hi.min(numbers.len().checked_sub(1)?); @@ -273,7 +254,7 @@ impl ReviewView { return; }; self.pending_reveal = None; - let numbers = row_numbers(&loaded.prepared); + let numbers = loaded.prepared.row_numbers(); let covered: Vec = numbers .iter() .enumerate() @@ -334,8 +315,8 @@ mod tests { } #[test] - fn row_numbers_count_each_side() { - let prepared = PreparedReviewDiff::from_content( + fn comments_cover_rows_on_their_side() { + let prepared = PreparedFileDiff::from_content( "a.rs", &git::FileDiffContent { old_text: Some("a\nb\nc\n".into()), @@ -343,8 +324,9 @@ mod tests { is_binary: false, too_large: false, }, + None, ); - let numbers = row_numbers(&prepared); + let numbers = prepared.row_numbers(); let tags: Vec<_> = numbers.iter().map(|n| n.0).collect(); assert_eq!( tags, diff --git a/crates/ui_gpui/src/main_screen/tests/comments.rs b/crates/ui_gpui/src/main_screen/tests/comments.rs index daa0fdb31..b526f2622 100644 --- a/crates/ui_gpui/src/main_screen/tests/comments.rs +++ b/crates/ui_gpui/src/main_screen/tests/comments.rs @@ -158,6 +158,21 @@ fn the_title_bar_switch_opens_switches_and_closes_the_panel(cx: &mut TestAppCont assert_eq!(panel_state(&mut test), None); } +#[gpui_kit::test] +fn the_pull_request_view_opens_from_the_title_bar_and_is_remembered(cx: &mut TestAppContext) { + let mut test = MainScreenTest::new(cx); + test.view_session("a"); + test.click("right-panel-view-PR"); + assert_eq!(panel_state(&mut test), Some(RightPanelView::PullRequest)); + let stored = test.stores.ui_state.stored("a").expect("UI state saved"); + assert_eq!(stored.right_panel_view.as_deref(), Some("pull-request")); + + test.view_session("b"); + test.click("right-panel-view-Files"); + test.view_session("a"); + assert_eq!(panel_state(&mut test), Some(RightPanelView::PullRequest)); +} + #[gpui_kit::test] fn a_comment_on_a_message_reaches_the_draft_and_the_transcript(cx: &mut TestAppContext) { let mut test = MainScreenTest::new(cx); @@ -179,3 +194,24 @@ fn a_comment_on_a_message_reaches_the_draft_and_the_transcript(cx: &mut TestAppC assert_eq!(marked[0].excerpt, "Use a ledger first."); }); } + +#[gpui_kit::test] +fn context_handed_to_the_agent_lands_in_the_draft(cx: &mut TestAppContext) { + let mut test = MainScreenTest::new(cx); + test.view_session("a"); + let text = "Check \"build\" on o/r#1: Failed".to_string(); + test.cx.update(|_, cx| { + crate::comments::report( + CommentChange::Attach(DraftAttachment::Text { + content: text.clone(), + }), + cx, + ) + }); + test.settle(); + let draft = test.stores.drafts.stored("a").expect("draft saved"); + assert!(matches!( + draft.attachments.as_slice(), + [DraftAttachment::Text { content }] if *content == text + )); +} diff --git a/crates/ui_gpui/src/main_screen/tests/mod.rs b/crates/ui_gpui/src/main_screen/tests/mod.rs index 4cfc5f6ac..d8a30b563 100644 --- a/crates/ui_gpui/src/main_screen/tests/mod.rs +++ b/crates/ui_gpui/src/main_screen/tests/mod.rs @@ -5,6 +5,7 @@ mod comments; mod composer; mod end_to_end; mod monitors; +mod pull_requests; mod sidebar; mod stored_outputs; mod sub_agents; diff --git a/crates/ui_gpui/src/main_screen/tests/pull_requests.rs b/crates/ui_gpui/src/main_screen/tests/pull_requests.rs new file mode 100644 index 000000000..4d0f53553 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/tests/pull_requests.rs @@ -0,0 +1,199 @@ +//! The pull request view in front of the real session core, GitHub +//! scripted: the session's repository found from its project, the list read +//! through the cache (page after page) and read anew on refresh, the session's own pull +//! request shown and commented on. + +use crate::test_support::{MainScreenTest, TestCore}; +use code_assistant_core::mocks::PendingLLMProvider; +use code_assistant_core::session::SessionConfig; +use github::fake::FakeTransport; +use github::transport::Method; +use gpui_kit::TestAppContext; +use serde_json::{Value, json}; +use std::path::Path; +use std::sync::Arc; +use tempfile::TempDir; + +fn git(dir: &Path, args: &[&str]) { + let status = std::process::Command::new("git") + .args(args) + .current_dir(dir) + .status() + .unwrap_or_else(|e| panic!("git {args:?}: {e}")); + assert!(status.success(), "git {args:?} failed"); +} + +/// A repository whose `origin` is stippi/code-assistant on GitHub. +fn github_checkout(dir: &Path) { + git(dir, &["init", "-q", "-b", "main"]); + git(dir, &["config", "user.email", "t@t.t"]); + git(dir, &["config", "user.name", "t"]); + git(dir, &["config", "commit.gpgsign", "false"]); + git(dir, &["commit", "-q", "--allow-empty", "-m", "init"]); + let remote = "git@github.com:stippi/code-assistant.git"; + git(dir, &["remote", "add", "origin", remote]); +} + +/// The `data` of one of the `github` crate's GraphQL fixtures. +fn fixture(name: &str) -> Value { + let path = format!( + "{}/../github/tests/fixtures/{name}.json", + env!("CARGO_MANIFEST_DIR") + ); + let answer: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + answer["data"].clone() +} + +/// GitHub as the `github` crate's fixtures have it; `own` is the pull +/// request of the checked-out branch. +fn scripted_github(own: Option) -> Arc { + let fake = FakeTransport::new(); + fake.on_graphql("PullRequestSearch") + .data(fixture("pull_request_search")); + let heads: Vec = own + .into_iter() + .map(|number| { + json!({ + "number": number, "title": "Pull request view", + "url": format!("https://github.com/stippi/code-assistant/pull/{number}"), + "baseRefName": "main", "headRefName": "feature/x", + "headRefOid": "0123456789abcdef0123456789abcdef01234567", + "state": "OPEN", "isDraft": false, "mergedAt": null, "closedAt": null, + "updatedAt": "2026-10-30T10:00:00Z", "isCrossRepository": false, + "headRepository": {"name": "code-assistant", "nameWithOwner": "stippi/code-assistant"}, + "headRepositoryOwner": {"login": "stippi"} + }) + }) + .collect(); + fake.on_graphql("PullRequestsByHead") + .data(json!({"repository": {"pullRequests": {"nodes": heads}}})); + fake.on_graphql("PullRequestCore") + .data(fixture("pull_request_core")); + fake.on_rest(Method::Get, "user") + .json(json!({"id": 1, "login": "stippi"})); + fake +} + +/// The main screen showing a session on a checkout of stippi/code-assistant +/// at branch `feature/x`. +fn session_on_checkout( + cx: &mut TestAppContext, + fake: &Arc, +) -> (MainScreenTest, TempDir) { + let checkout = tempfile::tempdir().unwrap(); + github_checkout(checkout.path()); + git(checkout.path(), &["checkout", "-q", "-b", "feature/x"]); + let core = TestCore::with_github(PendingLLMProvider::default().into_factory(), fake); + let config = SessionConfig { + init_path: Some(checkout.path().to_path_buf()), + ..SessionConfig::default() + }; + let session_id = core + .block_on(core.service.create_session_with_config(None, config, None)) + .unwrap(); + let mut test = MainScreenTest::with_core(cx, core); + test.gpui.cmd_load_session(session_id.clone(), None); + test.wait_until("the session is shown", |test| { + test.gpui.get_current_session_id().as_deref() == Some(session_id.as_str()) + }); + (test, checkout) +} + +fn searches(fake: &FakeTransport) -> usize { + fake.operations() + .iter() + .filter(|op| *op == "PullRequestSearch") + .count() +} + +#[gpui_kit::test] +fn the_tab_lists_the_projects_pull_requests_and_refreshes_them(cx: &mut TestAppContext) { + let fake = scripted_github(None); + let (mut test, _checkout) = session_on_checkout(cx, &fake); + + test.click("right-panel-view-PR"); + // The open one: the list shows the listed state (Open) only. + test.wait_until("the list shows", |test| test.shows("pr-row-291")); + assert!(!test.shows("pr-row-290"), "#290 is merged"); + // The fixture says more follow: the end of the rows asks for them. + test.wait_until("the next page was read", |_| searches(&fake) >= 2); + let read = searches(&fake); + + test.click("pr-list-refresh"); + test.wait_until("the list was read anew", |_| searches(&fake) > read); +} + +#[gpui_kit::test] +fn the_sessions_pull_request_shows_and_takes_a_comment(cx: &mut TestAppContext) { + let fake = scripted_github(Some(291)); + fake.on_graphql("PullRequestNodeId") + .data(json!({"repository": {"pullRequest": {"id": "PR_291"}}})); + fake.on_graphql("AddComment").data(json!({})); + let (mut test, _checkout) = session_on_checkout(cx, &fake); + + test.click("right-panel-view-PR"); + test.wait_until("the pull request is read", |test| test.shows("pr-composer")); + test.click("pr-composer"); + test.cx.simulate_input("Looks good"); + test.click("pr-comment-post"); + test.wait_until("the comment is posted", |_| { + fake.operations().iter().any(|op| op == "AddComment") + }); + let comment = fake + .requests() + .into_iter() + .find(|r| r.operation.as_deref() == Some("AddComment")) + .unwrap(); + assert_eq!(comment.variables.unwrap()["body"], json!("Looks good")); + test.wait_until("the composer closes", |test| !test.shows("pr-comment-post")); +} + +#[gpui_kit::test] +fn a_review_goes_with_the_pending_line_comments(cx: &mut TestAppContext) { + use crate::main_screen::right_panel::pull_request::review_draft::{ + PendingComment, review_drafts, + }; + use github::model::DiffSide; + + let fake = scripted_github(Some(291)); + let reviews = "repos/stippi/code-assistant/pulls/291/reviews"; + fake.on_rest(Method::Post, reviews).json(json!({"id": 1})); + let (mut test, _checkout) = session_on_checkout(cx, &fake); + let pr = github::RepoRef::new("github.com", "stippi", "code-assistant").pull_request(291); + let drafts = test.cx.update(|_, cx| review_drafts(cx)); + drafts.update(&mut test.cx, |drafts, cx| { + let comment = PendingComment { + id: 0, + path: "src/lib.rs".into(), + side: DiffSide::Right, + line: 12, + start_side: None, + start_line: None, + body: "Name this".into(), + excerpt: "+let x = 1;".into(), + }; + drafts.add(&pr, comment, cx); + }); + + test.click("right-panel-view-PR"); + test.wait_until("the pull request is read", |test| test.shows("pr-composer")); + // A pending line comment opens the review form. + test.click("pr-composer"); + test.click("pr-review-submit"); + test.wait_until("the review is sent", |_| { + fake.requests().iter().any(|r| r.path == reviews) + }); + let review = fake + .requests() + .into_iter() + .find(|r| r.path == reviews) + .unwrap(); + let body = review.body.expect("a JSON body"); + assert_eq!(body["comments"][0]["body"], json!("Name this")); + let pr_for_check = pr.clone(); + test.wait_until("the pending comment is gone", move |test| { + let drafts = drafts.clone(); + let pr = pr_for_check.clone(); + test.cx.update(|_, cx| drafts.read(cx).count(&pr) == 0) + }); +} diff --git a/crates/ui_gpui/src/shared/settings.rs b/crates/ui_gpui/src/shared/settings.rs index a6d59bdae..2806e9ac4 100644 --- a/crates/ui_gpui/src/shared/settings.rs +++ b/crates/ui_gpui/src/shared/settings.rs @@ -92,6 +92,11 @@ pub struct UiSettings { /// The session sidebar's view and project folders. #[serde(default)] pub sidebar: SidebarSettings, + + /// The pull request view's list settings and merge methods. + #[serde(default)] + pub pull_requests: + crate::main_screen::right_panel::pull_request::preferences::PullRequestPreferences, } /// Which list the session sidebar shows. @@ -136,6 +141,7 @@ impl Default for UiSettings { review_default_base: None, review_expanded_repos: Vec::new(), sidebar: SidebarSettings::default(), + pull_requests: Default::default(), } } } diff --git a/crates/ui_gpui/src/shared/theme.rs b/crates/ui_gpui/src/shared/theme.rs index 098781950..f6e478e8e 100644 --- a/crates/ui_gpui/src/shared/theme.rs +++ b/crates/ui_gpui/src/shared/theme.rs @@ -1,6 +1,6 @@ use gpui_kit::component::highlighter::{HighlightTheme, HighlightThemeStyle}; use gpui_kit::component::theme::{Theme, ThemeMode}; -use gpui_kit::{App, rgb, rgba}; +use gpui_kit::{App, Hsla, hsla, rgb, rgba}; use std::collections::HashMap; use std::sync::{Arc, LazyLock}; @@ -18,6 +18,11 @@ pub fn custom_dark_theme() -> gpui_kit::component::theme::ThemeColor { colors.sidebar = rgb(0x252525).into(); // Sidebar background colors.sidebar_border = rgb(0x404040).into(); // Sidebar border + // Hairlines and control outlines; the stock dark values sit below the + // background above and would not show. + colors.border = rgb(0x404040).into(); + colors.input = rgb(0x404040).into(); + // Text colors colors.foreground = rgba(0xFAFAFAFF).into(); // Main text colors.muted_foreground = rgb(0xAAAAAA).into(); // Secondary text @@ -82,6 +87,12 @@ pub fn custom_light_theme() -> gpui_kit::component::theme::ThemeColor { colors } +/// The violet GitHub and other tools use for merged work and branches; the +/// theme has no token for it. +pub fn merged_violet() -> Hsla { + hsla(0.75, 0.55, 0.65, 1.) +} + /// Our syntax colors per mode: few hues (red keywords, blue functions, green /// strings, after the GitBook/Shiki theme of the Slate docs), tuned for /// contrast on the diff rows' red/green backgrounds (see the tests). @@ -155,7 +166,7 @@ pub fn toggle_theme(window: Option<&mut gpui_kit::Window>, cx: &mut App) -> Them #[cfg(test)] mod tests { use super::*; - use crate::tool_cards::diff_card::{ + use crate::diff::render::{ added_row_colors, deleted_row_colors, diff_body_bg, word_emphasis_bg, }; use gpui_kit::{Hsla, Rgba, TestAppContext}; diff --git a/crates/ui_gpui/src/sidebar/session_item.rs b/crates/ui_gpui/src/sidebar/session_item.rs index 05b622000..0e254c74b 100644 --- a/crates/ui_gpui/src/sidebar/session_item.rs +++ b/crates/ui_gpui/src/sidebar/session_item.rs @@ -6,6 +6,7 @@ //! session the user has not looked at since it changed is unread: its title //! stands out and a dot marks it. Everything else recedes. +use crate::shared::theme::merged_violet; use code_assistant_core::persistence::ChatMetadata; use code_assistant_core::session::instance::SessionActivityState; use code_assistant_core::session::lifecycle::{AwaitingUser, SessionLifecycle, SessionStatus}; @@ -15,7 +16,7 @@ use gpui_kit::component::{ActiveTheme, StyledExt}; use gpui_kit::{ Animation, AnimationExt, ClickEvent, Context, EventEmitter, FocusHandle, Focusable, Hsla, InteractiveElement, SharedString, StatefulInteractiveElement, Styled, Transformation, Window, - div, hsla, percentage, prelude::*, px, + div, percentage, prelude::*, px, }; use std::time::SystemTime; @@ -29,12 +30,6 @@ struct GitGlyph { url: Option, } -/// The violet GitHub and other tools use for merged work and branches; the -/// theme has no token for it. -fn violet() -> Hsla { - hsla(0.75, 0.55, 0.65, 1.) -} - /// What a session wants from the user, least urgent first; a collapsed /// project folder shows the most urgent of its sessions. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] @@ -187,7 +182,7 @@ impl SessionListItem { let Some(pr) = &self.lifecycle.pull_request else { return Some(GitGlyph { icon: "icons/git_branch.svg", - color: violet().opacity(0.8), + color: merged_violet().opacity(0.8), tooltip: branch.to_string(), url: None, }); @@ -199,7 +194,7 @@ impl SessionListItem { cx.theme().muted_foreground, "draft", ), - PullRequestState::Merged => ("icons/git_merge.svg", violet(), "merged"), + PullRequestState::Merged => ("icons/git_merge.svg", merged_violet(), "merged"), PullRequestState::Closed => ( "icons/git_pull_request_closed.svg", cx.theme().danger, diff --git a/crates/ui_gpui/src/test_support/core.rs b/crates/ui_gpui/src/test_support/core.rs index eb4dd6a7c..1869424b4 100644 --- a/crates/ui_gpui/src/test_support/core.rs +++ b/crates/ui_gpui/src/test_support/core.rs @@ -1,11 +1,13 @@ //! [`TestCore`]: the real session core for end-to-end tests. use code_assistant_core::persistence::FileSessionPersistence; +use code_assistant_core::pull_requests::PullRequestSource; use code_assistant_core::session::event_stream::EventStream; use code_assistant_core::session::service::{ AgentRuntimeOptions, LlmClientFactory, SessionService, default_project_manager_factory, }; use code_assistant_core::session::{SessionConfig, SessionManager}; +use github::fake::FakeTransport; use std::future::Future; use std::sync::Arc; use tempfile::TempDir; @@ -21,6 +23,16 @@ pub struct TestCore { impl TestCore { pub fn new(llm: LlmClientFactory) -> Self { + Self::build(llm, None) + } + + /// A core whose GitHub answers come from `github` (scripted); its + /// snapshots stay in the temp dir. + pub fn with_github(llm: LlmClientFactory, github: &Arc) -> Self { + Self::build(llm, Some(github.client())) + } + + fn build(llm: LlmClientFactory, github: Option) -> Self { let runtime = tokio::runtime::Builder::new_multi_thread() .worker_threads(2) .enable_all() @@ -44,6 +56,10 @@ impl TestCore { }), project_manager_factory: default_project_manager_factory(), llm_client_factory: Some(llm), + pull_requests: github.map(|client| PullRequestSource { + client, + snapshot_dir: sessions_dir.path().join("pull-requests"), + }), }; let (service, worker) = SessionService::new(manager, Arc::new(options), events); runtime.spawn(worker); diff --git a/crates/ui_gpui/src/tool_cards/diff_card.rs b/crates/ui_gpui/src/tool_cards/diff_card.rs index 31e379db4..7e88ffca4 100644 --- a/crates/ui_gpui/src/tool_cards/diff_card.rs +++ b/crates/ui_gpui/src/tool_cards/diff_card.rs @@ -9,10 +9,11 @@ //! Replaces the old parameter-renderer-based rendering for these tools. use super::diff_prepare::SectionLines; -use super::diff_rows::{DiffRow, DiffRows, EndCallback, LineCallback, RowGeometry, RowSelection}; -use super::diff_syntax::DiffSyntax; use super::{CardRenderContext, ToolBlockRenderer, ToolBlockStyle, animated_card_body}; use crate::blocks::{BlockView, ToolUseBlock}; +use crate::diff::render::{added_row_colors, deleted_row_colors, diff_body_bg, render_diff_lines}; +use crate::diff::rows::{EndCallback, LineCallback, RowSelection}; +use crate::diff::syntax::DiffSyntax; use crate::shared::file_icons; use code_assistant_core::ui::ToolStatus; use gpui_kit::prelude::FluentBuilder; @@ -21,7 +22,6 @@ use gpui_kit::{ IntoElement, KeyDownEvent, ParentElement, SharedString, StatefulInteractiveElement, Styled, WeakEntity, Window, div, px, rems, }; -use similar::{ChangeTag, TextDiff}; use std::rc::Rc; // --------------------------------------------------------------------------- @@ -603,523 +603,9 @@ fn render_delete_body( } // --------------------------------------------------------------------------- -// Diff rendering +// Streaming blocks // --------------------------------------------------------------------------- -/// Normalize text for diff display. -/// -/// LLMs frequently emit a spurious leading `\n` at the start of `old_text` or -/// `new_text` JSON string values, and the two sides are not always consistent. -/// Additionally, format-on-save only updates `new_text` (the replace side) while -/// `old_text` keeps the raw LLM value, which can introduce trailing-newline -/// mismatches. -/// -/// We strip one leading `\n` (if present) so both sides start at real content, -/// then ensure both end with exactly one `\n` so `TextDiff` with -/// `newline_terminated(true)` treats the last line consistently. Interior blank -/// lines (intentional insertions) are preserved. -pub(super) fn normalize_for_diff(text: &str) -> String { - let trimmed = text.strip_prefix('\n').unwrap_or(text); - let trimmed = trimmed.strip_suffix('\n').unwrap_or(trimmed); - format!("{trimmed}\n") -} - -/// One line of a computed unified diff. `text` is a [`SharedString`] so cached -/// diffs can be re-rendered every frame with cheap clones. `emphasis` marks -/// the byte ranges within `text` that changed *within* the line (word diff); -/// they get a stronger background on top of the row color. -#[derive(Debug, Clone)] -pub struct DiffLine { - pub tag: ChangeTag, - pub text: SharedString, - pub emphasis: Vec>, -} - -/// Replace blocks with more lines than this (per side) skip the word-level -/// diff — pairing words across big rewrites produces noise, not signal, and -/// the word diff's cost grows with the block. Same cap as Zed. -const MAX_WORD_DIFF_LINES: usize = 8; - -/// Unchanged text between two emphasis ranges with at most this many -/// alphanumeric characters (a space, punctuation, "a", "the", "in") is not -/// worth breaking the emphasis for — the ranges merge across it. -const MAX_MERGED_ANCHOR_CHARS: usize = 3; - -/// A line with more emphasis ranges than this (after merging) is a rewrite, -/// not an edit: highlighting would fragment the whole paragraph. The block -/// then shows as plain changes. Counting ranges rather than characters keeps -/// one long insertion, however large, emphasized. -const MAX_EMPHASIS_SPANS: usize = 6; - -/// Byte ranges (per side) that a word-level diff of one replace block marks -/// as changed, relative to each side's concatenated block text. Runs our own -/// word diff instead of `similar`'s inline changes: those refuse blocks below -/// a similarity ratio, which drops exactly the case of a few sentences -/// inserted into a short paragraph. -fn word_diff_ranges( - old: &str, - new: &str, -) -> (Vec>, Vec>) { - let diff = TextDiff::configure() - .algorithm(similar::Algorithm::Patience) - .diff_unicode_words(old, new); - let bytes = |slices: &[&str]| slices.iter().map(|s| s.len()).sum::(); - let (mut old_ranges, mut new_ranges) = (Vec::new(), Vec::new()); - let (mut old_pos, mut new_pos) = (0, 0); - for op in diff.ops() { - let old_len = bytes(&diff.old_slices()[op.old_range()]); - let new_len = bytes(&diff.new_slices()[op.new_range()]); - if op.tag() != similar::DiffTag::Equal { - if old_len > 0 { - old_ranges.push(old_pos..old_pos + old_len); - } - if new_len > 0 { - new_ranges.push(new_pos..new_pos + new_len); - } - } - old_pos += old_len; - new_pos += new_len; - } - (old_ranges, new_ranges) -} - -/// Build the [`DiffLine`]s of one side of a replace block from its line -/// slices and the block-relative changed ranges. Each line's ranges are -/// clipped to the trimmed line and merged across small anchors. -fn emphasized_lines( - tag: ChangeTag, - slices: &[&str], - ranges: &[std::ops::Range], - out: &mut Vec, -) { - let mut line_start = 0; - for slice in slices { - let line_end = line_start + slice.len(); - let text = slice.trim_end(); - let mut emphasis: Vec> = ranges - .iter() - .filter(|r| r.start < line_end && r.end > line_start) - .map(|r| { - let start = r.start.max(line_start) - line_start; - let end = (r.end.min(line_end) - line_start).min(text.len()); - start..end - }) - .filter(|r| r.start < r.end) - .collect(); - emphasis.dedup_by(|next, prev| { - let anchor = &text[prev.end..next.start]; - if anchor.chars().filter(|c| c.is_alphanumeric()).count() <= MAX_MERGED_ANCHOR_CHARS { - prev.end = next.end; - true - } else { - false - } - }); - out.push(DiffLine { - tag, - text: text.to_string().into(), - emphasis, - }); - line_start = line_end; - } -} - -/// Expand one diff op into [`DiffLine`]s, with word-level emphasis for small -/// replace blocks. -fn collect_change_lines<'a>( - diff: &'a TextDiff<'a, 'a, 'a, str>, - op: &similar::DiffOp, - out: &mut Vec, -) { - let old_slices = &diff.old_slices()[op.old_range()]; - let new_slices = &diff.new_slices()[op.new_range()]; - let is_replace = op.tag() == similar::DiffTag::Replace; - let block_lines = old_slices.len().max(new_slices.len()); - - if is_replace && block_lines <= MAX_WORD_DIFF_LINES { - let (old_ranges, new_ranges) = word_diff_ranges(&old_slices.concat(), &new_slices.concat()); - let start = out.len(); - emphasized_lines(ChangeTag::Delete, old_slices, &old_ranges, out); - emphasized_lines(ChangeTag::Insert, new_slices, &new_ranges, out); - // The word diff pairs both sides of the block, so the rewrite verdict - // covers both: emphasis on one side with none on the other would - // suggest a deletion without a counterpart. - let rewritten = out[start..] - .iter() - .any(|l| l.emphasis.len() > MAX_EMPHASIS_SPANS); - if rewritten { - for line in &mut out[start..] { - line.emphasis.clear(); - } - } - } else { - for change in diff.iter_changes(op) { - out.push(DiffLine { - tag: change.tag(), - text: change.value().trim_end().to_string().into(), - emphasis: Vec::new(), - }); - } - } -} - -/// Run the line diff (the expensive part: normalization + Myers diff + per-line -/// allocations). Callers that render on every frame — like the Review panel — -/// should call this once per content change, cache the result, and feed it to -/// [`render_diff_lines`] per frame. -pub(crate) fn compute_diff_lines(old_text: &str, new_text: &str) -> Vec { - let old_norm = normalize_for_diff(old_text); - let new_norm = normalize_for_diff(new_text); - - let diff = TextDiff::configure() - .newline_terminated(true) - .diff_lines(&old_norm, &new_norm); - - let mut lines = Vec::new(); - for op in diff.ops() { - collect_change_lines(&diff, op, &mut lines); - } - lines -} - -/// One hunk of a unified diff: a run of changed lines plus surrounding -/// context, positioned at `old_start` / `new_start` (1-based) in the old and -/// new file. -#[derive(Debug, Clone)] -pub struct DiffHunk { - pub old_start: usize, - pub new_start: usize, - pub lines: Vec, -} - -/// Like [`compute_diff_lines`], but grouped into hunks with `context` lines -/// of surrounding context (à la `git diff`) — unchanged stretches between -/// hunks are dropped entirely, which keeps the element count proportional to -/// the *changed* lines instead of the file size. -pub fn compute_diff_hunks(old_text: &str, new_text: &str, context: usize) -> Vec { - let old_norm = normalize_for_diff(old_text); - let new_norm = normalize_for_diff(new_text); - - let diff = TextDiff::configure() - .newline_terminated(true) - .diff_lines(&old_norm, &new_norm); - - diff.grouped_ops(context) - .iter() - .map(|ops| { - let mut lines = Vec::new(); - for op in ops { - collect_change_lines(&diff, op, &mut lines); - } - DiffHunk { - old_start: ops.first().map(|op| op.old_range().start + 1).unwrap_or(1), - new_start: ops.first().map(|op| op.new_range().start + 1).unwrap_or(1), - lines, - } - }) - .collect() -} - -/// A whole file as one one-sided hunk (pure add or pure delete). No diff -/// computation — diffing against an empty side would only produce a phantom -/// deleted/inserted blank line (`normalize_for_diff` maps "" to "\n"). -pub fn single_sided_hunk(text: &str, tag: ChangeTag) -> Vec { - let norm = normalize_for_diff(text); - let lines: Vec = norm - .lines() - .map(|l| DiffLine { - tag, - text: l.trim_end().to_string().into(), - emphasis: Vec::new(), - }) - .collect(); - if lines.is_empty() { - return Vec::new(); - } - vec![DiffHunk { - old_start: 1, - new_start: 1, - lines, - }] -} - -/// A slice of one hunk, small enough to be a cheap item of a virtualized -/// list. `old_start` / `new_start` are the old- and new-file line numbers at -/// the slice's first line. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DiffChunk { - pub hunk: usize, - pub lines: std::ops::Range, - pub old_start: usize, - pub new_start: usize, -} - -impl DiffChunk { - /// Whether this chunk opens a hunk other than the first — the place for - /// the "⋯" separator. - pub fn starts_later_hunk(&self) -> bool { - self.hunk > 0 && self.lines.start == 0 - } -} - -/// Hunks cut into list-item-sized chunks, plus the gutter width (in digits) -/// all chunks of the file share. -#[derive(Debug, Clone, Default)] -pub struct ChunkedHunks { - pub chunks: Vec, - pub gutter_width: usize, -} - -/// Cut `hunks` into chunks of at most `max_lines` lines. Small hunks stay -/// whole; a big one (a newly added file is a single hunk) is split so a list -/// only builds the chunks in view. -pub fn chunk_hunks(hunks: &[DiffHunk], max_lines: usize) -> ChunkedHunks { - let mut chunks = Vec::new(); - let mut max_line = 1; - for (hunk_ix, hunk) in hunks.iter().enumerate() { - let mut old_line = hunk.old_start; - let mut new_line = hunk.new_start; - for (chunk_ix, lines) in hunk.lines.chunks(max_lines.max(1)).enumerate() { - let start = chunk_ix * max_lines.max(1); - chunks.push(DiffChunk { - hunk: hunk_ix, - lines: start..start + lines.len(), - old_start: old_line, - new_start: new_line, - }); - old_line += lines.iter().filter(|l| l.tag != ChangeTag::Insert).count(); - new_line += lines.iter().filter(|l| l.tag != ChangeTag::Delete).count(); - } - max_line = max_line.max(new_line); - } - ChunkedHunks { - chunks, - gutter_width: max_line.to_string().len(), - } -} - -/// Render one chunk of already-computed hunks with real new-file line -/// numbers, preceded by a slim "⋯" separator where a later hunk begins. -/// With `syntax`, rows are syntax highlighted. `selection` makes the rows -/// selectable (the Review panel passes it; tool cards do not). -pub(crate) fn render_diff_chunk( - hunks: &[DiffHunk], - chunk: &DiffChunk, - gutter_width: usize, - syntax: Option<&DiffSyntax>, - theme: &gpui_kit::component::theme::Theme, - rem_size: gpui_kit::Pixels, - selection: Option, -) -> gpui_kit::AnyElement { - let Some(lines) = hunks - .get(chunk.hunk) - .and_then(|h| h.lines.get(chunk.lines.clone())) - else { - return div().into_any(); - }; - let rows = render_diff_rows( - lines, - theme, - Some(chunk.new_start), - gutter_width, - rem_size, - syntax.map(|syntax| RowSyntax { - syntax, - start: LineCounter { - old: chunk.old_start, - new: chunk.new_start, - }, - }), - selection, - ); - if !chunk.starts_later_hunk() { - return rows; - } - let (_, ctx_color) = unchanged_row_colors(theme); - div() - .flex() - .flex_col() - .child( - div() - .w_full() - .flex() - .justify_center() - .text_color(ctx_color.opacity(0.5)) - .child("⋯"), - ) - .child(rows) - .into_any() -} - -/// Build the element tree for already-computed diff lines of a snippet. -/// `start_line` numbers the gutter; `syntax` was parsed from the snippet -/// alone, so its lines count from 1. -pub(crate) fn render_diff_lines( - diff_lines: &[DiffLine], - theme: &gpui_kit::component::theme::Theme, - start_line: Option, - rem_size: gpui_kit::Pixels, - syntax: Option<&DiffSyntax>, - selection: Option, -) -> gpui_kit::AnyElement { - // Compute the gutter width (number of digits) based on new-file line numbers - let gutter_width = if let Some(start) = start_line { - let new_count = diff_lines - .iter() - .filter(|l| l.tag != ChangeTag::Delete) - .count(); - let max_line = start + new_count; - max_line.to_string().len() - } else { - 0 - }; - render_diff_rows( - diff_lines, - theme, - start_line, - gutter_width, - rem_size, - syntax.map(|syntax| RowSyntax { - syntax, - start: LineCounter::default(), - }), - selection, - ) -} - -/// 1-based line numbers on both sides of a diff while walking its rows. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct LineCounter { - old: usize, - new: usize, -} - -impl Default for LineCounter { - fn default() -> Self { - Self { old: 1, new: 1 } - } -} - -impl LineCounter { - /// The line number of a row with `tag` in its own side (the old file for - /// deletions, the new file otherwise); then steps past the row. - fn advance(&mut self, tag: ChangeTag) -> usize { - let line_no = if tag == ChangeTag::Delete { - self.old - } else { - self.new - }; - if tag != ChangeTag::Insert { - self.old += 1; - } - if tag != ChangeTag::Delete { - self.new += 1; - } - line_no - } -} - -/// Syntax source for [`render_diff_rows`], with the line numbers of the first -/// row in the parsed texts. Those are independent of the gutter's -/// `start_line`: a tool card parses just its snippet but numbers the gutter -/// by the file. -struct RowSyntax<'a> { - syntax: &'a DiffSyntax, - start: LineCounter, -} - -/// Shared row builder: the rows with numbering from `start_line` (when -/// given) in a fixed `gutter_width`-digit gutter, as one [`DiffRows`] -/// element. -fn render_diff_rows( - diff_lines: &[DiffLine], - theme: &gpui_kit::component::theme::Theme, - start_line: Option, - gutter_width: usize, - rem_size: gpui_kit::Pixels, - syntax: Option, - selection: Option, -) -> gpui_kit::AnyElement { - let mut gutter_lines = LineCounter { - old: 1, - new: start_line.unwrap_or(1), - }; - let mut syntax_lines = syntax.as_ref().map(|s| s.start).unwrap_or_default(); - - // Gutter width: compute in rems (~0.5rem per digit + 0.75rem padding), - // then convert to rounded pixels so it aligns to the pixel grid. - let gutter_px = if start_line.is_some() { - rems(gutter_width as f32 * 0.5 + 0.75) - .to_pixels(rem_size) - .round() - } else { - px(0.) - }; - let geometry = RowGeometry { - gutter_width: gutter_px, - gutter_left: rems(0.375).to_pixels(rem_size), - // Without a gutter the text keeps the card's horizontal padding. - content_left: rems(if start_line.is_some() { 0.25 } else { 0.75 }).to_pixels(rem_size), - content_right: rems(0.75).to_pixels(rem_size), - }; - - let rows = diff_lines - .iter() - .map(|dl| { - let (row_bg, gutter_fg) = row_colors(dl.tag, theme); - let new_num = gutter_lines.advance(dl.tag); - let syntax_line = syntax_lines.advance(dl.tag); - - // Gutter with line number (shows new-file line numbers) - let gutter = start_line.is_some().then(|| { - let text = match dl.tag { - ChangeTag::Equal | ChangeTag::Insert => { - format!("{:>width$}", new_num, width = gutter_width) - } - ChangeTag::Delete => format!("{:>width$}", "", width = gutter_width), - }; - (SharedString::from(text), gutter_fg.opacity(0.5)) - }); - - // Word-level changes get a stronger background via text-run - // highlights, which wrap with the text. Syntax colors come - // first, the word emphasis layers on top. - let syntax_styles = syntax.as_ref().map_or_else(Vec::new, |s| { - s.syntax - .line_styles(dl.tag, syntax_line, &dl.text, &theme.highlight_theme) - }); - let highlights = if dl.emphasis.is_empty() && syntax_styles.is_empty() { - Vec::new() - } else { - let word_bg = word_emphasis_bg(dl.tag, theme); - let emphasis = dl.emphasis.iter().map(|range| { - ( - range.clone(), - gpui_kit::HighlightStyle { - background_color: Some(word_bg), - ..Default::default() - }, - ) - }); - gpui_kit::combine_highlights(syntax_styles, emphasis).collect() - }; - DiffRow { - text: dl.text.clone(), - highlights, - background: row_bg, - color: row_text_color(dl.tag, syntax.is_some(), theme), - gutter, - } - }) - .collect(); - - let element = DiffRows::new(rows, geometry); - match selection { - Some(selection) => element.selectable(selection).into_any(), - None => element.into_any(), - } -} - fn render_streaming_block( text: &str, is_deletion: bool, @@ -1311,381 +797,10 @@ fn abbreviate_path(path: &str) -> String { path.to_string() } -// --------------------------------------------------------------------------- -// Theme colors -// --------------------------------------------------------------------------- - -fn rgb_color(r: u8, g: u8, b: u8) -> gpui_kit::Hsla { - gpui_kit::Rgba { - r: r as f32 / 255.0, - g: g as f32 / 255.0, - b: b as f32 / 255.0, - a: 1.0, - } - .into() -} - -fn rgba_color(r: u8, g: u8, b: u8, a: u8) -> gpui_kit::Hsla { - gpui_kit::Rgba { - r: r as f32 / 255.0, - g: g as f32 / 255.0, - b: b as f32 / 255.0, - a: a as f32 / 255.0, - } - .into() -} - -/// Background of a diff card's body, behind the rows. -pub(crate) fn diff_body_bg(theme: &gpui_kit::component::theme::Theme) -> gpui_kit::Hsla { - if theme.is_dark() { - gpui_kit::hsla(0.0, 0.0, 0.08, 1.0) - } else { - gpui_kit::hsla(0.0, 0.0, 0.97, 1.0) - } -} - -pub(crate) fn deleted_row_colors( - theme: &gpui_kit::component::theme::Theme, -) -> (Option, gpui_kit::Hsla) { - if theme.is_dark() { - ( - Some(rgba_color(0x80, 0x20, 0x20, 0x60)), - rgb_color(0xFF, 0xBB, 0xBB), - ) - } else { - ( - Some(rgba_color(0xDD, 0x55, 0x55, 0x30)), - rgb_color(0x88, 0x00, 0x00), - ) - } -} - -pub(crate) fn added_row_colors( - theme: &gpui_kit::component::theme::Theme, -) -> (Option, gpui_kit::Hsla) { - if theme.is_dark() { - ( - Some(rgba_color(0x20, 0x60, 0x20, 0x60)), - rgb_color(0xBB, 0xFF, 0xBB), - ) - } else { - ( - Some(rgba_color(0x33, 0xAA, 0x33, 0x25)), - rgb_color(0x00, 0x66, 0x00), - ) - } -} - -fn row_colors( - tag: ChangeTag, - theme: &gpui_kit::component::theme::Theme, -) -> (Option, gpui_kit::Hsla) { - match tag { - ChangeTag::Equal => unchanged_row_colors(theme), - ChangeTag::Delete => deleted_row_colors(theme), - ChangeTag::Insert => added_row_colors(theme), - } -} - -/// Text color of a row. Syntax highlighted rows use the syntax theme's neutral -/// foreground, since red/green tinted text fights with the syntax colors; the -/// row background still tells the change. -fn row_text_color( - tag: ChangeTag, - highlighted: bool, - theme: &gpui_kit::component::theme::Theme, -) -> gpui_kit::Hsla { - highlighted - .then_some(theme.highlight_theme.style.editor_foreground) - .flatten() - .unwrap_or_else(|| row_colors(tag, theme).1) -} - -/// Background for word-level (intra-line) changes: a stronger tint layered on -/// top of the row's add/delete background. -pub(crate) fn word_emphasis_bg( - tag: ChangeTag, - theme: &gpui_kit::component::theme::Theme, -) -> gpui_kit::Hsla { - match (tag, theme.is_dark()) { - (ChangeTag::Delete, true) => rgba_color(0xC0, 0x38, 0x38, 0x70), - (ChangeTag::Delete, false) => rgba_color(0xE0, 0x60, 0x60, 0x60), - (ChangeTag::Insert, true) => rgba_color(0x38, 0xA0, 0x38, 0x70), - (ChangeTag::Insert, false) => rgba_color(0x40, 0xB8, 0x40, 0x50), - (ChangeTag::Equal, _) => gpui_kit::transparent_black(), - } -} - -pub(crate) fn unchanged_row_colors( - theme: &gpui_kit::component::theme::Theme, -) -> (Option, gpui_kit::Hsla) { - if theme.is_dark() { - (None, rgba_color(0xFF, 0xFF, 0xFF, 0x99)) - } else { - (None, rgba_color(0x33, 0x33, 0x33, 0x99)) - } -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn line_counter_numbers_each_row_in_its_own_side() { - let mut lines = LineCounter { old: 10, new: 20 }; - let numbers: Vec = [ - ChangeTag::Equal, - ChangeTag::Delete, - ChangeTag::Delete, - ChangeTag::Insert, - ChangeTag::Equal, - ] - .into_iter() - .map(|tag| lines.advance(tag)) - .collect(); - assert_eq!(numbers, [20, 11, 12, 21, 22]); - assert_eq!(lines, LineCounter { old: 14, new: 23 }); - } - - #[test] - fn highlighted_rows_use_the_neutral_syntax_foreground() { - use gpui_kit::component::theme::{Theme, ThemeColor}; - let mut theme = Theme::from(&*ThemeColor::light()); - theme.highlight_theme = crate::shared::theme::syntax_theme(theme.mode); - let foreground = theme.highlight_theme.style.editor_foreground.unwrap(); - - for tag in [ChangeTag::Equal, ChangeTag::Delete, ChangeTag::Insert] { - assert_eq!(row_text_color(tag, true, &theme), foreground); - } - // Without syntax the rows keep their tinted text. - assert_eq!( - row_text_color(ChangeTag::Insert, false, &theme), - added_row_colors(&theme).1 - ); - } - - #[test] - fn compute_diff_hunks_groups_changes_with_context() { - let old: String = (1..=20).map(|i| format!("line {i}\n")).collect(); - let mut new_lines: Vec = (1..=20).map(|i| format!("line {i}\n")).collect(); - new_lines[2] = "changed 3\n".into(); - new_lines[15] = "changed 16\n".into(); - let new: String = new_lines.concat(); - - let hunks = compute_diff_hunks(&old, &new, 3); - assert_eq!(hunks.len(), 2, "two distant changes → two hunks"); - assert_eq!((hunks[0].old_start, hunks[0].new_start), (1, 1)); - assert_eq!((hunks[1].old_start, hunks[1].new_start), (13, 13)); - for hunk in &hunks { - let deletes = hunk - .lines - .iter() - .filter(|l| l.tag == ChangeTag::Delete) - .count(); - let inserts = hunk - .lines - .iter() - .filter(|l| l.tag == ChangeTag::Insert) - .count(); - let equals = hunk - .lines - .iter() - .filter(|l| l.tag == ChangeTag::Equal) - .count(); - assert_eq!((deletes, inserts), (1, 1)); - assert!(equals <= 6, "at most 3 context lines per side"); - } - } - - fn hunk_of(old_start: usize, new_start: usize, tags: &[ChangeTag]) -> DiffHunk { - DiffHunk { - old_start, - new_start, - lines: tags - .iter() - .map(|&tag| DiffLine { - tag, - text: "x".into(), - emphasis: Vec::new(), - }) - .collect(), - } - } - - #[test] - fn chunk_hunks_keeps_small_hunks_whole() { - use ChangeTag::*; - let hunks = vec![ - hunk_of(1, 1, &[Equal, Delete, Insert, Equal]), - hunk_of(97, 98, &[Equal, Insert, Insert]), - ]; - let chunked = chunk_hunks(&hunks, 40); - assert_eq!( - chunked.chunks, - vec![ - DiffChunk { - hunk: 0, - lines: 0..4, - old_start: 1, - new_start: 1 - }, - DiffChunk { - hunk: 1, - lines: 0..3, - old_start: 97, - new_start: 98 - }, - ] - ); - // Last new-file line is 98 + 3 = 101 → three digits. - assert_eq!(chunked.gutter_width, 3); - assert!(!chunked.chunks[0].starts_later_hunk()); - assert!(chunked.chunks[1].starts_later_hunk()); - } - - #[test] - fn chunk_hunks_splits_large_hunks_with_continuous_numbering() { - use ChangeTag::*; - // 2 deletes first: they advance only the old-file numbering. - let mut tags = vec![Delete, Delete]; - tags.extend(std::iter::repeat_n(Insert, 8)); - let chunked = chunk_hunks(&[hunk_of(20, 10, &tags)], 4); - assert_eq!( - chunked.chunks, - vec![ - DiffChunk { - hunk: 0, - lines: 0..4, - old_start: 20, - new_start: 10 - }, - DiffChunk { - hunk: 0, - lines: 4..8, - old_start: 22, - new_start: 12 - }, - DiffChunk { - hunk: 0, - lines: 8..10, - old_start: 22, - new_start: 16 - }, - ] - ); - assert!(chunked.chunks.iter().all(|c| !c.starts_later_hunk())); - } - - #[test] - fn chunk_hunks_of_nothing_is_empty() { - let chunked = chunk_hunks(&[], 40); - assert!(chunked.chunks.is_empty()); - } - - #[test] - fn compute_diff_lines_marks_word_level_changes() { - let lines = compute_diff_lines("fn foo(alpha: u32) {}\n", "fn foo(beta: u32) {}\n"); - let del = lines.iter().find(|l| l.tag == ChangeTag::Delete).unwrap(); - let ins = lines.iter().find(|l| l.tag == ChangeTag::Insert).unwrap(); - - // The changed identifier is emphasized — not the whole line. - assert_eq!(del.emphasis.len(), 1); - assert_eq!(&del.text[del.emphasis[0].clone()], "alpha"); - assert_eq!(ins.emphasis.len(), 1); - assert_eq!(&ins.text[ins.emphasis[0].clone()], "beta"); - - // Unchanged context lines carry no emphasis. - assert!( - lines - .iter() - .filter(|l| l.tag == ChangeTag::Equal) - .all(|l| l.emphasis.is_empty()) - ); - } - - #[test] - fn word_diff_merges_ranges_split_only_by_whitespace() { - let lines = compute_diff_lines("keep foo bar keep\n", "keep qux quux keep\n"); - let ins = lines.iter().find(|l| l.tag == ChangeTag::Insert).unwrap(); - // "qux" and "quux" are separate word tokens with an unchanged space - // between them; visually that is one change. - assert_eq!(ins.emphasis.len(), 1); - assert_eq!(&ins.text[ins.emphasis[0].clone()], "qux quux"); - } - - #[test] - fn word_diff_kept_for_small_edit_in_long_paragraph() { - // A long prose paragraph (one line) with a single changed word is - // exactly where word emphasis helps most — length must not disable it. - let filler = "the data center deployment ".repeat(25); - let old = format!("{filler}serving a jurisdiction.\n"); - let new = format!("{filler}serving one or more jurisdictions.\n"); - assert!(old.len() > 512); - let lines = compute_diff_lines(&old, &new); - let ins = lines.iter().find(|l| l.tag == ChangeTag::Insert).unwrap(); - assert_eq!(ins.emphasis.len(), 1); - assert_eq!( - &ins.text[ins.emphasis[0].clone()], - "one or more jurisdictions" - ); - } - - #[test] - fn word_diff_keeps_both_sides_of_a_partially_rewritten_paragraph() { - // Deleted and inserted passages both get emphasis; small anchors - // ("service", "in") do not fragment it. The counterpart of a - // highlighted deletion must be visible on the inserted side. - let lines = compute_diff_lines( - "- **Main Tenant** — AI Core's top-level tenant, mapped one-to-one to a service instance in a BTP subaccount.\n", - "- **Main Tenant** — AI Core's top-level tenant, identified by the BTP subaccount / zone ID. Multiple AI Core service instances in one subaccount reference the same Main Tenant and Resource Groups.\n", - ); - let del = lines.iter().find(|l| l.tag == ChangeTag::Delete).unwrap(); - let ins = lines.iter().find(|l| l.tag == ChangeTag::Insert).unwrap(); - assert!(!del.emphasis.is_empty()); - assert!(!ins.emphasis.is_empty()); - assert!(ins.emphasis.len() <= 3, "{:?}", ins.emphasis); - assert!(&ins.text[ins.emphasis[0].clone()].starts_with("identified by the BTP subaccount")); - } - - #[test] - fn word_diff_keeps_long_insertion_into_short_paragraph() { - // Two short sentences become five: the insertion dwarfs the original, - // yet it is one contiguous change and must be emphasized as such. - let lines = compute_diff_lines( - "Traces stay local. Keys stay local.\n", - "Traces stay local. Every trace is written by the consumer that received it, and that consumer runs in the same jurisdiction as the deployment. Retention follows the jurisdiction's own schedule rather than a global default. Keys stay local.\n", - ); - let ins = lines.iter().find(|l| l.tag == ChangeTag::Insert).unwrap(); - assert_eq!(ins.emphasis.len(), 1, "{:?}", ins.emphasis); - assert!(&ins.text[ins.emphasis[0].clone()].starts_with("Every trace")); - } - - #[test] - fn word_diff_dropped_for_fragmented_rewrite() { - // Every other word changed, with real words as anchors in between: - // more emphasis spans than a reader can follow, so both sides fall - // back to plain changes. - let lines = compute_diff_lines( - "alpha keep1 beta keep2 gamma keep3 delta keep4 epsilon keep5 zeta keep6 eta keep7 theta\n", - "one keep1 two keep2 three keep3 four keep4 five keep5 six keep6 seven keep7 eight\n", - ); - assert!( - lines - .iter() - .filter(|l| l.tag != ChangeTag::Equal) - .all(|l| l.emphasis.is_empty()) - ); - } - - #[test] - fn single_sided_hunk_is_one_pure_hunk() { - let hunks = single_sided_hunk("a\nb\nc\n", ChangeTag::Insert); - assert_eq!(hunks.len(), 1); - assert_eq!(hunks[0].new_start, 1); - assert!(hunks[0].lines.iter().all(|l| l.tag == ChangeTag::Insert)); - assert_eq!(hunks[0].lines.len(), 3); - } - #[test] fn test_parse_single_section() { let diff = "<<<<<<< SEARCH\nold line\n=======\nnew line\n>>>>>>> REPLACE"; @@ -1771,84 +886,4 @@ mod tests { }; assert_eq!(extract_path_or_paths(&tool), "a.rs, b.rs"); } - - /// Helper: compute diff tags after the same normalization `render_unified_diff` uses. - fn diff_tags(old: &str, new: &str) -> Vec<(ChangeTag, String)> { - let old_norm = normalize_for_diff(old); - let new_norm = normalize_for_diff(new); - let diff = TextDiff::configure() - .newline_terminated(true) - .diff_lines(&old_norm, &new_norm); - diff.iter_all_changes() - .map(|c| (c.tag(), c.value().trim_end().to_string())) - .collect() - } - - #[test] - fn test_diff_no_spurious_leading_green_line() { - // LLMs sometimes emit new_text with a leading \n that old_text lacks. - // The normalization should prevent a spurious "added empty line" at top. - let tags = diff_tags( - "/// comment\n#[derive(Debug)]", - "\n/// comment\n#[derive(Debug)]\npub new_field: u32,", - ); - // First line should be Equal, not Insert - assert_eq!( - tags[0].0, - ChangeTag::Equal, - "first line should be equal, got {:?}", - tags - ); - assert_eq!(tags[0].1, "/// comment"); - // The actually new line should be Insert - assert!( - tags.iter() - .any(|(t, l)| *t == ChangeTag::Insert && l == "pub new_field: u32,") - ); - } - - #[test] - fn test_diff_trailing_newline_mismatch_no_spurious_change() { - // old has no trailing \n, new does — should not produce a spurious change - let tags = diff_tags("line1\nline2", "line1\nline2\n"); - assert!( - tags.iter().all(|(t, _)| *t == ChangeTag::Equal), - "trailing newline mismatch should not produce changes: {:?}", - tags - ); - } - - #[test] - fn test_diff_normal_addition() { - let tags = diff_tags("line1\nline2\n", "line1\nline2\nnew_line\n"); - assert_eq!(tags.len(), 3); - assert_eq!(tags[0], (ChangeTag::Equal, "line1".to_string())); - assert_eq!(tags[1], (ChangeTag::Equal, "line2".to_string())); - assert_eq!(tags[2], (ChangeTag::Insert, "new_line".to_string())); - } - - #[test] - fn test_diff_intentional_blank_line_insertion_preserved() { - // Adding a blank line between two functions is a real change that must be shown. - let tags = diff_tags("fn a() {}\nfn b() {}", "fn a() {}\n\nfn b() {}"); - let inserts: Vec<_> = tags - .iter() - .filter(|(t, _)| *t == ChangeTag::Insert) - .collect(); - assert_eq!(inserts.len(), 1, "should insert one blank line: {:?}", tags); - assert_eq!(inserts[0].1, "", "the inserted line should be blank"); - } - - #[test] - fn test_diff_leading_newline_mismatch_both_directions() { - // old has leading \n, new doesn't — should not produce spurious change - let tags = diff_tags("\nline1\nline2", "line1\nline2\nnew"); - assert_eq!( - tags[0].0, - ChangeTag::Equal, - "first line should be equal: {:?}", - tags - ); - assert_eq!(tags[0].1, "line1"); - } } diff --git a/crates/ui_gpui/src/tool_cards/diff_prepare.rs b/crates/ui_gpui/src/tool_cards/diff_prepare.rs index 77666ee2c..571b4e24f 100644 --- a/crates/ui_gpui/src/tool_cards/diff_prepare.rs +++ b/crates/ui_gpui/src/tool_cards/diff_prepare.rs @@ -8,12 +8,10 @@ //! only changes colors: it always runs on a background thread, together with //! the line diff of inputs above [`SYNC_DIFF_MAX_BYTES`]. -use super::diff_card::{ - DiffLine, compute_diff_lines, get_param, parse_diff_sections, parse_match_start_lines, - single_sided_hunk, -}; -use super::diff_syntax::DiffSyntax; +use super::diff_card::{get_param, parse_diff_sections, parse_match_start_lines}; use crate::blocks::ToolUseBlock; +use crate::diff::model::{DiffLine, compute_diff_lines, normalize_for_diff, single_sided_hunk}; +use crate::diff::syntax::DiffSyntax; use gpui_kit::component::highlighter::HighlightTheme; use similar::ChangeTag; use std::hash::{Hash, Hasher}; @@ -135,14 +133,19 @@ impl DiffInput { } /// Parse every section for highlighting; `None` without a grammar for - /// the file. Sections are snippets, so lines count from 1 on both sides. + /// the file. Sections are snippets, so lines count from 1 on both sides; + /// they are normalized like the line diff does. /// With `theme`, the line styles are computed right away too. /// CPU-heavy — call on a background thread. pub fn parse_syntax(&self, theme: Option<&HighlightTheme>) -> Option> { self.sections .iter() .map(|s| { - let syntax = DiffSyntax::parse(&self.path, Some(&s.old), Some(&s.new))?; + let syntax = DiffSyntax::parse( + &self.path, + Some(&normalize_for_diff(&s.old)), + Some(&normalize_for_diff(&s.new)), + )?; if let Some(theme) = theme { syntax.prime(theme); } diff --git a/crates/ui_gpui/src/tool_cards/diff_syntax.rs b/crates/ui_gpui/src/tool_cards/diff_syntax.rs deleted file mode 100644 index 124a776e5..000000000 --- a/crates/ui_gpui/src/tool_cards/diff_syntax.rs +++ /dev/null @@ -1,452 +0,0 @@ -//! Syntax highlighting for diff rows. Both sides of a diff are parsed once -//! (tree-sitter, via gpui-component's [`SyntaxHighlighter`]) on a background -//! thread, and the styles of every line are computed once per theme, ideally -//! right there ([`DiffSyntax::prime`]): a tree-sitter query per row and frame -//! showed up as up to 7% of a frame's draw time. Rendering then only slices -//! the cached line styles. - -use super::diff_card::normalize_for_diff; -use gpui_kit::HighlightStyle; -use gpui_kit::component::Rope; -use gpui_kit::component::ThemeMode; -use gpui_kit::component::highlighter::{ - HighlightTheme, Language, LanguageRegistry, SyntaxHighlighter, -}; -use similar::ChangeTag; -use std::ops::Range; -use std::sync::Mutex; - -/// Styles of one line, as ranges relative to the line's start. -type LineStyles = Vec<(Range, HighlightStyle)>; - -/// Identifies the theme a [`StyleCache`] was computed for. The address alone -/// could be reused by a later theme, so name and appearance are compared too. -#[derive(Debug, Clone, PartialEq, Eq)] -struct ThemeKey { - address: usize, - name: String, - appearance: ThemeMode, -} - -impl ThemeKey { - fn of(theme: &HighlightTheme) -> Self { - Self { - address: theme as *const HighlightTheme as usize, - name: theme.name.clone(), - appearance: theme.appearance, - } - } -} - -/// The styles of every line of one side, for one theme. -struct StyleCache { - theme: ThemeKey, - lines: Vec, -} - -/// The grammar name for a file, if gpui-component ships one for it. -pub fn language_for_path(path: &str) -> Option<&'static str> { - let file_name = path.rsplit('/').next().unwrap_or(path); - let name = match file_name { - "Makefile" | "makefile" | "GNUmakefile" => "make", - "CMakeLists.txt" => "cmake", - "Cargo.lock" => "toml", - _ => match file_name.rsplit_once('.')?.1 { - "rs" => "rust", - "py" => "python", - "js" | "mjs" | "cjs" | "jsx" => "javascript", - "ts" | "mts" | "cts" => "typescript", - "tsx" => "tsx", - "c" | "h" => "c", - "cc" | "cpp" | "cxx" | "hpp" | "hh" | "hxx" => "cpp", - "cs" => "csharp", - "sh" | "bash" | "zsh" => "bash", - "md" | "mdx" => "markdown", - "yml" | "yaml" => "yaml", - "htm" | "html" => "html", - "css" | "scss" => "css", - "kt" | "kts" => "kotlin", - "rb" => "ruby", - "ex" | "exs" => "elixir", - "json" | "jsonc" => "json", - "go" => "go", - "java" => "java", - "lua" => "lua", - "php" => "php", - "proto" => "proto", - "scala" => "scala", - "sql" => "sql", - "svelte" => "svelte", - "swift" => "swift", - "toml" => "toml", - "zig" => "zig", - "cmake" => "cmake", - "graphql" | "gql" => "graphql", - _ => return None, - }, - }; - // Grammars are feature-gated in gpui-component; only claim what is built in. - (Language::from_str(name) != Language::Plain).then_some(name) -} - -/// Fence tags LLMs commonly write that gpui-component does not know, mapped to -/// a grammar that highlights them well enough. -const LANGUAGE_ALIASES: &[(&str, &str)] = &[ - ("shell", "bash"), - ("zsh", "bash"), - ("console", "bash"), - ("shellscript", "bash"), - ("dockerfile", "bash"), - ("jsx", "javascript"), - ("mjs", "javascript"), - ("golang", "go"), - ("h", "c"), - ("hpp", "cpp"), - ("cc", "cpp"), - ("cxx", "cpp"), - ("py3", "python"), - ("python3", "python"), - ("htm", "html"), - ("json5", "json"), -]; - -/// Make Markdown code blocks tagged with one of [`LANGUAGE_ALIASES`] highlight. -pub fn register_language_aliases() { - let registry = LanguageRegistry::singleton(); - for (alias, language) in LANGUAGE_ALIASES { - if let Some(config) = registry.language(language) { - registry.register(alias, &config); - } - } -} - -/// One parsed side of a diff. `line_starts[n]` is the byte offset of line -/// `n + 1` in the parsed text. -struct SyntaxSide { - highlighter: SyntaxHighlighter, - text: String, - line_starts: Vec, - /// Filled by [`Self::prime`] or on the first query; replaced when the - /// theme changes. - cache: Mutex>, -} - -impl SyntaxSide { - fn parse(language: &str, text: &str) -> Self { - // The same normalization the line diff applies, so line numbers agree. - let text = normalize_for_diff(text); - let mut highlighter = SyntaxHighlighter::new(language); - highlighter.update(None, &Rope::from_str(&text), None); - let line_starts = std::iter::once(0) - .chain(text.match_indices('\n').map(|(ix, _)| ix + 1)) - .collect(); - Self { - highlighter, - text, - line_starts, - cache: Mutex::new(None), - } - } - - /// Compute and keep the styles of every line for `theme`. - fn prime(&self, theme: &HighlightTheme) { - *self.cache.lock().unwrap() = Some(StyleCache { - theme: ThemeKey::of(theme), - lines: self.style_all_lines(theme), - }); - } - - /// One query over the whole text, cut into lines. Styles that are the - /// default are left out, like empty ranges. - fn style_all_lines(&self, theme: &HighlightTheme) -> Vec { - let mut lines: Vec = vec![Vec::new(); self.line_starts.len()]; - let line_end = |ix: usize| { - self.line_starts - .get(ix + 1) - .map_or(self.text.len(), |next| next - 1) - }; - let mut line_ix = 0; - for (range, style) in self.highlighter.styles(&(0..self.text.len()), theme) { - if style == HighlightStyle::default() || range.is_empty() { - continue; - } - while line_ix + 1 < self.line_starts.len() - && self.line_starts[line_ix + 1] <= range.start - { - line_ix += 1; - } - // A style can span lines (block comments, strings): clip it to each. - let mut ix = line_ix; - while ix < self.line_starts.len() && self.line_starts[ix] < range.end { - let start = self.line_starts[ix]; - let clipped = range.start.max(start)..range.end.min(line_end(ix)); - if clipped.start < clipped.end { - lines[ix].push((clipped.start - start..clipped.end - start, style)); - } - ix += 1; - } - } - lines - } - - fn line_styles(&self, line_no: usize, line: &str, theme: &HighlightTheme) -> LineStyles { - let Some(&start) = line_no - .checked_sub(1) - .and_then(|ix| self.line_starts.get(ix)) - else { - return Vec::new(); - }; - // Diff rows and parsed text split lines independently; only style a - // row that really is this line, so ranges always fit its text. - if line.is_empty() || !self.text[start..].starts_with(line) { - return Vec::new(); - } - let key = ThemeKey::of(theme); - let mut cache = self.cache.lock().unwrap(); - let cache = match cache.as_mut() { - Some(cache) if cache.theme == key => cache, - _ => cache.insert(StyleCache { - theme: key, - lines: self.style_all_lines(theme), - }), - }; - cache.lines[line_no - 1] - .iter() - .filter(|(r, _)| r.start < line.len()) - .map(|(r, style)| (r.start..r.end.min(line.len()), *style)) - .collect() - } - - /// The styles of `line_no` queried on their own, as they were computed - /// before the cache; the cache has to give the same answer. - #[cfg(test)] - fn line_styles_uncached(&self, line_no: usize, theme: &HighlightTheme) -> LineStyles { - let start = self.line_starts[line_no - 1]; - let end = self - .line_starts - .get(line_no) - .map_or(self.text.len(), |next| next - 1); - let range = start..end; - self.highlighter - .styles(&range, theme) - .into_iter() - .filter_map(|(r, style)| { - let r = r.start.max(range.start) - start..r.end.min(range.end) - start; - (r.start < r.end && style != HighlightStyle::default()).then_some((r, style)) - }) - .collect() - } -} - -/// Parsed old and new side of one file's diff. -pub struct DiffSyntax { - old: Option, - new: Option, -} - -impl std::fmt::Debug for DiffSyntax { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("DiffSyntax").finish_non_exhaustive() - } -} - -impl DiffSyntax { - /// Parse both sides of `path`'s diff. `None` when there is no grammar for - /// the file. CPU-heavy for large files — call on a background thread. - pub fn parse(path: &str, old: Option<&str>, new: Option<&str>) -> Option { - let language = language_for_path(path)?; - Some(Self { - old: old.map(|text| SyntaxSide::parse(language, text)), - new: new.map(|text| SyntaxSide::parse(language, text)), - }) - } - - /// Compute the styles of every line of both sides for `theme` now, so - /// the first frames do not have to. Call on the background thread that - /// parsed. - pub fn prime(&self, theme: &HighlightTheme) { - for side in [&self.old, &self.new].into_iter().flatten() { - side.prime(theme); - } - } - - /// Syntax styles for one diff row, as ranges into `line`. `line_no` is - /// 1-based in the row's own side: the old file for deletions, the new - /// file otherwise. Cheap after the first call per theme. - pub fn line_styles( - &self, - tag: ChangeTag, - line_no: usize, - line: &str, - theme: &HighlightTheme, - ) -> Vec<(Range, HighlightStyle)> { - let side = match tag { - ChangeTag::Delete => &self.old, - ChangeTag::Equal | ChangeTag::Insert => &self.new, - }; - side.as_ref() - .map(|side| side.line_styles(line_no, line, theme)) - .unwrap_or_default() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn language_is_detected_from_extension_and_file_name() { - assert_eq!(language_for_path("crates/ui/src/lib.rs"), Some("rust")); - assert_eq!(language_for_path("Cargo.lock"), Some("toml")); - assert_eq!(language_for_path("build/Makefile"), Some("make")); - assert_eq!(language_for_path("notes.txt"), None); - assert_eq!(language_for_path("LICENSE"), None); - } - - #[test] - fn common_fence_tags_resolve_to_a_grammar() { - register_language_aliases(); - let registry = LanguageRegistry::singleton(); - for (tag, language) in [ - ("shell", "bash"), - ("zsh", "bash"), - ("console", "bash"), - ("jsx", "javascript"), - ("golang", "go"), - ("h", "c"), - ("hpp", "cpp"), - ("dockerfile", "bash"), - // Built into gpui-component already. - ("rs", "rust"), - ("py", "python"), - ("yml", "yaml"), - ] { - let config = registry.language(tag); - assert_eq!( - config.as_ref().map(|c| c.name.as_ref()), - Some(language), - "fence tag {tag}" - ); - assert!(config.unwrap().has_grammar()); - } - assert!(registry.language("no-such-language").is_none()); - } - - #[test] - fn no_grammar_means_no_syntax() { - assert!(DiffSyntax::parse("notes.txt", Some("a"), Some("b")).is_none()); - } - - #[test] - fn line_styles_are_relative_to_the_row_and_pick_the_right_side() { - let old = "fn old_name() {}\n"; - let new = "// intro\nfn new_name() {}\n"; - let syntax = DiffSyntax::parse("a.rs", Some(old), Some(new)).unwrap(); - let theme = HighlightTheme::default_dark(); - - let keyword_at_start = - |styles: &[(Range, HighlightStyle)]| styles.iter().any(|(r, _)| *r == (0..2)); - - // New line 2 is the `fn`; on the old side it is line 1. - let inserted = syntax.line_styles(ChangeTag::Insert, 2, "fn new_name() {}", &theme); - assert!(keyword_at_start(&inserted), "{inserted:?}"); - let deleted = syntax.line_styles(ChangeTag::Delete, 1, "fn old_name() {}", &theme); - assert!(keyword_at_start(&deleted), "{deleted:?}"); - - for (range, _) in inserted.iter().chain(&deleted) { - assert!(range.end <= "fn new_name() {}".len()); - } - } - - #[test] - fn rows_that_do_not_match_the_parsed_line_get_no_styles() { - let syntax = DiffSyntax::parse("a.rs", None, Some("fn main() {}\n")).unwrap(); - let theme = HighlightTheme::default_dark(); - assert!( - syntax - .line_styles(ChangeTag::Insert, 1, "let x = 1;", &theme) - .is_empty() - ); - assert!( - syntax - .line_styles(ChangeTag::Insert, 9, "fn main() {}", &theme) - .is_empty() - ); - assert!( - syntax - .line_styles(ChangeTag::Insert, 0, "fn main() {}", &theme) - .is_empty() - ); - // No old side was parsed. - assert!( - syntax - .line_styles(ChangeTag::Delete, 1, "fn main() {}", &theme) - .is_empty() - ); - } - - #[test] - fn cached_line_styles_match_per_line_queries() { - let text = - "/* a comment\n over two lines */\nfn main() {\n let s = \"multi\nline\";\n}\n"; - let side = SyntaxSide::parse("rust", text); - let theme = HighlightTheme::default_dark(); - let cached = side.style_all_lines(&theme); - assert_eq!(cached.len(), side.line_starts.len()); - for line_no in 1..=side.line_starts.len() { - assert_eq!( - cached[line_no - 1], - side.line_styles_uncached(line_no, &theme), - "line {line_no}" - ); - } - // The comment's style reaches the second line, relative to that line. - assert_eq!(cached[1].first().map(|(r, _)| r.start), Some(0)); - } - - #[test] - fn line_styles_are_computed_once_per_theme() { - let syntax = DiffSyntax::parse("a.rs", Some("fn a() {}\n"), Some("fn b() {}\n")).unwrap(); - let dark = HighlightTheme::default_dark(); - let light = HighlightTheme::default_light(); - assert!(syntax.new.as_ref().unwrap().cache.lock().unwrap().is_none()); - - syntax.prime(&dark); - let cached_theme = - |side: &SyntaxSide| side.cache.lock().unwrap().as_ref().map(|c| c.theme.clone()); - assert_eq!( - cached_theme(syntax.new.as_ref().unwrap()), - Some(ThemeKey::of(&dark)) - ); - assert_eq!( - cached_theme(syntax.old.as_ref().unwrap()), - Some(ThemeKey::of(&dark)) - ); - - // Same theme: served from the cache; another theme replaces it. - assert!( - !syntax - .line_styles(ChangeTag::Insert, 1, "fn b() {}", &dark) - .is_empty() - ); - assert_eq!( - cached_theme(syntax.new.as_ref().unwrap()), - Some(ThemeKey::of(&dark)) - ); - assert!( - !syntax - .line_styles(ChangeTag::Insert, 1, "fn b() {}", &light) - .is_empty() - ); - assert_eq!( - cached_theme(syntax.new.as_ref().unwrap()), - Some(ThemeKey::of(&light)) - ); - } - - #[test] - fn diff_syntax_can_cross_threads() { - fn assert_send_sync() {} - assert_send_sync::(); - } -} diff --git a/crates/ui_gpui/src/tool_cards/mod.rs b/crates/ui_gpui/src/tool_cards/mod.rs index 0839ec75f..0ea9b2c5a 100644 --- a/crates/ui_gpui/src/tool_cards/mod.rs +++ b/crates/ui_gpui/src/tool_cards/mod.rs @@ -20,8 +20,6 @@ pub mod browser_renderer; pub mod code_card; pub mod diff_card; pub mod diff_prepare; -pub mod diff_rows; -pub mod diff_syntax; pub mod inline_renderer; pub mod mcp_tool; pub mod session_card; diff --git a/crates/ui_terminal/src/app.rs b/crates/ui_terminal/src/app.rs index dc56d7cbb..18f5eea4b 100644 --- a/crates/ui_terminal/src/app.rs +++ b/crates/ui_terminal/src/app.rs @@ -1366,6 +1366,7 @@ impl TerminalTuiApp { project_manager_factory: code_assistant_core::session::service::default_project_manager_factory(), llm_client_factory: None, + pull_requests: None, }), events, ); diff --git a/deny.toml b/deny.toml index f4ca4f6d2..884337a72 100644 --- a/deny.toml +++ b/deny.toml @@ -40,6 +40,12 @@ ignore = [ { id = "RUSTSEC-2026-0391", reason = "zstd with_context() reuse leaks stream data; zip never reuses a context" }, { id = "RUSTSEC-2026-0398", reason = "zstd Seekable::decompress overflow; the seekable feature is not built" }, { id = "RUSTSEC-2026-0403", reason = "zstd legacy frame decoding exposes uninit memory; the legacy feature is not built" }, + # lru 0.12 comes in only through ratatui 0.29 (patched fork, no newer lru + # there); our own crates use a fixed lru. ratatui's layout cache calls + # `get_or_insert` and `resize` only, on keys and values whose Drop cannot + # panic. Drop these when ratatui moves to lru >= 0.18.2. + { id = "RUSTSEC-2026-0002", reason = "lru IterMut is unsound; ratatui never iterates its layout cache mutably" }, + { id = "RUSTSEC-2026-0253", reason = "lru pop() lacks panic safety; ratatui's cached Rect/Layout values never panic on drop" }, ] # --------------------------------------------------------------------------- diff --git a/docs/files-panel-plan.md b/docs/files-panel-plan.md index 2f15fdd24..b4b9f6790 100644 --- a/docs/files-panel-plan.md +++ b/docs/files-panel-plan.md @@ -103,13 +103,13 @@ line_selection, comment_editor}.rs`, `review_view/comments.rs`, header at all. - **Review view** (`review_view.rs`) stacks changed files per repo with their hunks in a virtualized `list()`. Diff bodies are `DiffRows` elements - (`tool_cards/diff_rows.rs`), which support whole-line selection through + (`diff/rows.rs`), which support whole-line selection through `RowSelection` (`base_line`, `highlight`, `on_start`/`on_drag`/`on_end`). The view keeps one `DiffSelection { file, anchor, head }` and a Copy button. Selection indices are flat indices into the file's hunk lines, not file line numbers. -- **Diff data.** `PreparedReviewDiff` holds hunks of lines with - syntax highlights (`tool_cards/diff_syntax.rs`, `diff_prepare.rs`); each +- **Diff data.** `PreparedFileDiff` (`diff/prepare.rs`) holds hunks of + lines with syntax highlights (`diff/syntax.rs`); each diff line knows its old/new line numbers (the gutter shows them). - **Composer.** `InputArea` (`ui_gpui/src/input/mod.rs`) holds text plus `Vec`; attachments render as `AttachmentView` tiles and diff --git a/docs/frame-profiling.md b/docs/frame-profiling.md index 7909eed29..863155990 100644 --- a/docs/frame-profiling.md +++ b/docs/frame-profiling.md @@ -187,7 +187,7 @@ counts do). tree-sitter query per row and frame fell from 4-7 % of the draw time to 0.2-0.4 %; the `edit` card's build went from 0.85 ms to 0.25 ms per card. 2. **Diff rows as one element** (`DiffRows` in - `tool_cards/diff_rows.rs`, used by tool cards and the review panel). + `diff/rows.rs`, used by tool cards and the review panel). Taffy's share of the draw time fell from 39-40 % to 25-27 %. Over the whole sweep the draw mean went from 5.44 ms to 5.16 ms (p95 6.50 to 5.93 ms); in intervals with diff cards in view it went down by diff --git a/docs/pull-request-view.md b/docs/pull-request-view.md new file mode 100644 index 000000000..0f14dcd59 --- /dev/null +++ b/docs/pull-request-view.md @@ -0,0 +1,299 @@ +# Pull request view + +A GitHub pull request page in the right panel: the conversation, the +timeline and the changed files with inline review threads, plus everything +needed to act on a PR (comment, review, reply, resolve, react, label, +request reviewers, mark files viewed, merge). Modelled closely on T3 Code's +pull request panel (`~/workspace/t3code`, `apps/web/src/components/pullRequest/`, +`packages/source-control-github/src/server/`, `apps/server/src/pullRequest/`), +including how it stays fast. + +## Layers + +``` +github (Layer 0) HTTP client for api.github.com: auth via gh, + REST + GraphQL, quota, ETags, typed model, + JSON → model mapping, every PR operation +git (Layer 0) + patch.rs: unified diff text → files/hunks +code_assistant_core pull_requests/: repo resolution for a session, + (Layer 3) read cache (TTL, coalescing, stale-while- + revalidate, disk snapshots), invalidation after + writes, app-scoped UiEvent; SessionService methods +ui_gpui (Layer 4) diff/: shared diff model + row rendering (Review + and PR Files tab), right_panel/pull_request/: the + panel, tabs, composer, pickers +``` + +### `github` crate + +- **Auth**: `GH_TOKEN` / `GITHUB_TOKEN`, else `gh auth token --hostname ` + (10 s timeout). Tokens are cached 5 min. A 401 drops the cached token and + retries once. Only `gh` supplies the token; requests go over HTTP. +- **Transport seam**: `trait Transport { async fn send(Request) -> Response }` + with a reqwest implementation and a scripted fake (feature `test-utils`) + that answers by GraphQL operation name or REST method + path. Every + higher-level test (crate and core) runs against the fake. There is no + second trait over the whole API. +- **HTTP**: `Authorization: Bearer`, `x-github-api-version: 2022-11-28`, + `user-agent: code-assistant`. github.com → `https://api.github.com` and + `/graphql`; GHE → `https:///api/v3` and `/api/graphql`. At most 8 + concurrent requests (secondary limits), 30 s timeout (60 s for diffs), + 8 MB body cap. +- **Quota**: record `x-ratelimit-*` per host and resource. Background reads + stop below 10 % remaining; interactive reads may use quota down to 1. + 429 or a rate-limited 403 pauses the host until `retry-after` / + `x-ratelimit-reset` (else 30 s, doubling to 15 min). GraphQL 200s carrying + `RATE_LIMITED` count as rate limited too. +- **ETag cache** for REST GETs (512 entries, 1 h): `If-None-Match`, a 304 + returns the stored body (costs no quota). Used by the checks revalidator. +- **GraphQL documents** ported from T3's `.graphql` files (`include_str!`): + `PullRequestCore`, `PullRequestCheckContexts`, `PullRequestActivity`, + `PullRequestCommitStats`, `ReviewThreads`, `ReviewThreadComments`, + `PullRequestSearch`, `PullRequestList`, `PullRequestSummary` (aliased, + 25 per request), `PullRequestFilesViewed`, `ReviewerCandidates`, + `LabelCandidates`, `PullRequestActionState` and the mutations + (`AddComment`, `UpdateIssueComment`, `UpdateReviewComment`, + `UpdatePullRequest`, `AddReviewThreadReply`, `Resolve/UnresolveReviewThread`, + `AddReaction`/`RemoveReaction`, `SetFileViewed` aliased up to 100, + `MergePullRequest`, `EnablePullRequestAutoMerge`, + `DisablePullRequestAutoMerge`, `UpdatePullRequestBranch`, + `MarkPullRequestReadyForReview`, `ConvertPullRequestToDraft`, + `ClosePullRequest`, `ReopenPullRequest`). Node ids come from + `PullRequestNodeId` (LRU 128). +- **REST**: `GET user` (viewer), diff `GET pulls/N` with + `Accept: application/vnd.github.diff` (on truncation or 406 fall back to + `pulls/N/files?per_page=100&page=k`, rebuilt as patch text, cursor = page), + commit diff `commits/SHA`, file contents `contents/?ref=SHA` + (`application/vnd.github.raw+json`, 1 MB cap), checks revalidation + (`pulls/N`, `commits/SHA/check-runs?filter=all`, `commits/SHA/status`), + submit review `POST pulls/N/reviews`, reviewers + `POST/DELETE pulls/N/requested_reviewers`, labels `POST issues/N/labels`, + `DELETE issues/N/labels/{name}`. +- **Model** (serde, also persisted): `RepoRef {host, owner, name}` + (+ `from_remote_url`), `PullRequestRef {repo, number}`, `Actor`, `Label`, + `Check`, `ChecksState`, `ListEntry`, `Summary`, `Detail` (body, head sha, + reviewers, labels, checks, merge capabilities, viewer permissions, + behind-by, auto-merge, maintainer-can-modify), `Activity` (comments of + kind issue-comment / review-comment / review, review threads, commits, + reactions), `ReviewThread`, `ThreadComment`, `Reaction`, `Commit`, + `DiffPage {patch, truncated, next_cursor, omitted_file_stats}`, + `FileViewed {path, state: Unviewed|Viewed|Dismissed}`. Field lists follow + `t3code/packages/contracts/src/pullRequest.ts`. + +### `git::patch` + +`parse_patch(&str) -> Vec` for `diff --git` text (renames, new / +deleted modes, binary markers, `\ No newline at end of file`, CRLF) and +`parse_hunks(&str)` for a single file's `patch` field. A hunk keeps its +`@@` header text (function context) and old/new start lines. + +### Core: `code_assistant_core::pull_requests` + +- **Repo resolution**: session → effective path → `origin` remote (else the + first remote, by name, whose URL names a GitHub repository) → `RepoRef` + (`git::GitRepository::remotes`, `pull_requests::github_remote`). The + head branch is the session's `branch`, else the branch checked out there + unless it is the base branch. The session's own PR comes from the + lifecycle snapshot (number) or a head-branch lookup (newest PR not from a + fork, cached 60 s). +- **Client**: one `PullRequests` store per `SessionService`, built from + `AgentRuntimeOptions::pull_requests` (`PullRequestSource {client, + snapshot_dir}`; `None` = `github::Client::new()` and + `/pull-requests/`; tests pass a `FakeTransport` client). It + sits in the service's `ServiceCtx`; every API method runs through + `call_io`, outside the session lanes. Agent runs get the client through + `RunConfig::github` for the post-run lifecycle refresh. +- **Read cache** (`pull_requests/cache.rs`), one LRU per part, keyed by + the PR (lists by `(repo, query)`, diffs by `(pr, commit, cursor)`; a + detail read that sees a new head drops the PR's diffs, so a diff + prefetched alongside the first detail read still counts), with in-flight + coalescing (concurrent identical + reads share one request, run on a task of its own so it lands even when + the readers are gone) and these TTLs: + + | Part | TTL | Held | + |---|---|---| + | list | 30 s | 64 | + | detail | 60 s (10 min once merged / closed) | 128 | + | checks | 15 s | 128 | + | activity (comments, threads, commits) | 60 s | 128 | + | diff page | 60 s; per-commit diffs 10 min | 128, 32 MB of patch text | + | files viewed | 15 s | 128 | + | viewer, candidates | 10 min | 32 | + | own PR of a branch | 60 s | 128 | + +- **Stale-while-revalidate**: a value within its TTL is served as it is; + up to 10 min past it, at once with `refreshing: true` while a background + read (`Priority::Background`) refreshes it. When that read lands, stored + or failed, an app-scoped `UiEvent::PullRequestChanged {pr, part}` (lists: + `PullRequestListChanged {repo}`) tells the view to read again. A failed + refresh keeps the value and waits one TTL before the next try (no refresh + loop). With nothing servable the reader waits for an interactive read; if + that fails for a passing reason (rate limit, network, 5xx), a value within + the window is served instead. +- **Disk snapshots** for a cold start: the last detail + activity of the + 20 most recently read PRs and the first page of the 20 most recently read + lists, one JSON file each under `/pull-requests/` (name = hash + of the key, the key stored inside). Written on a background task, one + write after the other, pruned after each write; read when memory holds + nothing, shown at once as stale, then revalidated. +- **Writes** run interactively, then (failed or not) invalidate the parts + they change: the next read waits for GitHub, reads under way are dropped + so a pre-write answer never lands. Each part is announced with + `PullRequestChanged`; a write touching the detail also invalidates the + repo's lists and publishes `PullRequestListChanged`. + + | Write | Parts | + |---|---| + | comment, edit comment, reply, resolve, react | activity | + | title / body / maintainer edits, labels | detail (+ label candidates) | + | review, request reviewers | detail, activity (+ reviewer candidates) | + | action (merge, close, update branch, …) | detail, checks, activity | + | files viewed | files viewed only (no diff refetch) | + | refresh | every part, candidates | + +- The lifecycle sweep reads the session PRs through the same client: + one batched `PullRequestSummary` for all known PR numbers, a head-branch + lookup for sessions without one (or whose PR now has another head or is + gone), then one more batch for those (`session::pull_request`). + +#### API (`SessionService`, `session/service/pull_requests.rs`) + +Model types are the `github` crate's (`github::model::*`, `RepoRef`, +`PullRequestRef`); core re-exports nothing of its own for them. `Fetched`, +`PullRequestPart` and `SessionPullRequests` live in +`code_assistant_core::pull_requests`. Errors are `anyhow` around +`github::Error` (downcast for e.g. `NoCredential`). List refreshes have no +method of their own: list reads revalidate after 30 s, and writes that +change a row invalidate the repo's lists. + +```rust +/// A cached read: the value plus whether a background refresh is under way +/// (a `PullRequestChanged` follows when it lands). +pub struct Fetched { pub value: T, pub fetched_at: SystemTime, pub refreshing: bool } + +pub enum PullRequestPart { Detail, Checks, Activity, Diff, FilesViewed } + +// Where a session's pull requests live and which one is its own; `None` +// without a project or a GitHub remote. +async fn session_pull_requests(&self, session_id: String) -> Result>; +// SessionPullRequests { repo: RepoRef, head_branch: Option, own: Option } + +// Reads: held value at once (memory, else disk snapshot) and a background +// revalidation when past the TTL; nothing held → waits for the fetch. +// list_stats, thread_comments and file_contents are not cached; viewer and +// candidates are cached but return the value alone. +async fn pull_request_list(&self, repo: RepoRef, query: ListQuery) -> Result>; +async fn pull_request_list_stats(&self, prs: Vec) -> Result>; +async fn pull_request_detail(&self, pr: PullRequestRef) -> Result>; +async fn pull_request_checks(&self, pr: PullRequestRef) -> Result>; +async fn pull_request_activity(&self, pr: PullRequestRef) -> Result>; +async fn pull_request_diff(&self, pr: PullRequestRef, commit: Option, cursor: Option) -> Result>; +async fn pull_request_files_viewed(&self, pr: PullRequestRef) -> Result>; +async fn pull_request_thread_comments(&self, pr: PullRequestRef, thread_id: String, cursor: String) -> Result; // after the thread's next_comments_cursor +async fn pull_request_file_contents(&self, pr: PullRequestRef, commit: Option, change: FileChange, old_path: String, new_path: String) -> Result; +async fn pull_request_reviewer_candidates(&self, pr: PullRequestRef) -> Result; +async fn pull_request_label_candidates(&self, pr: PullRequestRef) -> Result; +async fn github_viewer(&self, host: String) -> Result; + +// Writes: run interactively, then invalidate and publish PullRequestChanged. +async fn pull_request_comment(&self, pr, body: String) -> Result<()>; +async fn pull_request_update_comment(&self, pr, id: String, kind: EditableCommentKind, body: String) -> Result<()>; +async fn pull_request_update(&self, pr, update: PullRequestUpdate) -> Result<()>; +async fn pull_request_submit_review(&self, pr, review: ReviewSubmission) -> Result<()>; +async fn pull_request_reply(&self, pr, thread_id: String, body: String) -> Result<()>; +async fn pull_request_resolve_thread(&self, pr, thread_id: String, resolved: bool) -> Result<()>; +async fn pull_request_react(&self, pr, subject_id: Option, content: ReactionContent, reacted: bool) -> Result<()>; +async fn pull_request_request_reviewers(&self, pr, reviewers: Vec, requested: bool) -> Result<()>; +async fn pull_request_set_labels(&self, pr, labels: Vec, applied: bool) -> Result<()>; +async fn pull_request_set_files_viewed(&self, pr, files: Vec<(String, bool)>) -> Result<()>; +async fn pull_request_action(&self, pr, action: Action) -> Result<()>; +async fn pull_request_refresh(&self, pr: PullRequestRef) -> Result<()>; // drop every cached part, refetch on next read +``` + +`UiEvent::PullRequestChanged { pr: PullRequestRef, part: PullRequestPart }` +and `UiEvent::PullRequestListChanged { repo: RepoRef }` are app-scoped +(always forwarded); a view holding that PR re-reads the part. + +### UI: right panel tab "Pull request" + +Navigation: the tab shows the session's PR when it has one, otherwise the +repo's PR list; a back crumb returns to the list. Detail tabs: **Summary**, +**Timeline**, **Code** (T3's Guide tab, an agent-written review tour, is +out of scope for now). + +- **Header**: repo · `#N ↗` in the state colour; editable title; author · + updated; `base ← head` (copy), files, `+A −D`; behind-by warning with + "Update branch"; primary action (Merge with method / Ready for review / + Auto-merge / state badge) with a two-click inline confirm; ⋯ menu + (refresh, draft/ready, close/reopen, auto-merge on/off, merge method, + maintainer edits, open on GitHub, copy link, delete branch). Collapses to + one row when scrolled. +- **Summary** (GitHub's Conversation): reviewers (avatars, verdict rings, + picker), labels (chips, picker), description (markdown, edit), checks + (collapsed list, popover in the tab bar), comments with bot / resolved + filters, newest-first toggle, 10 conversations at a time, comment cards + (header strip, verdict badge, reactions, edit, body clipped at 240 px), + threads with replies, collapsed resolved/dismissed cards. +- **Timeline**: opened, commits (click → Code tab scoped to the commit), + grouped comments, review verdicts, review threads, merged/closed. +- **Code**: commit scope, files in tree order, hide tests, viewed counter, + per-file sticky header with Viewed checkbox (shift-click range, ticking + folds), dual-gutter diff with syntax highlighting, inline review threads + (reply, resolve, load more, reactions, edit), threads not on the diff, + new comments on a line or range → pending review (or → agent, as today's + line comments), diff pages loaded as the end scrolls into view. +- **Composer**: floating button with the pending-comment count; Comment + (post, close with comment) and Review (Comment / Approve / Request + changes + pending comments). +- **Optimistic**: files viewed (overlay, writes batched 400 ms, serialized), + reactions (pending map keyed by the host's reaction signature); list after + merge/close. Everything else waits for the server, then refreshes. +- **Freshness**: checks poll every 45 s while pending (60 s otherwise) only + while the tab is visible; a full revalidation when the window activates. +- **Colours**: open emerald, draft zinc, closed red, merged violet; + approved emerald, changes requested red, pending checks amber. + +### Look (from T3 Code, light theme) + +- **List row**: state glyph left; `#281` muted + title (truncated) on line + one with a checks / review glyph and `+1,933 −179` (green / red, mono) + right-aligned; line two small and muted: avatar + login, repo, relative + time right-aligned. Group headers ("Others 4") with an icon and a hairline + to the right. Above the list: search field ("Search pull requests, or + label:bug"), then outline buttons Sort, Filters, a refresh icon button. + Sort menu: Merge readiness, Blocked on me, Recently updated, Newest, + Oldest, Largest, Smallest. Filters menu with sub-menus showing the current + value right-aligned: State (Open), Involvement (All), Author (Anyone), + Labels (Any), Draft, Review, Checks (All). +- **Detail header**: muted `owner/repo` followed by `#290 ↗` in the state + colour; right side an outline "Check out ▾", a state-coloured outline + badge ("Merged" with merge icon) or the merge button, and `⋯`. Title in + semibold, larger. Meta line: avatar + login (medium) · "updated 9h ago" + muted, right-aligned mono `gh pr checkout 290` (click copies). Branch + line in mono muted: `main ← feat/image-file-renderer`, right-aligned file + icon "6 files" `+432 −46`. A hairline separates the header from the tab + row. +- **Tab row**: segmented control (Summary · Timeline · Code), selected + segment white with a soft shadow on a light grey track. Right side per + tab: Summary "✓ 5 of 6 passing" (checks popover), Timeline "💬 0 · ⟜ 1 · + ⇅ Newest first", Code nothing (its toolbar follows below). +- **Summary**: label column (icon + "Reviewers", "Labels") with values + ("None" muted + add icon); "Description ▾" section header; the body as + rendered markdown at normal reading size. +- **Timeline**: vertical rail with 32 px markers (icons or avatars), text + "Pull request merged / 9h ago", " Pull request opened", commit rows + with headline, short sha, time and `+432 −46` right-aligned. +- **Code**: toolbar "All commits ▾" (dropdown lists "All commits" and each + commit headline + short sha), "6 files · 0 / 6 viewed"; right-aligned icon + toggles (whitespace ¶, collapse all, stacked | split, wrap, file tree). + File header rows: chevron, status icon (blue dot modified, green plus + added), full path, `+69 −6`, "☐ Viewed". Diff: single line-number gutter, + added lines with a green bar at the left edge and a pale green + background, syntax highlighted; separators "30 unmodified lines" and "More + unchanged context may be available" as centred muted text on a hairline. +- **⋯ menu**: Refresh; (agent items); Open on GitHub; Copy link ⇧⌘C; Copy + PR number ⇧⌘K; Revert changes. Items have leading icons and an optional + muted second line. +- **Composer**: round floating button bottom right with a speech-bubble + icon. diff --git a/docs/session-lifecycle.md b/docs/session-lifecycle.md index c266c222a..b6d5d7692 100644 --- a/docs/session-lifecycle.md +++ b/docs/session-lifecycle.md @@ -91,15 +91,18 @@ A session is associated with a branch in two ways: `ChatMetadata::branch` mirrors the config field for the sidebar. -The pull request behind the branch is read with the GitHub CLI -(`gh pr view --json …`, `session::pull_request`) and stored as a +The pull request behind the branch is read through the `github` crate's +client (`session::pull_request::read_branch_pull_requests`) and stored as a `PullRequestSnapshot` (number, url, title, open/draft/merged/closed, review -decision, checks verdict) in the lifecycle record. It is refreshed after -each run of the session and by the sweep for every unsettled session on a -branch. Without `gh`, or without a login, sessions show only their branch. -The `fetch_pull_request` seam is the place to swap in an API client -(octocrab) later; what that needs on top is a token and the owner/repo -parsed from the remote. +decision, checks verdict) in the lifecycle record. The repository comes +from the checkout's `origin` remote (else the first GitHub remote). It is +refreshed after each run of the session and by the sweep for every +unsettled session on a branch: one batched `PullRequestSummary` request for +all sessions whose pull request is known, a lookup by branch for the others +(and for a known one that is gone or now has another head), then one more +batch for what that found. The token comes from `GH_TOKEN` / `GITHUB_TOKEN` +or `gh auth token`; without one, the reads fail and sessions show only their +branch. See [pull-request-view.md](pull-request-view.md) for the client. ## Storage