diff --git a/Cargo.lock b/Cargo.lock index beb0e863..9b18671b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10069,6 +10069,10 @@ name = "similar" version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +dependencies = [ + "bstr", + "unicode-segmentation", +] [[package]] name = "simplecss" diff --git a/crates/code_assistant_core/src/session/mod.rs b/crates/code_assistant_core/src/session/mod.rs index 367c04cd..d642acae 100644 --- a/crates/code_assistant_core/src/session/mod.rs +++ b/crates/code_assistant_core/src/session/mod.rs @@ -24,6 +24,7 @@ pub mod watcher; pub use event_stream::{EventPayload, EventStream, SessionEvent, StreamError, Subscription}; pub use manager::SessionManager; pub use service::SessionService; +pub use service::{RepoReview, ReviewMode, ReviewScanState, WorktreeListing}; pub use turn::{ ResourceRef, ToolRecord, TurnDispatch, TurnHandle, TurnOutcome, TurnRequest, TurnStatus, TurnUsage, diff --git a/crates/code_assistant_core/src/session/service.rs b/crates/code_assistant_core/src/session/service.rs index c6723a53..f5885838 100644 --- a/crates/code_assistant_core/src/session/service.rs +++ b/crates/code_assistant_core/src/session/service.rs @@ -31,7 +31,7 @@ use llm::factory::create_llm_client_from_model; use llm::provider_config::ConfigurationSystem; use sandbox::SandboxPolicy; use std::future::Future; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::pin::Pin; use std::sync::Arc; use tokio::sync::Mutex; @@ -135,6 +135,64 @@ pub struct WorktreeListing { pub is_git_repo: bool, } +/// Which changes the Review panel should compare. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum ReviewMode { + /// Local working-tree changes (staged, unstaged, untracked) vs `HEAD`. + WorkingTree, + /// The current branch vs a base branch, using merge-base (PR) semantics. + BranchVsBase, +} + +/// Progress of one repo's background review scan, carried alongside the data +/// so the UI can render an activity indicator per repo. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReviewScanState { + /// Not scanned yet in this round (data, if any, is from the cache). + Pending, + /// Currently being scanned in the background. + Scanning, + /// Scan finished; data is fresh. + Done, +} + +/// Changed files for a single git repository within the reviewed project, +/// plus the per-repo metadata the UI needs to render its selectors. +#[derive(Debug, Clone)] +pub struct RepoReview { + /// Absolute path to the repository's working directory. + pub repo_root: PathBuf, + /// Human-readable label (the repo directory's name). + pub label: String, + pub current_branch: Option, + /// Candidate base refs (local + remote branches) for `BranchVsBase` mode. + pub base_candidates: Vec, + /// The base ref actually used in `BranchVsBase` mode (may have been + /// defaulted from `None`). + pub base: Option, + pub files: Vec, + /// Aggregate added/deleted line counts (untracked files not included). + pub stats: git::DiffStats, + /// Scan progress for this repo in the current listing round. + pub scan_state: ReviewScanState, +} + +impl RepoReview { + /// An entry for a discovered-but-not-yet-scanned repo. + pub fn pending(repo_root: PathBuf, label: String) -> Self { + Self { + repo_root, + label, + current_branch: None, + base_candidates: Vec::new(), + base: None, + files: Vec::new(), + stats: git::DiffStats::default(), + scan_state: ReviewScanState::Pending, + } + } +} + /// A git worktree the session was switched to. #[derive(Debug, Clone)] pub struct CreatedWorktree { @@ -1028,6 +1086,110 @@ impl SessionService { .await } + /// Discover the git repositories to review for a session — fast, no + /// per-repo scanning. Returns `(repo workdir, display label)` pairs. + /// + /// Resolves the session's on-disk directory with `effective_project_path` + /// (worktree-aware). An empty result means "not a git project". + pub async fn list_review_repos(&self, session_id: String) -> Result> { + self.call(move |ctx| async move { + let project_root = { + let manager = ctx.manager.lock().await; + session_effective_path(&manager, &session_id)? + }; + Ok(discover_review_repos(&project_root)) + }) + .await + } + + /// Scan a single repo for the Review panel: changed files, diff stats, and + /// the selector metadata. In `BranchVsBase` mode a `None` `base_override` + /// defaults to the current branch's upstream, else the first remote + /// candidate. + pub async fn scan_review_repo( + &self, + repo_root: PathBuf, + label: String, + mode: ReviewMode, + base_override: Option, + ) -> Result { + self.call(move |_ctx| async move { + let repo = + git::GitRepository::open(&repo_root).context("Failed to open git repository")?; + let current_branch = repo.current_branch(); + let base_candidates = repo.list_base_candidates().unwrap_or_default(); + + let (files, stats, resolved_base) = match mode { + ReviewMode::WorkingTree => { + let files = repo + .changed_files_working_tree() + .await + .context("Failed to list working-tree changes")?; + // Stats are best-effort (e.g. unborn HEAD has no diff base). + let stats = repo.diff_stats_working_tree().await.unwrap_or_default(); + (files, stats, None) + } + ReviewMode::BranchVsBase => { + let resolved = resolve_review_base(&repo, base_override, &base_candidates); + match &resolved { + Some(b) => { + let files = repo + .changed_files_vs_base(b) + .await + .with_context(|| format!("Failed to diff against {b}"))?; + let stats = repo.diff_stats_vs_base(b).await.unwrap_or_default(); + (files, stats, resolved) + } + None => (Vec::new(), git::DiffStats::default(), None), + } + } + }; + + Ok(RepoReview { + repo_root, + label, + current_branch, + base_candidates, + base: resolved_base, + files, + stats, + scan_state: ReviewScanState::Done, + }) + }) + .await + } + + /// Load both sides of the diff for a single file in the Review panel. + pub async fn get_review_file_diff( + &self, + _session_id: String, + repo_root: PathBuf, + mode: ReviewMode, + base: Option, + file: git::ChangedFile, + ) -> Result { + self.call(move |_ctx| async move { + let repo = + git::GitRepository::open(&repo_root).context("Failed to open git repository")?; + + match mode { + ReviewMode::WorkingTree => repo + .file_diff_working_tree(&file) + .await + .context("Failed to load working-tree diff"), + ReviewMode::BranchVsBase => { + let base_candidates = repo.list_base_candidates().unwrap_or_default(); + let resolved = resolve_review_base(&repo, base, &base_candidates) + .ok_or_else(|| anyhow!("No base branch available for comparison"))?; + repo.file_diff_vs_base(&resolved, &file) + .await + .context("Failed to load branch-vs-base diff") + } + } + }) + .await + } + pub async fn switch_worktree( &self, session_id: String, @@ -1206,6 +1368,173 @@ fn session_project_root(manager: &SessionManager, session_id: &str) -> Result Result { + let session = manager + .get_session(session_id) + .ok_or_else(|| anyhow!("Session {session_id} not found"))?; + session + .session + .config + .effective_project_path() + .cloned() + .ok_or_else(|| anyhow!("Session has no project path configured")) +} + +/// Choose the base ref for `BranchVsBase` mode. Prefers an explicit `base` +/// (validated against candidates), then the current branch's upstream, then +/// the first `origin/*` candidate, then any candidate that is not the current +/// branch. +fn resolve_review_base( + repo: &git::GitRepository, + base: Option, + candidates: &[String], +) -> Option { + if let Some(base) = base + && candidates.iter().any(|c| c == &base) + { + return Some(base); + } + + let current = repo.current_branch(); + + if let Some(branch) = repo.list_branches().ok().and_then(|branches| { + branches + .into_iter() + .find(|b| b.is_head) + .and_then(|b| b.upstream) + }) && candidates.iter().any(|c| c == &branch) + { + return Some(branch); + } + + if let Some(origin) = candidates.iter().find(|c| c.starts_with("origin/")) { + return Some(origin.clone()); + } + + candidates + .iter() + .find(|c| current.as_deref() != Some(c.as_str())) + .cloned() +} + +/// Discover the git repositories to review under `root`. +/// +/// Returns `(repo workdir, display label)` pairs. If `root` is itself a repo +/// root, that single repo is returned (preserving single-repo behavior). +/// Otherwise `root`'s immediate subdirectories are scanned and each one that is +/// its own repo root is included — no deep recursion. Results are sorted by +/// label. +/// +/// `GitRepository::open` discovers *upward*, so a plain child folder inside an +/// upward repo would resolve to that ancestor; the `workdir() == dir` check +/// (both canonicalized) is what pins each entry to an actual repo root. +fn discover_review_repos(root: &Path) -> Vec<(PathBuf, String)> { + fn is_repo_root(dir: &Path) -> bool { + let Ok(repo) = git::GitRepository::open(dir) else { + return false; + }; + let workdir = repo.workdir(); + match (workdir.canonicalize(), dir.canonicalize()) { + (Ok(a), Ok(b)) => a == b, + _ => workdir == dir, + } + } + + fn label_for(dir: &Path) -> String { + dir.file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| dir.display().to_string()) + } + + if is_repo_root(root) { + return vec![(root.to_path_buf(), label_for(root))]; + } + + let mut repos: Vec<(PathBuf, String)> = Vec::new(); + if let Ok(entries) = std::fs::read_dir(root) { + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + if is_repo_root(&path) { + let label = label_for(&path); + repos.push((path, label)); + } + } + } + repos.sort_by_key(|(_, label)| label.to_lowercase()); + repos +} + +#[cfg(test)] +mod discover_tests { + use super::discover_review_repos; + use std::process::Command; + + fn git_init(dir: &std::path::Path) { + let ok = Command::new("git") + .args(["init", "-q"]) + .current_dir(dir) + .status() + .expect("run git init") + .success(); + assert!(ok, "git init failed in {}", dir.display()); + } + + #[test] + fn root_is_repo_returns_single_entry() { + let tmp = tempfile::tempdir().unwrap(); + git_init(tmp.path()); + let repos = discover_review_repos(tmp.path()); + assert_eq!(repos.len(), 1); + assert_eq!( + repos[0].0.canonicalize().unwrap(), + tmp.path().canonicalize().unwrap() + ); + } + + #[test] + fn plain_folder_with_two_child_repos() { + let tmp = tempfile::tempdir().unwrap(); + let a = tmp.path().join("alpha"); + let b = tmp.path().join("beta"); + std::fs::create_dir(&a).unwrap(); + std::fs::create_dir(&b).unwrap(); + std::fs::create_dir(tmp.path().join("plain")).unwrap(); + git_init(&a); + git_init(&b); + let repos = discover_review_repos(tmp.path()); + assert_eq!(repos.len(), 2); + assert_eq!(repos[0].1, "alpha"); + assert_eq!(repos[1].1, "beta"); + } + + #[test] + fn nested_non_root_child_is_ignored() { + // A repo at root/outer, with root/outer/inner a plain subdir. Scanning + // root sees only `outer`; `inner` is never a scanned top-level child, + // and a plain folder with no repos yields nothing. + let tmp = tempfile::tempdir().unwrap(); + let outer = tmp.path().join("outer"); + std::fs::create_dir(&outer).unwrap(); + git_init(&outer); + std::fs::create_dir(outer.join("inner")).unwrap(); + + // Scanning the repo root itself → single entry (the root). + let repos = discover_review_repos(&outer); + assert_eq!(repos.len(), 1); + + // A sibling plain folder with no git repos → empty. + let empty = tmp.path().join("empty"); + std::fs::create_dir(&empty).unwrap(); + assert!(discover_review_repos(&empty).is_empty()); + } +} + async fn send_user_message_impl( ctx: &ServiceCtx, session_id: &str, diff --git a/crates/code_assistant_core/src/ui/ui_events.rs b/crates/code_assistant_core/src/ui/ui_events.rs index 2387b019..40155b55 100644 --- a/crates/code_assistant_core/src/ui/ui_events.rs +++ b/crates/code_assistant_core/src/ui/ui_events.rs @@ -333,6 +333,13 @@ pub enum UiEvent { is_git_repo: bool, }, + // === Review Panel Events === + /// The Review panel's changed-files listing changed. Pure notification — + /// the data itself is mirrored into the UI layer's state by the sender. + UpdateReviewFiles, + /// A prepared file diff for the Review panel arrived (same mirror scheme). + UpdateReviewDiff, + // === Configuration Events === /// Configuration files (providers.json / models.json) were changed on disk. /// The UI should reload model lists, settings sections, etc. diff --git a/crates/git/Cargo.toml b/crates/git/Cargo.toml index 269d0a53..702200da 100644 --- a/crates/git/Cargo.toml +++ b/crates/git/Cargo.toml @@ -6,7 +6,7 @@ edition = "2024" [dependencies] anyhow = "1.0.95" serde = { version = "1.0.215", features = ["derive"] } -tokio = { version = "1.40.0", features = ["process"] } +tokio = { version = "1.40.0", features = ["fs", "process"] } tracing = "0.1.40" which = "7.0" diff --git a/crates/git/src/binary.rs b/crates/git/src/binary.rs index 10872412..e3aec00e 100644 --- a/crates/git/src/binary.rs +++ b/crates/git/src/binary.rs @@ -75,4 +75,39 @@ impl GitBinary { Ok(stdout) } + + /// Run a git command and return raw stdout bytes on success. + /// + /// Unlike [`run`](Self::run), this does not require the output to be valid + /// UTF-8 and does not strip any trailing newline. Use it for commands whose + /// output may be binary (e.g. `git show :` on a binary blob). + pub async fn run_bytes>( + &self, + working_dir: &Path, + args: &[S], + ) -> Result> { + let mut cmd = self.command(working_dir); + cmd.args(args); + + let output = cmd + .output() + .await + .context("Failed to execute git command")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let args_display: Vec<_> = args + .iter() + .map(|a| a.as_ref().to_string_lossy().to_string()) + .collect(); + bail!( + "git {} failed (exit {}): {}", + args_display.join(" "), + output.status.code().unwrap_or(-1), + stderr.trim() + ); + } + + Ok(output.stdout) + } } diff --git a/crates/git/src/diff.rs b/crates/git/src/diff.rs new file mode 100644 index 00000000..55c3fa6b --- /dev/null +++ b/crates/git/src/diff.rs @@ -0,0 +1,666 @@ +use crate::repository::GitRepository; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +/// Upper bound on the size (in bytes) of a single side of a diff we will load +/// into memory and hand to the UI. Larger blobs are reported as `too_large` +/// and their text is omitted. +const MAX_DIFF_BYTES: usize = 1_500_000; + +/// How a file changed relative to the comparison base. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ChangeStatus { + Added, + Modified, + Deleted, + Renamed, + Copied, + TypeChanged, + Untracked, +} + +/// A single changed file in a review listing. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChangedFile { + /// Repo-relative path of the file in its new/current location + /// (forward-slash separated). + pub path: String, + /// For renames/copies, the original repo-relative path; otherwise `None`. + pub orig_path: Option, + /// The kind of change. + pub status: ChangeStatus, +} + +/// Aggregate line-change counts for a review listing (à la `git diff --stat`). +/// +/// Untracked files are not included — `git diff --numstat` only covers +/// tracked content. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct DiffStats { + pub additions: usize, + pub deletions: usize, +} + +/// The two whole-file sides of a diff, ready to be fed to the UI's unified +/// diff renderer. Either side may be `None` (pure add or delete). When +/// `is_binary` or `too_large` is set, the text sides are omitted and the UI +/// should show a placeholder instead. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FileDiffContent { + /// The old (base) content, or `None` if the file was added. + pub old_text: Option, + /// The new (current) content, or `None` if the file was deleted. + pub new_text: Option, + /// True if either side is not valid UTF-8 (i.e. a binary blob). + pub is_binary: bool, + /// True if either side exceeds [`MAX_DIFF_BYTES`]. + pub too_large: bool, +} + +/// One side of a diff, before decoding. +enum Side { + /// The side does not exist (pure add or delete). + Absent, + /// The side exists but is larger than [`MAX_DIFF_BYTES`]. + TooLarge, + /// The raw bytes of the side. + Bytes(Vec), +} + +impl Side { + fn from_bytes(bytes: Vec) -> Self { + if bytes.len() > MAX_DIFF_BYTES { + Side::TooLarge + } else { + Side::Bytes(bytes) + } + } +} + +impl GitRepository { + /// List files that differ between the working tree (including staged + /// changes and untracked files) and `HEAD`. + pub async fn changed_files_working_tree(&self) -> Result> { + let out = self + .git + .run_bytes( + self.workdir(), + &[ + "-c", + "core.quotepath=false", + "status", + "--porcelain=v1", + "-z", + "--untracked-files=all", + ], + ) + .await?; + Ok(parse_status_z(&out)) + } + + /// List files that differ between `base` and `HEAD` using three-dot + /// (merge-base) semantics, matching what a pull request would show. + pub async fn changed_files_vs_base(&self, base: &str) -> Result> { + let range = format!("{base}...HEAD"); + let out = self + .git + .run_bytes( + self.workdir(), + &[ + "-c", + "core.quotepath=false", + "diff", + "--name-status", + "-M", + "-z", + &range, + ], + ) + .await?; + Ok(parse_diff_name_status_z(&out)) + } + + /// Load both sides of the diff for `file` in working-tree mode: + /// old = the `HEAD` version, new = the current working-tree file. + pub async fn file_diff_working_tree(&self, file: &ChangedFile) -> Result { + let old_ref_path = file.orig_path.as_deref().unwrap_or(&file.path); + let old = if matches!(file.status, ChangeStatus::Added | ChangeStatus::Untracked) { + Side::Absent + } else { + let bytes = self + .git + .run_bytes(self.workdir(), &["show", &format!("HEAD:{old_ref_path}")]) + .await + .with_context(|| format!("reading HEAD:{old_ref_path}"))?; + Side::from_bytes(bytes) + }; + + let new = if matches!(file.status, ChangeStatus::Deleted) { + Side::Absent + } else { + self.read_workdir_file(&file.path).await? + }; + + Ok(build_diff_content(old, new)) + } + + /// Load both sides of the diff for `file` in branch-vs-base mode: + /// old = the version at `merge-base(base, HEAD)`, new = the `HEAD` version. + pub async fn file_diff_vs_base( + &self, + base: &str, + file: &ChangedFile, + ) -> Result { + let merge_base = self + .git + .run(self.workdir(), &["merge-base", base, "HEAD"]) + .await + .with_context(|| format!("merge-base {base} HEAD"))?; + let merge_base = merge_base.trim(); + + let old_ref_path = file.orig_path.as_deref().unwrap_or(&file.path); + let old = if matches!(file.status, ChangeStatus::Added) { + Side::Absent + } else { + let bytes = self + .git + .run_bytes( + self.workdir(), + &["show", &format!("{merge_base}:{old_ref_path}")], + ) + .await + .with_context(|| format!("reading {merge_base}:{old_ref_path}"))?; + Side::from_bytes(bytes) + }; + + let new = if matches!(file.status, ChangeStatus::Deleted) { + Side::Absent + } else { + let bytes = self + .git + .run_bytes(self.workdir(), &["show", &format!("HEAD:{}", file.path)]) + .await + .with_context(|| format!("reading HEAD:{}", file.path))?; + Side::from_bytes(bytes) + }; + + Ok(build_diff_content(old, new)) + } + + /// Aggregate added/deleted line counts of the working tree (staged + + /// unstaged) vs `HEAD`. Untracked files are not counted. + pub async fn diff_stats_working_tree(&self) -> Result { + let out = self + .git + .run_bytes(self.workdir(), &["diff", "--numstat", "-M", "HEAD"]) + .await?; + Ok(parse_numstat(&out)) + } + + /// Aggregate added/deleted line counts of `base...HEAD` (merge-base + /// semantics, matching [`Self::changed_files_vs_base`]). + pub async fn diff_stats_vs_base(&self, base: &str) -> Result { + let range = format!("{base}...HEAD"); + let out = self + .git + .run_bytes(self.workdir(), &["diff", "--numstat", "-M", &range]) + .await?; + Ok(parse_numstat(&out)) + } + + /// Candidate base refs for branch-vs-base comparison: local branches plus + /// remote-tracking branches (excluding `*/HEAD`), sorted and de-duplicated. + pub fn list_base_candidates(&self) -> Result> { + let repo = self.repo.to_thread_local(); + let mut out = Vec::new(); + + for reference in repo.references()?.local_branches()? { + let reference = reference.map_err(|e| anyhow::anyhow!("{e}"))?; + let name = reference.name().shorten().to_string(); + if !name.is_empty() { + out.push(name); + } + } + + for reference in repo.references()?.remote_branches()? { + let reference = reference.map_err(|e| anyhow::anyhow!("{e}"))?; + let name = reference.name().shorten().to_string(); + if name.is_empty() || name.ends_with("/HEAD") { + continue; + } + out.push(name); + } + + out.sort(); + out.dedup(); + Ok(out) + } + + /// Read a working-tree file as a diff `Side`, mapping a missing file to + /// `Absent` and an oversized file to `TooLarge` (without reading it). + async fn read_workdir_file(&self, rel_path: &str) -> Result { + let full = self.workdir().join(rel_path); + match tokio::fs::metadata(&full).await { + Ok(meta) if meta.len() as usize > MAX_DIFF_BYTES => Ok(Side::TooLarge), + Ok(_) => match tokio::fs::read(&full).await { + Ok(bytes) => Ok(Side::from_bytes(bytes)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Side::Absent), + Err(e) => Err(e).with_context(|| format!("reading {}", full.display())), + }, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Side::Absent), + Err(e) => Err(e).with_context(|| format!("stat {}", full.display())), + } + } +} + +/// Classify a porcelain `XY` status pair into a single net `ChangeStatus`. +fn classify_status(x: char, y: char) -> ChangeStatus { + if x == '?' || y == '?' { + return ChangeStatus::Untracked; + } + let has = |c: char| x == c || y == c; + if has('R') { + ChangeStatus::Renamed + } else if has('C') { + ChangeStatus::Copied + } else if has('A') { + ChangeStatus::Added + } else if has('D') { + ChangeStatus::Deleted + } else if has('T') { + ChangeStatus::TypeChanged + } else { + ChangeStatus::Modified + } +} + +/// Parse the output of `git status --porcelain=v1 -z`. +/// +/// Records are NUL-terminated. Each record is `XYPATH`. For renames and +/// copies the `-z` format omits the ` -> ` and reverses the field order, so the +/// new path is in the record itself and the original path is the *next* +/// NUL-terminated field. +fn parse_status_z(bytes: &[u8]) -> Vec { + let mut files = Vec::new(); + let mut chunks = bytes.split(|&b| b == 0); + while let Some(chunk) = chunks.next() { + if chunk.len() < 4 { + // Empty trailing chunk or malformed record; skip. + continue; + } + let x = chunk[0] as char; + let y = chunk[1] as char; + // chunk[2] is the separating space; path starts at index 3. + let path = String::from_utf8_lossy(&chunk[3..]).into_owned(); + let status = classify_status(x, y); + let orig_path = if matches!(status, ChangeStatus::Renamed | ChangeStatus::Copied) { + chunks + .next() + .map(|c| String::from_utf8_lossy(c).into_owned()) + } else { + None + }; + files.push(ChangedFile { + path, + orig_path, + status, + }); + } + files +} + +/// Parse the output of `git diff --name-status -M -z`. +/// +/// Fields are NUL-terminated. A regular record is `STATUS`, then `PATH`. A +/// rename/copy record is `STATUS`, then the source path, then the destination +/// path. +fn parse_diff_name_status_z(bytes: &[u8]) -> Vec { + let mut files = Vec::new(); + let mut chunks = bytes.split(|&b| b == 0).filter(|c| !c.is_empty()); + while let Some(status_chunk) = chunks.next() { + let code = status_chunk[0] as char; + let status = match code { + 'A' => ChangeStatus::Added, + 'D' => ChangeStatus::Deleted, + 'R' => ChangeStatus::Renamed, + 'C' => ChangeStatus::Copied, + 'T' => ChangeStatus::TypeChanged, + _ => ChangeStatus::Modified, + }; + if matches!(status, ChangeStatus::Renamed | ChangeStatus::Copied) { + let Some(old) = chunks.next() else { break }; + let Some(new) = chunks.next() else { break }; + files.push(ChangedFile { + path: String::from_utf8_lossy(new).into_owned(), + orig_path: Some(String::from_utf8_lossy(old).into_owned()), + status, + }); + } else { + let Some(path) = chunks.next() else { break }; + files.push(ChangedFile { + path: String::from_utf8_lossy(path).into_owned(), + orig_path: None, + status, + }); + } + } + files +} + +/// Parse `git diff --numstat` output and sum the per-file counts. +/// +/// Each line is `ADDEDDELETEDPATH`; binary files report `-` in the +/// numeric columns and are skipped. +fn parse_numstat(bytes: &[u8]) -> DiffStats { + let mut stats = DiffStats::default(); + for line in bytes.split(|&b| b == b'\n') { + let mut fields = line.split(|&b| b == b'\t'); + let (Some(add), Some(del)) = (fields.next(), fields.next()) else { + continue; + }; + let parse = |f: &[u8]| std::str::from_utf8(f).ok()?.parse::().ok(); + if let (Some(add), Some(del)) = (parse(add), parse(del)) { + stats.additions += add; + stats.deletions += del; + } + } + stats +} + +/// Decode both raw sides into a [`FileDiffContent`], flagging binary and +/// oversized content. +fn build_diff_content(old: Side, new: Side) -> FileDiffContent { + let mut is_binary = false; + let mut too_large = false; + + let mut decode = |side: Side| -> Option { + match side { + Side::Absent => None, + Side::TooLarge => { + too_large = true; + None + } + Side::Bytes(bytes) => match String::from_utf8(bytes) { + Ok(text) => Some(text), + Err(_) => { + is_binary = true; + None + } + }, + } + }; + + let old_text = decode(old); + let new_text = decode(new); + + FileDiffContent { + old_text, + new_text, + is_binary, + too_large, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::init_repo_with_commit; + use std::path::Path; + use tempfile::TempDir; + + /// Run a git command synchronously for test setup (staging, committing). + 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 write(dir: &Path, rel: &str, content: &[u8]) { + let full = dir.join(rel); + if let Some(parent) = full.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(full, content).unwrap(); + } + + fn find<'a>(files: &'a [ChangedFile], path: &str) -> &'a ChangedFile { + files + .iter() + .find(|f| f.path == path) + .unwrap_or_else(|| panic!("no changed file with path {path} in {files:?}")) + } + + #[test] + fn parse_status_handles_rename_pair() { + // Rename record: new path in-record, old path in following field. + let raw = b"R new_name.txt\0old_name.txt\0M other.txt\0"; + let files = parse_status_z(raw); + assert_eq!(files.len(), 2); + assert_eq!(files[0].status, ChangeStatus::Renamed); + assert_eq!(files[0].path, "new_name.txt"); + assert_eq!(files[0].orig_path.as_deref(), Some("old_name.txt")); + assert_eq!(files[1].status, ChangeStatus::Modified); + assert_eq!(files[1].path, "other.txt"); + assert_eq!(files[1].orig_path, None); + } + + #[test] + fn parse_status_handles_untracked_and_paths_with_spaces() { + let raw = b"?? a file.txt\0 M tracked.rs\0"; + let files = parse_status_z(raw); + assert_eq!(files.len(), 2); + assert_eq!(files[0].status, ChangeStatus::Untracked); + assert_eq!(files[0].path, "a file.txt"); + assert_eq!(files[1].status, ChangeStatus::Modified); + assert_eq!(files[1].path, "tracked.rs"); + } + + #[test] + fn parse_diff_name_status_handles_rename() { + let raw = b"M\0a.txt\0R100\0old.txt\0new.txt\0A\0added.txt\0"; + let files = parse_diff_name_status_z(raw); + assert_eq!(files.len(), 3); + assert_eq!(files[0].status, ChangeStatus::Modified); + assert_eq!(files[0].path, "a.txt"); + assert_eq!(files[1].status, ChangeStatus::Renamed); + assert_eq!(files[1].orig_path.as_deref(), Some("old.txt")); + assert_eq!(files[1].path, "new.txt"); + assert_eq!(files[2].status, ChangeStatus::Added); + assert_eq!(files[2].path, "added.txt"); + } + + #[test] + fn parse_numstat_sums_and_skips_binary() { + let raw = b"3\t1\tsrc/a.rs\n-\t-\tblob.bin\n10\t0\tnew.txt\n"; + let stats = parse_numstat(raw); + assert_eq!( + stats, + DiffStats { + additions: 13, + deletions: 1 + } + ); + assert_eq!(parse_numstat(b""), DiffStats::default()); + } + + #[tokio::test] + async fn diff_stats_working_tree_counts_lines() { + let dir = TempDir::new().unwrap(); + init_repo_with_commit(dir.path()); + + write(dir.path(), "a.txt", b"one\ntwo\nthree\n"); + git(dir.path(), &["add", "."]); + git(dir.path(), &["commit", "-m", "seed"]); + + // Replace one line and add one (net: +2 -1). + write(dir.path(), "a.txt", b"one\nTWO\nthree\nfour\n"); + + let repo = GitRepository::open(dir.path()).unwrap(); + let stats = repo.diff_stats_working_tree().await.unwrap(); + assert_eq!( + stats, + DiffStats { + additions: 2, + deletions: 1 + } + ); + } + + #[tokio::test] + async fn working_tree_add_modify_delete_untracked() { + let dir = TempDir::new().unwrap(); + init_repo_with_commit(dir.path()); + + // Seed two tracked files and commit them. + write(dir.path(), "keep.txt", b"one\ntwo\n"); + write(dir.path(), "gone.txt", b"delete me\n"); + git(dir.path(), &["add", "."]); + git(dir.path(), &["commit", "-m", "seed"]); + + // Modify keep.txt, delete gone.txt, add an untracked new.txt. + write(dir.path(), "keep.txt", b"one\nchanged\n"); + std::fs::remove_file(dir.path().join("gone.txt")).unwrap(); + write(dir.path(), "new.txt", b"brand new\n"); + + let repo = GitRepository::open(dir.path()).unwrap(); + let files = repo.changed_files_working_tree().await.unwrap(); + + assert_eq!(find(&files, "keep.txt").status, ChangeStatus::Modified); + assert_eq!(find(&files, "gone.txt").status, ChangeStatus::Deleted); + assert_eq!(find(&files, "new.txt").status, ChangeStatus::Untracked); + + // Modified: both sides present. + let d = repo + .file_diff_working_tree(find(&files, "keep.txt")) + .await + .unwrap(); + assert_eq!(d.old_text.as_deref(), Some("one\ntwo\n")); + assert_eq!(d.new_text.as_deref(), Some("one\nchanged\n")); + assert!(!d.is_binary && !d.too_large); + + // Deleted: no new side. + let d = repo + .file_diff_working_tree(find(&files, "gone.txt")) + .await + .unwrap(); + assert_eq!(d.old_text.as_deref(), Some("delete me\n")); + assert_eq!(d.new_text, None); + + // Untracked: no old side. + let d = repo + .file_diff_working_tree(find(&files, "new.txt")) + .await + .unwrap(); + assert_eq!(d.old_text, None); + assert_eq!(d.new_text.as_deref(), Some("brand new\n")); + } + + #[tokio::test] + async fn working_tree_rename() { + let dir = TempDir::new().unwrap(); + init_repo_with_commit(dir.path()); + + write(dir.path(), "original.txt", b"stable content\n"); + git(dir.path(), &["add", "."]); + git(dir.path(), &["commit", "-m", "seed"]); + + // Stage a rename so git detects it as R. + git(dir.path(), &["mv", "original.txt", "renamed.txt"]); + + let repo = GitRepository::open(dir.path()).unwrap(); + let files = repo.changed_files_working_tree().await.unwrap(); + + let renamed = find(&files, "renamed.txt"); + assert_eq!(renamed.status, ChangeStatus::Renamed); + assert_eq!(renamed.orig_path.as_deref(), Some("original.txt")); + + let d = repo.file_diff_working_tree(renamed).await.unwrap(); + assert_eq!(d.old_text.as_deref(), Some("stable content\n")); + assert_eq!(d.new_text.as_deref(), Some("stable content\n")); + } + + #[tokio::test] + async fn working_tree_binary_and_too_large() { + let dir = TempDir::new().unwrap(); + init_repo_with_commit(dir.path()); + + // Binary file (invalid UTF-8 bytes). + write(dir.path(), "blob.bin", &[0u8, 159, 146, 150, 255]); + // Oversized text file. + let big = vec![b'a'; MAX_DIFF_BYTES + 10]; + write(dir.path(), "big.txt", &big); + + let repo = GitRepository::open(dir.path()).unwrap(); + let files = repo.changed_files_working_tree().await.unwrap(); + + let d = repo + .file_diff_working_tree(find(&files, "blob.bin")) + .await + .unwrap(); + assert!(d.is_binary); + assert_eq!(d.new_text, None); + + let d = repo + .file_diff_working_tree(find(&files, "big.txt")) + .await + .unwrap(); + assert!(d.too_large); + assert_eq!(d.new_text, None); + } + + #[tokio::test] + async fn branch_vs_base_three_dot() { + let dir = TempDir::new().unwrap(); + init_repo_with_commit(dir.path()); + + // Base commit on the default branch. + write(dir.path(), "shared.txt", b"base line\n"); + git(dir.path(), &["add", "."]); + git(dir.path(), &["commit", "-m", "base"]); + let base_branch = { + let repo = GitRepository::open(dir.path()).unwrap(); + repo.current_branch().unwrap() + }; + + // Diverge onto a feature branch: modify shared, add a file. + git(dir.path(), &["checkout", "-b", "feature"]); + write(dir.path(), "shared.txt", b"feature line\n"); + write(dir.path(), "feature_only.txt", b"new on feature\n"); + git(dir.path(), &["add", "."]); + git(dir.path(), &["commit", "-m", "feature work"]); + + let repo = GitRepository::open(dir.path()).unwrap(); + let files = repo.changed_files_vs_base(&base_branch).await.unwrap(); + + assert_eq!(find(&files, "shared.txt").status, ChangeStatus::Modified); + assert_eq!(find(&files, "feature_only.txt").status, ChangeStatus::Added); + + let d = repo + .file_diff_vs_base(&base_branch, find(&files, "shared.txt")) + .await + .unwrap(); + assert_eq!(d.old_text.as_deref(), Some("base line\n")); + assert_eq!(d.new_text.as_deref(), Some("feature line\n")); + + let d = repo + .file_diff_vs_base(&base_branch, find(&files, "feature_only.txt")) + .await + .unwrap(); + assert_eq!(d.old_text, None); + assert_eq!(d.new_text.as_deref(), Some("new on feature\n")); + } + + #[tokio::test] + async fn base_candidates_include_branches() { + let dir = TempDir::new().unwrap(); + init_repo_with_commit(dir.path()); + git(dir.path(), &["checkout", "-b", "extra-branch"]); + + let repo = GitRepository::open(dir.path()).unwrap(); + let candidates = repo.list_base_candidates().unwrap(); + assert!(candidates.iter().any(|c| c == "extra-branch")); + } +} diff --git a/crates/git/src/lib.rs b/crates/git/src/lib.rs index 54a3cbc5..54abc87e 100644 --- a/crates/git/src/lib.rs +++ b/crates/git/src/lib.rs @@ -1,11 +1,13 @@ mod binary; mod branch; +mod diff; mod repository; mod types; pub mod worktree; pub use binary::GitBinary; pub use branch::BranchNotMerged; +pub use diff::*; pub use repository::GitRepository; pub use types::*; diff --git a/crates/ui_acp/src/ui.rs b/crates/ui_acp/src/ui.rs index 20345378..04bdbc5d 100644 --- a/crates/ui_acp/src/ui.rs +++ b/crates/ui_acp/src/ui.rs @@ -973,6 +973,9 @@ impl UserInterface for ACPUserUI { UiEvent::UpdateWorktreeData { .. } => { // Worktree management not supported in ACP UI } + UiEvent::UpdateReviewFiles | UiEvent::UpdateReviewDiff => { + // Review panel is GPUI-specific. + } UiEvent::UpdateAllowedModels { .. } => { // Model dropdown filtering is GPUI-specific. } diff --git a/crates/ui_gpui/Cargo.toml b/crates/ui_gpui/Cargo.toml index 18b68819..4af05aac 100644 --- a/crates/ui_gpui/Cargo.toml +++ b/crates/ui_gpui/Cargo.toml @@ -37,7 +37,7 @@ dirs = "5.0" open = "5" # Diff visualization -similar = { version = "2.7.0", features = ["inline"] } +similar = { version = "2.7.0", features = ["inline", "unicode"] } # Image handling (pasted/attached images) image = "0.25" diff --git a/crates/ui_gpui/src/app/commands.rs b/crates/ui_gpui/src/app/commands.rs index 320b0a82..d8f7da5f 100644 --- a/crates/ui_gpui/src/app/commands.rs +++ b/crates/ui_gpui/src/app/commands.rs @@ -614,6 +614,156 @@ impl Gpui { }); } + // ======================================================================== + // Review panel + // ======================================================================== + + /// Refresh the Review panel: discover repos immediately (showing cached + /// results where available), then scan them one at a time in the + /// background, streaming a listing update after every state change. + pub(crate) fn cmd_list_review_files( + &self, + session_id: String, + mode: code_assistant_core::session::ReviewMode, + base_overrides: std::collections::HashMap, + ) { + use code_assistant_core::session::{RepoReview, ReviewScanState}; + use std::sync::atomic::Ordering; + + let Some(service) = self.session_service() else { + return; + }; + let gpui = self.clone(); + // Supersede any scan still streaming for an older request. + let epoch = self.review_scan_epoch.fetch_add(1, Ordering::SeqCst) + 1; + + self.dispatch(async move { + // Mirror the listing into the Gpui global and notify the UI. The + // event itself carries no data (see UiEvent::UpdateReviewFiles). + let push = |repos: &[RepoReview], is_git_repo: bool| { + if gpui.review_scan_epoch.load(Ordering::SeqCst) == epoch + && gpui.is_current_session(&session_id) + { + let repos = repos + .iter() + .map(|r| crate::RepoReviewData { + repo_root: r.repo_root.clone(), + label: r.label.clone(), + current_branch: r.current_branch.clone(), + base_candidates: r.base_candidates.clone(), + base: r.base.clone(), + files: r.files.clone(), + stats: r.stats, + scan_state: r.scan_state, + }) + .collect(); + gpui.set_current_review_listing(Some(crate::ReviewData { + repos, + is_git_repo, + mode, + })); + gpui.push_event(UiEvent::UpdateReviewFiles); + } + }; + + // Phase 1: fast discovery — show every repo right away, seeded + // from the on-disk cache where a previous scan exists. + let discovered = match service.list_review_repos(session_id.clone()).await { + Ok(d) => d, + Err(e) => { + debug!("Failed to discover review repos: {e:#}"); + return; + } + }; + let is_git_repo = !discovered.is_empty(); + let mut repos: Vec = discovered + .into_iter() + .map(|(root, label)| { + crate::shared::review_cache::load(&root, &label, mode) + .unwrap_or_else(|| RepoReview::pending(root, label)) + }) + .collect(); + push(&repos, is_git_repo); + + // Phase 2: scan repo by repo, streaming each result. + for ix in 0..repos.len() { + if gpui.review_scan_epoch.load(Ordering::SeqCst) != epoch { + return; + } + repos[ix].scan_state = ReviewScanState::Scanning; + push(&repos, is_git_repo); + + let root = repos[ix].repo_root.clone(); + let label = repos[ix].label.clone(); + let base_override = base_overrides.get(&root).cloned(); + match service + .scan_review_repo(root, label, mode, base_override) + .await + { + Ok(review) => { + crate::shared::review_cache::store(&review, mode); + repos[ix] = review; + } + Err(e) => { + debug!( + "Failed to scan review repo {}: {e:#}", + repos[ix].repo_root.display() + ); + // Keep the cached/empty data; just stop indicating. + repos[ix].scan_state = ReviewScanState::Done; + } + } + push(&repos, is_git_repo); + } + }); + } + + /// Fetch the diff for a single file selected in the Review panel. + pub(crate) fn cmd_get_review_file_diff( + &self, + session_id: String, + repo_root: PathBuf, + mode: code_assistant_core::session::ReviewMode, + base: Option, + file: git::ChangedFile, + ) { + let Some(service) = self.session_service() else { + return; + }; + let gpui = self.clone(); + let path = file.path.clone(); + let event_repo_root = repo_root.clone(); + self.dispatch(async move { + 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). + let prepared = match &result { + Ok(diff) => crate::PreparedReviewDiff::from_content(diff), + // 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(&git::FileDiffContent { + old_text: None, + new_text: None, + is_binary: false, + too_large: false, + }), + }; + if let Err(e) = &result { + gpui.display_error(format!("Failed to load diff: {e:#}")); + } + if gpui.is_current_session(&session_id) { + gpui.set_current_review_diff(Some(crate::ReviewDiff { + repo_root: event_repo_root, + path, + prepared, + })); + gpui.push_event(UiEvent::UpdateReviewDiff); + } + }); + } + // ======================================================================== // Projects // ======================================================================== diff --git a/crates/ui_gpui/src/app/event_loop.rs b/crates/ui_gpui/src/app/event_loop.rs index 8546c0b9..84502d2d 100644 --- a/crates/ui_gpui/src/app/event_loop.rs +++ b/crates/ui_gpui/src/app/event_loop.rs @@ -842,6 +842,12 @@ impl Gpui { cx.refresh(); } + // Pure notifications — the command layer already mirrored the + // review data (listing / prepared diff) into the Gpui globals. + UiEvent::UpdateReviewFiles | UiEvent::UpdateReviewDiff => { + cx.refresh(); + } + 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/lib.rs b/crates/ui_gpui/src/lib.rs index cc259d80..4ef33e7d 100644 --- a/crates/ui_gpui/src/lib.rs +++ b/crates/ui_gpui/src/lib.rs @@ -46,6 +46,42 @@ pub struct UiSettingsGlobal(pub shared::settings::UiSettings); impl Global for UiSettingsGlobal {} +/// Delay after the last [`update_ui_settings`] call before writing to disk. +const UI_SETTINGS_SAVE_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(500); + +/// Pending debounced settings-save task. Replacing it drops (and thereby +/// cancels) the previous timer, so rapid updates coalesce into a single write. +struct UiSettingsSaveTask(#[allow(dead_code)] gpui::Task<()>); + +impl Global for UiSettingsSaveTask {} + +/// Mutate the global [`UiSettings`] and persist to disk, debounced. +/// +/// The disk snapshot is taken when the debounce timer fires — not at call time +/// — so concurrent writes cannot land out of order and clobber a newer state. +/// A quit within the debounce window is covered by the `on_app_quit` flush. +/// +/// Callable anywhere with access to `&mut App` (including via `Context` deref), +/// so both `MainScreen` handlers and `ReviewView` resize callbacks can use it. +pub fn update_ui_settings(cx: &mut App, f: impl FnOnce(&mut shared::settings::UiSettings)) { + if !cx.has_global::() { + return; + } + f(&mut cx.global_mut::().0); + + let task = cx.spawn(async move |cx: &mut AsyncApp| { + cx.background_executor() + .timer(UI_SETTINGS_SAVE_DEBOUNCE) + .await; + let settings = cx.update(|cx| cx.global::().0.clone()); + cx.background_spawn(async move { + settings.save(); + }) + .await; + }); + cx.set_global(UiSettingsSaveTask(task)); +} + /// Snapshot of worktree/branch data for the active session, kept in `Gpui` /// so that `RootView::render()` can push it into the `WorktreeSelector`. #[derive(Debug, Clone, PartialEq)] @@ -55,6 +91,102 @@ pub struct WorktreeData { pub is_git_repo: bool, } +/// Latest changed-files listing for the Review panel, mirrored from the +/// backend into a global the `ReviewView` reads during render. +#[derive(Debug, Clone, PartialEq)] +pub struct ReviewData { + pub repos: Vec, + pub is_git_repo: bool, + pub mode: code_assistant_core::session::ReviewMode, +} + +/// Per-repo changed-files data within a [`ReviewData`] listing. +#[derive(Debug, Clone, PartialEq)] +pub struct RepoReviewData { + pub repo_root: std::path::PathBuf, + pub label: String, + pub current_branch: Option, + pub base_candidates: Vec, + pub base: Option, + pub files: Vec, + /// Aggregate added/deleted line counts (untracked files not included). + pub stats: git::DiffStats, + /// Background-scan progress for this repo (drives the activity indicator). + pub scan_state: code_assistant_core::session::ReviewScanState, +} + +/// Latest loaded diff for a single file in the Review panel, already prepared +/// for rendering (see [`PreparedReviewDiff`]). +#[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; + +/// 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. +#[derive(Debug, Clone)] +pub struct PreparedReviewDiff { + pub is_binary: bool, + pub too_large: bool, + pub hunks: Vec, + 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(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(), + 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, + hunks, + additions, + deletions, + } + } +} + // Our main UI struct that implements the UserInterface trait #[derive(Clone)] pub struct Gpui { @@ -112,6 +244,18 @@ pub struct Gpui { // Current worktree state (branches + worktrees listing from backend) current_worktree_data: Arc>>, + // Review panel state (changed-files listing + selected-file diff) mirrored + // from the backend for the ReviewView to read during render. The u64 is a + // generation counter bumped on every write so the view can detect changes + // with a plain integer compare instead of deep-cloning per frame. + current_review_listing: Arc)>>, + current_review_diff: Arc)>>, + + // Monotonic id of the latest review-scan request. A streaming scan task + // compares its captured epoch against this and stops when superseded, so + // stale scans never overwrite fresher listings. + review_scan_epoch: Arc, + // Last usage from the active session's most recent assistant message. // Stored separately from chat_sessions so it cannot be overwritten by // stale metadata loaded from disk (via UpdateChatList / ListSessions). @@ -340,6 +484,8 @@ impl Gpui { self.current_mcp_servers.lock().unwrap().clear(); self.pending_permission_requests.lock().unwrap().clear(); *self.current_worktree_data.lock().unwrap() = None; + self.set_current_review_listing(None); + self.set_current_review_diff(None); *self.current_session_last_usage.lock().unwrap() = None; *self.current_session_total_usage.lock().unwrap() = None; } @@ -439,6 +585,9 @@ impl Gpui { // Current worktree state current_worktree_data: Arc::new(Mutex::new(None)), + current_review_listing: Arc::new(Mutex::new((0, None))), + current_review_diff: Arc::new(Mutex::new((0, None))), + review_scan_epoch: Arc::new(std::sync::atomic::AtomicU64::new(0)), // Current session last usage current_session_last_usage: Arc::new(Mutex::new(None)), @@ -526,6 +675,25 @@ impl Gpui { // Store settings as a GPUI global so entities can access/update them cx.set_global(UiSettingsGlobal(ui_settings.clone())); + // Flush pending debounced writes (UI settings + per-session UI + // state) before the process exits, closing the debounce window. + cx.on_app_quit(|cx| { + let settings = cx + .try_global::() + .map(|global| global.0.clone()); + let files = shared::ui_state::UiStateStore::try_global() + .and_then(|store| store.lock().ok()) + .map(|mut store| store.take_dirty()) + .unwrap_or_default(); + async move { + if let Some(settings) = settings { + settings.save(); + } + shared::ui_state::write_ui_state_files(files); + } + }) + .detach(); + init(cx); // Spawn task to receive UiEvents @@ -745,6 +913,32 @@ impl Gpui { self.current_worktree_data.lock().unwrap().clone() } + /// Return the review listing and its generation, but only if it changed + /// since `seen`. The unchanged case — the per-frame hot path — costs a + /// mutex lock and an integer compare; the data is only cloned on change. + pub fn review_listing_if_newer(&self, seen: u64) -> Option<(u64, Option)> { + let slot = self.current_review_listing.lock().unwrap(); + (slot.0 != seen).then(|| (slot.0, slot.1.clone())) + } + + pub fn set_current_review_listing(&self, data: Option) { + let mut slot = self.current_review_listing.lock().unwrap(); + slot.0 = slot.0.wrapping_add(1); + slot.1 = data; + } + + /// Generation-gated access to the review diff; see [`Self::review_listing_if_newer`]. + pub fn review_diff_if_newer(&self, seen: u64) -> Option<(u64, Option)> { + let slot = self.current_review_diff.lock().unwrap(); + (slot.0 != seen).then(|| (slot.0, slot.1.clone())) + } + + pub fn set_current_review_diff(&self, diff: Option) { + let mut slot = self.current_review_diff.lock().unwrap(); + slot.0 = slot.0.wrapping_add(1); + slot.1 = diff; + } + pub fn get_current_session_last_usage(&self) -> Option { self.current_session_last_usage.lock().unwrap().clone() } diff --git a/crates/ui_gpui/src/main_screen/mod.rs b/crates/ui_gpui/src/main_screen/mod.rs index 2e0f01a2..f1450650 100644 --- a/crates/ui_gpui/src/main_screen/mod.rs +++ b/crates/ui_gpui/src/main_screen/mod.rs @@ -1,5 +1,6 @@ mod about_dialog; pub mod project_dialog; +pub mod right_panel; mod status_popover; use crate::sidebar::{SessionSidebar, SessionSidebarEvent}; @@ -37,6 +38,9 @@ use tracing::{debug, error, warn}; const SIDEBAR_ANIMATION_DURATION_MS: f32 = 250.0; const SIDEBAR_ANIMATION_FRAME_MS: u64 = 8; // ~120 FPS +/// Fixed width of the right (review) sidebar when fully expanded. +const RIGHT_SIDEBAR_WIDTH: f32 = 440.0; + /// Return the argument text for a syntactically separate `/goal` command. /// Prefixes such as `/goals` remain ordinary input. fn goal_command_args(input: &str) -> Option<&str> { @@ -62,6 +66,103 @@ enum SidebarAnimationState { }, } +/// Drives the width animation for one collapsible sidebar. Multiple animators +/// can share a single ticking task on [`MainScreen`]; each tracks its own +/// easing state independently. +#[derive(Clone, Debug)] +struct SidebarAnimator { + state: SidebarAnimationState, +} + +impl SidebarAnimator { + fn new() -> Self { + Self { + state: SidebarAnimationState::Idle, + } + } + + /// Begin animating towards expanded (`true`) or collapsed (`false`). + /// Reverses smoothly if an animation is already in flight. + fn start(&mut self, should_expand: bool) { + let target = if should_expand { 1.0 } else { 0.0 }; + let now = Instant::now(); + + match &self.state { + SidebarAnimationState::Animating { + width_scale, + target: current_target, + .. + } if *current_target != target => { + // Reverse mid-animation: keep current scale, adjust start for smooth transition + let current_progress = if target == 1.0 { + *width_scale + } else { + 1.0 - *width_scale + }; + let adjusted_start = now + - Duration::from_millis( + (current_progress * SIDEBAR_ANIMATION_DURATION_MS) as u64, + ); + self.state = SidebarAnimationState::Animating { + width_scale: *width_scale, + target, + start_time: adjusted_start, + }; + } + _ => { + let initial = if should_expand { 0.0 } else { 1.0 }; + self.state = SidebarAnimationState::Animating { + width_scale: initial, + target, + start_time: now, + }; + } + } + } + + /// Advance one frame. Returns `true` while still animating. + fn tick(&mut self) -> bool { + match &mut self.state { + SidebarAnimationState::Animating { + width_scale, + target, + start_time, + } => { + let elapsed = start_time.elapsed().as_millis() as f32; + let progress = (elapsed / SIDEBAR_ANIMATION_DURATION_MS).min(1.0); + // ease-out cubic + let eased = 1.0 - (1.0 - progress).powi(3); + + *width_scale = if *target == 1.0 { eased } else { 1.0 - eased }; + + if progress >= 1.0 { + *width_scale = *target; + self.state = SidebarAnimationState::Idle; + false + } else { + true + } + } + SidebarAnimationState::Idle => false, + } + } + + /// Current animation scale: 0.0 = fully collapsed, 1.0 = fully expanded. + /// `collapsed` supplies the resting value when no animation is running. + fn scale(&self, collapsed: bool) -> f32 { + match &self.state { + SidebarAnimationState::Animating { width_scale, .. } => *width_scale, + SidebarAnimationState::Idle => { + if collapsed { + 0.0 + } else { + 1.0 + } + } + } + } +} + /// Events emitted by MainScreen to its parent (the AppShell/RootView). #[derive(Clone, Debug)] pub enum MainScreenEvent { @@ -94,13 +195,27 @@ pub struct MainScreen { /// Pending folder path from the file picker, waiting to create the dialog in render pending_project_path: Option, + // Right (review) sidebar state + right_sidebar_collapsed: bool, + right_panel: Entity, + /// Session id last pushed into the right panel (change detection). + right_panel_session_id: Option, + // Sidebar animation - sidebar_animation_state: SidebarAnimationState, + left_animator: SidebarAnimator, + right_animator: SidebarAnimator, sidebar_content_width: Rc>, sidebar_animation_task: Option>, /// UI zoom scale factor (1.0 = 100%, multiplied with the base font size) ui_scale: f32, + /// Current (persisted) width of the right review sidebar when expanded. + right_sidebar_width: Pixels, + /// Whether the user is currently dragging the sidebar's resize handle. + right_sidebar_resizing: bool, + /// Drag anchor: pointer x and sidebar width captured at mouse-down. + resize_start_x: f32, + resize_start_width: f32, /// Cached context token limit for the current model (model_name, limit). /// Reloaded when the model changes. context_limit_cache: Option<(String, u32)>, @@ -144,10 +259,19 @@ impl MainScreen { .map(|s| s.0.ui_scale) .unwrap_or(1.0); + // Restore the persisted right-sidebar width (default fallback). + let initial_sidebar_width = cx + .try_global::() + .and_then(|s| s.0.right_sidebar_width) + .unwrap_or(RIGHT_SIDEBAR_WIDTH); + // Watch for window move / resize so we can persist bounds. let window_bounds_subscription = cx.observe_window_bounds(window, Self::on_window_bounds_changed); + // Create the right (review) sidebar panel. + let right_panel = cx.new(|cx| right_panel::RightPanel::new(window, cx)); + let mut root_view = Self { input_area, project_sidebar, @@ -166,11 +290,20 @@ impl MainScreen { about_dialog: None, pending_project_path: None, - sidebar_animation_state: SidebarAnimationState::Idle, + right_sidebar_collapsed: true, // Review sidebar hidden by default + right_panel, + right_panel_session_id: None, + + left_animator: SidebarAnimator::new(), + right_animator: SidebarAnimator::new(), sidebar_content_width: Rc::new(Cell::new(px(0.0))), sidebar_animation_task: None, ui_scale: initial_scale, + right_sidebar_width: px(initial_sidebar_width), + right_sidebar_resizing: false, + resize_start_x: 0.0, + resize_start_width: 0.0, context_limit_cache: None, _input_area_subscription: input_area_subscription, _plan_banner_subscription: plan_banner_subscription, @@ -194,10 +327,50 @@ impl MainScreen { ) { let should_expand = self.sidebar_collapsed; self.sidebar_collapsed = !self.sidebar_collapsed; - self.start_sidebar_animation(should_expand, cx); + self.left_animator.start(should_expand); + self.ensure_sidebar_animation_task(cx); cx.notify(); } + pub fn on_toggle_right_sidebar( + &mut self, + _: &ClickEvent, + _window: &mut gpui::Window, + cx: &mut Context, + ) { + let should_expand = self.right_sidebar_collapsed; + self.right_sidebar_collapsed = !self.right_sidebar_collapsed; + self.right_animator.start(should_expand); + self.ensure_sidebar_animation_task(cx); + + // When opening, make sure the panel reflects the current session and + // has fresh data. + if should_expand { + let session_id = self.current_session_id.clone(); + self.right_panel.update(cx, |panel, cx| { + panel.set_session(session_id, cx); + }); + } + + // Persist the open/closed state for the active session. + if let Some(session_id) = &self.current_session_id { + if let Ok(mut store) = crate::shared::ui_state::UiStateStore::global().lock() { + store.set_right_panel_open(session_id, !self.right_sidebar_collapsed); + } + if let Some(sender) = cx.try_global::() { + let _ = sender.0.try_send(UiEvent::PersistUiState); + } + } + + cx.notify(); + } + + fn ensure_sidebar_animation_task(&mut self, cx: &mut Context) { + if self.sidebar_animation_task.is_none() { + self.start_sidebar_animation_task(cx); + } + } + fn on_open_settings( &mut self, _: &ClickEvent, @@ -233,47 +406,8 @@ impl MainScreen { // ── Sidebar animation ───────────────────────────────────────────────── - fn start_sidebar_animation(&mut self, should_expand: bool, cx: &mut Context) { - let target = if should_expand { 1.0 } else { 0.0 }; - let now = Instant::now(); - - match &self.sidebar_animation_state { - SidebarAnimationState::Animating { - width_scale, - target: current_target, - .. - } if *current_target != target => { - // Reverse mid-animation: keep current scale, adjust start for smooth transition - let current_progress = if target == 1.0 { - *width_scale - } else { - 1.0 - *width_scale - }; - let adjusted_start = now - - Duration::from_millis( - (current_progress * SIDEBAR_ANIMATION_DURATION_MS) as u64, - ); - self.sidebar_animation_state = SidebarAnimationState::Animating { - width_scale: *width_scale, - target, - start_time: adjusted_start, - }; - } - _ => { - let initial = if should_expand { 0.0 } else { 1.0 }; - self.sidebar_animation_state = SidebarAnimationState::Animating { - width_scale: initial, - target, - start_time: now, - }; - } - } - - if self.sidebar_animation_task.is_none() { - self.start_sidebar_animation_task(cx); - } - } - + /// Ticking task shared by both sidebar animators. Runs until neither the + /// left nor the right animator is still animating. fn start_sidebar_animation_task(&mut self, cx: &mut Context) { let task = cx.spawn(async move |weak_entity, async_cx| { loop { @@ -283,14 +417,13 @@ impl MainScreen { .await; let should_continue = weak_entity.update(async_cx, |view, cx| { - view.update_sidebar_animation(); - match &view.sidebar_animation_state { - SidebarAnimationState::Idle => false, - _ => { - cx.notify(); - true - } + let left = view.left_animator.tick(); + let right = view.right_animator.tick(); + let cont = left || right; + if cont { + cx.notify(); } + cont }); if let Ok(should_continue) = should_continue { @@ -308,41 +441,14 @@ impl MainScreen { self.sidebar_animation_task = Some(task); } - fn update_sidebar_animation(&mut self) { - match &mut self.sidebar_animation_state { - SidebarAnimationState::Animating { - width_scale, - target, - start_time, - } => { - let elapsed = start_time.elapsed().as_millis() as f32; - let progress = (elapsed / SIDEBAR_ANIMATION_DURATION_MS).min(1.0); - // ease-out cubic - let eased = 1.0 - (1.0 - progress).powi(3); - - *width_scale = if *target == 1.0 { eased } else { 1.0 - eased }; - - if progress >= 1.0 { - *width_scale = *target; - self.sidebar_animation_state = SidebarAnimationState::Idle; - } - } - SidebarAnimationState::Idle => {} - } + /// Current left (project) sidebar animation scale. + fn sidebar_animation_scale(&self) -> f32 { + self.left_animator.scale(self.sidebar_collapsed) } - /// Current sidebar animation scale: 0.0 = fully collapsed, 1.0 = fully expanded - fn sidebar_animation_scale(&self) -> f32 { - match &self.sidebar_animation_state { - SidebarAnimationState::Animating { width_scale, .. } => *width_scale, - SidebarAnimationState::Idle => { - if self.sidebar_collapsed { - 0.0 - } else { - 1.0 - } - } - } + /// Current right (review) sidebar animation scale. + fn right_sidebar_animation_scale(&self) -> f32 { + self.right_animator.scale(self.right_sidebar_collapsed) } fn on_plan_banner_event( @@ -446,15 +552,7 @@ impl MainScreen { /// Update the global [`UiSettings`], persist to disk on a background thread. fn update_settings(cx: &mut Context, f: impl FnOnce(&mut settings::UiSettings)) { - if cx.has_global::() { - let global = cx.global_mut::(); - f(&mut global.0); - let settings = global.0.clone(); - cx.background_spawn(async move { - settings.save(); - }) - .detach(); - } + crate::update_ui_settings(cx, f); } /// Called when the window is moved or resized. @@ -1121,9 +1219,11 @@ impl MainScreen { ("".to_string(), Vec::new(), None) }; - // Clear worktree data while we hold the ref + // Clear worktree + review data while we hold the ref let gpui_handle = if let Some(gpui) = &gpui { *gpui.current_worktree_data.lock().unwrap() = None; + gpui.set_current_review_listing(None); + gpui.set_current_review_diff(None); Some((*gpui).clone()) } else { None @@ -1166,6 +1266,31 @@ impl MainScreen { sel.set_local(window, cx); }); }); + + // Restore the right (review) sidebar's open state for the new session. + let restored_open = new_session_id + .as_ref() + .and_then(|id| { + crate::shared::ui_state::UiStateStore::try_global() + .and_then(|store| store.lock().ok()) + .map(|mut store| store.get_right_panel_open(id)) + }) + .unwrap_or(false); + + // Animate to the restored state if it differs from the current one. + if restored_open == self.right_sidebar_collapsed { + // (collapsed == !open) — they differ, so kick the animation. + self.right_animator.start(restored_open); + self.ensure_sidebar_animation_task(cx); + } + self.right_sidebar_collapsed = !restored_open; + + // Point the panel at the new session (clears stale tree/diff) and, when + // open, request fresh data. + self.right_panel_session_id = new_session_id.clone(); + self.right_panel.update(cx, |panel, cx| { + panel.set_session(new_session_id.clone(), cx); + }); } } @@ -1445,6 +1570,7 @@ impl Render for MainScreen { let new_project_dialog = self.new_project_dialog.clone(); let about_dialog = self.about_dialog.clone(); let sidebar_scale = self.sidebar_animation_scale(); + let right_sidebar_scale = self.right_sidebar_animation_scale(); let permission_prompts = self.render_permission_prompts(cx); // Main container with titlebar and content @@ -1586,6 +1712,31 @@ impl Render for MainScreen { .flex() .items_center() .gap_1() + // Review (right) sidebar toggle button + .child( + div() + .id("toggle-right-sidebar-btn") + .size(px(28.)) + .rounded_sm() + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .hover(|s| s.bg(cx.theme().muted)) + .child( + Icon::default() + .path(SharedString::from( + if self.right_sidebar_collapsed { + "icons/panel_right_open.svg" + } else { + "icons/panel_right_close.svg" + }, + )) + .with_size(Size::Small) + .text_color(cx.theme().muted_foreground), + ) + .on_click(cx.listener(Self::on_toggle_right_sidebar)), + ) .child( div() .id("about-btn") @@ -1710,12 +1861,99 @@ impl Render for MainScreen { .border_color(cx.theme().border) .child(self.input_area.clone()), ), - ), + ) + // Right sidebar: Review panel (animated width, resizable) + .when(right_sidebar_scale > 0.0, { + let right_panel = self.right_panel.clone(); + let border = cx.theme().border; + let handle_color = if self.right_sidebar_resizing { + cx.theme().drag_border + } else { + cx.theme().border + }; + let sidebar_width = self.right_sidebar_width * right_sidebar_scale; + let resizing = self.right_sidebar_resizing; + let handle_mouse_down = + cx.listener(|this, ev: &gpui::MouseDownEvent, _window, cx| { + this.right_sidebar_resizing = true; + this.resize_start_x = f32::from(ev.position.x); + this.resize_start_width = f32::from(this.right_sidebar_width); + cx.notify(); + }); + move |el| { + el.child( + div() + .relative() + .flex_none() + .h_full() + .overflow_hidden() + .border_l_1() + .border_color(border) + .w(sidebar_width) + .child(right_panel) + // Left-edge drag handle to resize the sidebar. + .child( + div() + .id("right-sidebar-resize-handle") + .absolute() + .top_0() + .left_0() + .h_full() + .w(px(6.)) + .cursor_col_resize() + .bg(handle_color) + .opacity(if resizing { 1.0 } else { 0.0 }) + .hover(|s| s.opacity(1.0)) + .on_mouse_down( + gpui::MouseButton::Left, + handle_mouse_down, + ), + ), + ) + } + }), ) // Modal dialog overlay for new project creation .when_some(new_project_dialog, |el, dialog| el.child(dialog)) // Modal "About" dialog overlay .when_some(about_dialog, |el, dialog| el.child(dialog)) + // While resizing the sidebar, a transparent full-window overlay + // captures pointer motion so the drag tracks even over child views. + .when(self.right_sidebar_resizing, |el| { + el.child( + div() + .absolute() + .inset_0() + .cursor_col_resize() + .on_mouse_move(cx.listener( + |this, ev: &gpui::MouseMoveEvent, _window, cx| { + if !this.right_sidebar_resizing { + return; + } + // Dragging the left edge leftward widens the sidebar. + let delta = f32::from(ev.position.x) - this.resize_start_x; + let new_width = + (this.resize_start_width - delta).clamp(320.0, 1800.0); + this.right_sidebar_width = px(new_width); + cx.notify(); + }, + )) + .on_mouse_up( + gpui::MouseButton::Left, + cx.listener(|this, _ev: &gpui::MouseUpEvent, _window, cx| { + if !this.right_sidebar_resizing { + return; + } + this.right_sidebar_resizing = false; + let width = f32::from(this.right_sidebar_width); + crate::update_ui_settings(cx, |s| { + s.right_sidebar_width = Some(width); + }); + cx.notify(); + }), + ), + ) + }) } } diff --git a/crates/ui_gpui/src/main_screen/right_panel/mod.rs b/crates/ui_gpui/src/main_screen/right_panel/mod.rs new file mode 100644 index 00000000..e4a699ee --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/mod.rs @@ -0,0 +1,89 @@ +//! The right sidebar's view switcher. +//! +//! For now the sidebar hosts a single view — [`review_view::ReviewView`] — but +//! it is structured as a switcher so additional views (e.g. an outline or a +//! terminal) can be added without reworking the [`crate::main_screen::MainScreen`] +//! shell that owns it. + +pub mod review_view; + +use gpui::{Context, Entity, FocusHandle, Focusable, Render, Window, div, prelude::*}; +use review_view::ReviewView; + +/// Which view the right panel is currently showing. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RightPanelView { + Review, +} + +impl RightPanelView { + /// Stable string used for persistence. + pub fn as_str(self) -> &'static str { + match self { + RightPanelView::Review => "review", + } + } + + /// Parse a persisted string back into a view (defaults to `Review`). + #[allow(clippy::should_implement_trait)] + pub fn from_str(_s: &str) -> Self { + // Only one view exists today; everything maps to Review. + RightPanelView::Review + } +} + +pub struct RightPanel { + active_view: RightPanelView, + review_view: Entity, + focus_handle: FocusHandle, +} + +impl RightPanel { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let review_view = cx.new(|cx| ReviewView::new(window, cx)); + Self { + active_view: RightPanelView::Review, + review_view, + focus_handle: cx.focus_handle(), + } + } + + #[allow(dead_code)] + pub fn active_view(&self) -> RightPanelView { + self.active_view + } + + #[allow(dead_code)] + pub fn set_active_view(&mut self, view: RightPanelView, cx: &mut Context) { + if self.active_view != view { + self.active_view = view; + cx.notify(); + } + } + + /// Point the active view(s) at a session. + pub fn set_session(&mut self, session_id: Option, cx: &mut Context) { + self.review_view + .update(cx, |v, cx| v.set_session(session_id, 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)); + } +} + +impl Focusable for RightPanel { + fn focus_handle(&self, _cx: &gpui::App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for RightPanel { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let body = match self.active_view { + RightPanelView::Review => self.review_view.clone().into_any_element(), + }; + div().size_full().child(body) + } +} 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 new file mode 100644 index 00000000..677cb6c4 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/review_view.rs @@ -0,0 +1,966 @@ +//! The "Review" view for the right sidebar: a compare-mode selector above a +//! single scrollable column of per-repo sections. Within a repo, changed files +//! are **stacked**: each file has a collapsible header (icon, path, status, +//! per-file `+/−`) with its diff hunks directly below — no separate tree/diff +//! split. +//! +//! Backend data arrives through the `current_review_listing` / `current_review_diff` +//! globals on [`Gpui`]; this view consumes them in `render` (the "sync-in-render" +//! technique the worktree selector uses). Change detection is generation-based: +//! the per-frame unchanged case costs an integer compare. +//! +//! Diffs load lazily, one file at a time: after each arrival the next visible +//! file without a diff is requested. Hunks (changed lines + a few context +//! lines) are computed once on arrival and cached — rendering never diffs, and +//! the element count scales with changed lines, not file sizes. + +use crate::shared::file_icons; +use crate::tool_cards::diff_card::{added_row_colors, deleted_row_colors, render_diff_hunks}; +use crate::{Gpui, PreparedReviewDiff, RepoReviewData}; +use code_assistant_core::session::{ReviewMode, ReviewScanState}; +use git::{ChangeStatus, ChangedFile}; +use gpui::{ + AnimationExt, Context, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, Render, + Subscription, Window, div, prelude::*, px, rems, +}; +use gpui_component::{ + ActiveTheme, Icon, Sizable, Size, + scroll::ScrollableElement, + select::{Select, SelectEvent, SelectItem, SelectState}, + v_flex, +}; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; + +// --------------------------------------------------------------------------- +// Compare-mode dropdown +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug)] +struct ModeOption { + label: String, + value: ReviewMode, +} + +impl SelectItem for ModeOption { + type Value = ReviewMode; + fn title(&self) -> gpui::SharedString { + self.label.clone().into() + } + fn display_title(&self) -> Option { + None + } + fn value(&self) -> &Self::Value { + &self.value + } +} + +fn mode_options() -> Vec { + vec![ + ModeOption { + label: "Working tree".to_string(), + value: ReviewMode::WorkingTree, + }, + ModeOption { + label: "Branch vs base".to_string(), + value: ReviewMode::BranchVsBase, + }, + ] +} + +// --------------------------------------------------------------------------- +// Base-branch dropdown +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug)] +struct BaseOption { + branch: String, +} + +impl SelectItem for BaseOption { + type Value = String; + fn title(&self) -> gpui::SharedString { + self.branch.clone().into() + } + fn display_title(&self) -> Option { + None + } + fn value(&self) -> &Self::Value { + &self.branch + } +} + +// --------------------------------------------------------------------------- +// Per-repo section +// --------------------------------------------------------------------------- + +/// One git repo's stacked file list plus its base selector, rendered as a +/// collapsible section. +struct RepoSection { + repo_root: PathBuf, + label: String, + base_state: Entity>>, + base_candidates: Vec, + base: Option, + files: Vec, + stats: git::DiffStats, + scan_state: ReviewScanState, + collapsed: bool, + _base_sub: Subscription, +} + +/// Single-letter status badge (same colors the old file tree used). +fn status_badge(status: ChangeStatus) -> (&'static str, gpui::Hsla) { + match status { + ChangeStatus::Added | ChangeStatus::Untracked => ("A", gpui::rgb(0x3f_a5_5a).into()), + ChangeStatus::Modified => ("M", gpui::rgb(0xc7_9a_3a).into()), + ChangeStatus::Deleted => ("D", gpui::rgb(0xc7_4a_4a).into()), + ChangeStatus::Renamed => ("R", gpui::rgb(0x4a_82_c7).into()), + ChangeStatus::Copied => ("C", gpui::rgb(0x4a_82_c7).into()), + ChangeStatus::TypeChanged => ("T", gpui::rgb(0x8a_6a_c7).into()), + } +} + +// --------------------------------------------------------------------------- +// ReviewView +// --------------------------------------------------------------------------- + +/// Identifies one changed file across repos. +type FileKey = (PathBuf, String); + +/// Sentinel for "never synced": guarantees the first generation compare +/// mismatches, whatever the global's current generation is. +const GENERATION_UNSEEN: u64 = u64::MAX; + +pub struct ReviewView { + session_id: Option, + mode_state: Entity>>, + + /// Current compare mode (drives requests). Base is tracked per repo. + mode: ReviewMode, + is_git_repo: bool, + /// Whether the first discovery response has arrived. + has_listing: bool, + + /// Per-repo sections, in listing order. + repos: Vec, + /// User's explicit per-repo base choices for this session. + base_overrides: HashMap, + /// Persisted default base ref, seeds a repo's base when it has no override. + default_base: Option, + + /// Prepared diffs by file, filled lazily one request at a time. + file_diffs: HashMap, + /// Files the user collapsed (default is expanded). + collapsed_files: HashSet, + /// The single outstanding diff request; arrivals for anything else are + /// stale (e.g. from before a mode/base change) and dropped. + in_flight: Option, + + /// Generation of the consumed listing. Change detection per frame is a + /// plain integer compare against the global's generation — no clones. + listing_generation: u64, + /// Generation of the last consumed diff (see `listing_generation`). + diff_generation: u64, + + focus_handle: FocusHandle, + _mode_sub: Subscription, +} + +impl EventEmitter<()> for ReviewView {} + +impl ReviewView { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let mode_state = cx.new(|cx| { + let mut state = SelectState::new(mode_options(), None, window, cx); + state.set_selected_value(&ReviewMode::WorkingTree, window, cx); + state + }); + let mode_sub = cx.subscribe_in(&mode_state, window, Self::on_mode_event); + + // Seed the persisted default base from settings. + let default_base = cx + .try_global::() + .and_then(|g| g.0.review_default_base.clone()); + + Self { + session_id: None, + mode_state, + mode: ReviewMode::WorkingTree, + is_git_repo: false, + has_listing: false, + repos: Vec::new(), + base_overrides: HashMap::new(), + default_base, + file_diffs: HashMap::new(), + collapsed_files: HashSet::new(), + in_flight: None, + listing_generation: GENERATION_UNSEEN, + diff_generation: GENERATION_UNSEEN, + focus_handle: cx.focus_handle(), + _mode_sub: mode_sub, + } + } + + /// Point the view at a session and request its changed files. + pub fn set_session(&mut self, session_id: Option, cx: &mut Context) { + self.session_id = session_id; + // Reset per-session state; fresh data will arrive via the global. + self.has_listing = false; + self.listing_generation = GENERATION_UNSEEN; + self.diff_generation = GENERATION_UNSEEN; + self.repos.clear(); + self.base_overrides.clear(); + self.file_diffs.clear(); + self.collapsed_files.clear(); + self.in_flight = None; + + // Restore the persisted compare mode for this session. The selector + // resyncs from the echoed listing on the next render. + if let Some(id) = &self.session_id { + if let Some(store) = crate::shared::ui_state::UiStateStore::try_global() + && let Ok(mut store) = store.lock() + { + let mode = store.get_review_compare_mode(id); + self.mode = match mode.as_deref() { + Some("branch_vs_base") => ReviewMode::BranchVsBase, + _ => ReviewMode::WorkingTree, + }; + } + } else { + self.mode = ReviewMode::WorkingTree; + } + + self.request_listing(cx); + } + + /// Persist the current compare mode for the active session. + fn persist_mode(&self, cx: &mut Context) { + let Some(session_id) = &self.session_id else { + return; + }; + let mode = match self.mode { + ReviewMode::WorkingTree => "working_tree", + ReviewMode::BranchVsBase => "branch_vs_base", + }; + if let Ok(mut store) = crate::shared::ui_state::UiStateStore::global().lock() { + store.set_review_compare_mode(session_id, mode.to_string()); + } + if let Some(sender) = cx.try_global::() { + let _ = sender + .0 + .try_send(code_assistant_core::ui::ui_events::UiEvent::PersistUiState); + } + } + + /// Re-request the changed-files listing for the current mode. + pub fn reload(&mut self, cx: &mut Context) { + self.request_listing(cx); + } + + fn request_listing(&self, cx: &mut Context) { + let Some(session_id) = self.session_id.clone() else { + return; + }; + if let Some(gpui) = cx.try_global::() { + gpui.cmd_list_review_files(session_id, self.mode, self.base_overrides.clone()); + } + } + + /// Request the next visible file that has no prepared diff yet. At most + /// one request is in flight; collapsed repos and files are skipped, which + /// keeps loading lazy. + fn ensure_diff_request(&mut self, cx: &mut Context) { + if self.in_flight.is_some() { + return; + } + let Some(session_id) = self.session_id.clone() else { + return; + }; + + let mut next: Option<(PathBuf, Option, ChangedFile)> = None; + 'outer: for section in &self.repos { + if section.collapsed { + continue; + } + for file in §ion.files { + let key = (section.repo_root.clone(), file.path.clone()); + if self.collapsed_files.contains(&key) || self.file_diffs.contains_key(&key) { + continue; + } + next = Some(( + section.repo_root.clone(), + section.base.clone(), + file.clone(), + )); + break 'outer; + } + } + + if let Some((repo_root, base, file)) = next { + self.in_flight = Some((repo_root.clone(), file.path.clone())); + if let Some(gpui) = cx.try_global::() { + gpui.cmd_get_review_file_diff(session_id, repo_root, self.mode, base, file); + } + } + } + + fn on_repo_base_changed(&mut self, repo_root: PathBuf, branch: String, cx: &mut Context) { + // Diffs of this repo were computed against the old base. + self.file_diffs.retain(|(root, _), _| root != &repo_root); + self.in_flight = None; + + self.base_overrides.insert(repo_root, branch.clone()); + // Remember this as the global default for future repos/sessions. + self.default_base = Some(branch.clone()); + crate::update_ui_settings(cx, |s| s.review_default_base = Some(branch)); + self.request_listing(cx); + cx.notify(); + } + + fn on_mode_event( + &mut self, + _: &Entity>>, + event: &SelectEvent>, + _window: &mut Window, + cx: &mut Context, + ) { + if let SelectEvent::Confirm(Some(mode)) = event + && *mode != self.mode + { + self.mode = *mode; + // A new mode invalidates every prepared diff. + self.file_diffs.clear(); + self.in_flight = None; + self.persist_mode(cx); + self.request_listing(cx); + cx.notify(); + } + } + + /// Consume the latest listing from the global if it changed. The per-frame + /// unchanged case is a generation compare — no clone, no deep equality. + fn sync_listing(&mut self, window: &mut Window, cx: &mut Context) { + let Some((generation, listing)) = cx + .try_global::() + .and_then(|g| g.review_listing_if_newer(self.listing_generation)) + else { + return; + }; + self.listing_generation = generation; + + let Some(listing) = listing else { + // Cleared (e.g. session change): drop all sections. + self.has_listing = false; + self.repos.clear(); + return; + }; + + self.has_listing = true; + self.is_git_repo = listing.is_git_repo; + self.mode = listing.mode; + + // Sync the mode selector to the echoed mode. + self.mode_state.update(cx, |state, cx| { + state.set_selected_value(&listing.mode, window, cx); + }); + + // Rebuild sections when the set of repos changes; otherwise update the + // existing sections in place (preserving expansion state). + let incoming_roots: Vec = + listing.repos.iter().map(|r| r.repo_root.clone()).collect(); + let current_roots: Vec = self.repos.iter().map(|r| r.repo_root.clone()).collect(); + + if incoming_roots != current_roots { + self.repos = listing + .repos + .iter() + .map(|r| self.build_section(r, window, cx)) + .collect(); + } else { + for (section, data) in self.repos.iter_mut().zip(listing.repos.iter()) { + Self::update_section(section, data, window, cx); + } + } + + // Drop prepared diffs and collapse state of files that vanished from + // the listing. + let live: HashSet = self + .repos + .iter() + .flat_map(|s| { + s.files + .iter() + .map(|f| (s.repo_root.clone(), f.path.clone())) + }) + .collect(); + self.file_diffs.retain(|key, _| live.contains(key)); + self.collapsed_files.retain(|key| live.contains(key)); + if let Some(in_flight) = &self.in_flight + && !live.contains(in_flight) + { + self.in_flight = None; + } + + // Apply the persisted default base to any repo that has no explicit + // override yet and whose resolved base differs. Seeding the override + // and re-requesting makes the default actually take effect. This + // terminates: once the backend echoes the default as the repo's base, + // the `base != default` guard stops further seeding. + if let Some(default) = self.default_base.clone() { + let mut seeded = false; + for data in &listing.repos { + if !self.base_overrides.contains_key(&data.repo_root) + && data.base_candidates.iter().any(|c| c == &default) + && data.base.as_deref() != Some(default.as_str()) + { + self.base_overrides + .insert(data.repo_root.clone(), default.clone()); + seeded = true; + } + } + if seeded { + self.request_listing(cx); + } + } + + self.ensure_diff_request(cx); + } + + /// Consume the latest diff arrival from the global if it changed. Only the + /// response to the outstanding request is accepted; the hunks are computed + /// once here, then the next missing diff is requested. + fn sync_diff(&mut self, cx: &mut Context) { + let Some((generation, diff)) = cx + .try_global::() + .and_then(|g| g.review_diff_if_newer(self.diff_generation)) + else { + return; + }; + self.diff_generation = generation; + + if let Some(d) = diff { + let key = (d.repo_root, d.path); + if self.in_flight.as_ref() == Some(&key) { + self.in_flight = None; + self.file_diffs.insert(key, d.prepared); + } + } + self.ensure_diff_request(cx); + } + + /// Create a fresh [`RepoSection`] for `data`, wiring the base-selector + /// subscription so events identify their repo. + fn build_section( + &self, + data: &RepoReviewData, + window: &mut Window, + cx: &mut Context, + ) -> RepoSection { + let items: Vec = data + .base_candidates + .iter() + .map(|b| BaseOption { branch: b.clone() }) + .collect(); + let effective_base = self.effective_base(data); + let base_state = cx.new(|cx| { + let mut state = SelectState::new(items, None, window, cx); + if let Some(base) = &effective_base { + state.set_selected_value(base, window, cx); + } + state + }); + + let root_for_base = data.repo_root.clone(); + let base_sub = cx.subscribe_in( + &base_state, + window, + move |this, _state, event, _window, cx| { + if let SelectEvent::Confirm(Some(branch)) = event { + this.on_repo_base_changed(root_for_base.clone(), branch.clone(), cx); + } + }, + ); + + // Sections default to collapsed; only repos the user expanded (stored + // by absolute root path in the UI settings) start open. + let expanded = cx + .try_global::() + .is_some_and(|g| g.0.review_expanded_repos.contains(&data.repo_root)); + + RepoSection { + repo_root: data.repo_root.clone(), + label: data.label.clone(), + base_state, + base_candidates: data.base_candidates.clone(), + base: effective_base, + files: data.files.clone(), + stats: data.stats, + scan_state: data.scan_state, + collapsed: !expanded, + _base_sub: base_sub, + } + } + + /// Update an existing section's data + base selector in place. + fn update_section( + section: &mut RepoSection, + data: &RepoReviewData, + window: &mut Window, + cx: &mut Context, + ) { + section.label = data.label.clone(); + section.files = data.files.clone(); + section.stats = data.stats; + section.scan_state = data.scan_state; + + if section.base_candidates != data.base_candidates { + section.base_candidates = data.base_candidates.clone(); + let items: Vec = data + .base_candidates + .iter() + .map(|b| BaseOption { branch: b.clone() }) + .collect(); + section.base_state.update(cx, |state, cx| { + state.set_items(items, window, cx); + }); + } + section.base = data.base.clone(); + if let Some(base) = &data.base { + section.base_state.update(cx, |state, cx| { + state.set_selected_value(base, window, cx); + }); + } + } + + /// The base ref to preselect for a repo: an explicit session override, else + /// the persisted default (when it is a candidate), else the resolved base. + fn effective_base(&self, data: &RepoReviewData) -> Option { + if let Some(base) = self.base_overrides.get(&data.repo_root) { + return Some(base.clone()); + } + if let Some(default) = &self.default_base + && data.base_candidates.iter().any(|c| c == default) + { + return Some(default.clone()); + } + data.base.clone() + } + + /// The rotating double-arrow used on active sessions, in grey — shown on + /// the repo whose scan is currently running. `id` keys the animation. + fn scan_spinner(id: impl std::fmt::Display, muted: gpui::Hsla) -> gpui::AnyElement { + gpui::svg() + .size(px(12.)) + .path("icons/arrow_circle.svg") + .text_color(muted) + .with_animation( + gpui::SharedString::from(format!("review-scan-spin-{id}")), + gpui::Animation::new(std::time::Duration::from_secs(2)).repeat(), + |svg, delta| { + svg.with_transformation(gpui::Transformation::rotate(gpui::percentage(delta))) + }, + ) + .into_any_element() + } + + /// The same double-arrow, static and faded — marks a repo that is queued + /// for scanning but not yet running. + fn pending_marker(muted: gpui::Hsla) -> gpui::AnyElement { + gpui::svg() + .size(px(12.)) + .path("icons/arrow_circle.svg") + .text_color(muted.opacity(0.4)) + .into_any_element() + } + + /// The repo header's right-hand slot: a spinner while a repo is being + /// scanned, a faded static one while it waits its turn, and a `+adds −dels` + /// summary once its (possibly cached) result is in. + fn render_scan_indicator(&self, section: &RepoSection, cx: &Context) -> gpui::AnyElement { + let theme = cx.theme(); + let muted = theme.muted_foreground; + + if matches!(section.scan_state, ReviewScanState::Scanning) { + return Self::scan_spinner(section.repo_root.display(), muted); + } + + let pending = matches!(section.scan_state, ReviewScanState::Pending); + let has_data = !section.files.is_empty() || section.stats != git::DiffStats::default(); + if !has_data { + // Nothing (yet) to summarize: a queued repo shows a wait marker, + // a scanned clean repo shows no indicator at all. + return if pending { + Self::pending_marker(muted) + } else { + div().into_any_element() + }; + } + + // Stats badge; a trailing wait marker means "cached, refresh queued". + div() + .flex() + .flex_row() + .items_center() + .gap_1() + .text_xs() + .child( + div() + .text_color(added_row_colors(theme).1) + .child(format!("+{}", section.stats.additions)), + ) + .child( + div() + .text_color(deleted_row_colors(theme).1) + .child(format!("−{}", section.stats.deletions)), + ) + .when(pending, |el| el.child(Self::pending_marker(muted))) + .into_any_element() + } + + /// One stacked file: collapsible header (icon, path, status, `+/−`) with + /// the file's diff hunks directly below. + fn render_file_entry( + &self, + repo_root: &std::path::Path, + file: &ChangedFile, + window: &Window, + cx: &mut Context, + ) -> gpui::AnyElement { + let theme = cx.theme(); + let muted = theme.muted_foreground; + let fg = theme.foreground; + let border = theme.border; + + let key: FileKey = (repo_root.to_path_buf(), file.path.clone()); + let collapsed = self.collapsed_files.contains(&key); + let entry = self.file_diffs.get(&key); + let loading = self.in_flight.as_ref() == Some(&key); + + // Right-hand slot of the file header. + let indicator: gpui::AnyElement = match entry { + Some(e) if e.is_binary => div() + .text_xs() + .text_color(muted) + .child("binary") + .into_any_element(), + Some(e) if e.too_large => div() + .text_xs() + .text_color(muted) + .child("too large") + .into_any_element(), + Some(e) => div() + .flex() + .flex_row() + .items_center() + .gap_1() + .text_xs() + .child( + div() + .text_color(added_row_colors(theme).1) + .child(format!("+{}", e.additions)), + ) + .child( + div() + .text_color(deleted_row_colors(theme).1) + .child(format!("−{}", e.deletions)), + ) + .into_any_element(), + None if loading => { + Self::scan_spinner(format!("{}:{}", repo_root.display(), file.path), muted) + } + None => Self::pending_marker(muted), + }; + + let chevron = if collapsed { + "icons/chevron_right.svg" + } else { + "icons/chevron_down.svg" + }; + let (status_letter, status_color) = status_badge(file.status); + let file_name = file.path.rsplit('/').next().unwrap_or(&file.path); + let icon = file_icons::get().get_icon_for_filename(file_name); + + let toggle_key = key.clone(); + let header = div() + .id(gpui::SharedString::from(format!( + "review-file-{}:{}", + repo_root.display(), + file.path + ))) + .flex() + .flex_row() + .items_center() + .gap_1p5() + .pl_3() + .pr_2() + .py_0p5() + .border_t_1() + .border_color(border) + .cursor_pointer() + .hover(|s| s.bg(theme.muted)) + .child(gpui::svg().size(px(10.)).path(chevron).text_color(muted)) + .child(file_icons::render_icon(&icon, 14.0, muted, "📄")) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .text_ellipsis() + .text_xs() + .text_color(fg) + .child(file.path.clone()), + ) + .child( + div() + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(status_color) + .child(status_letter), + ) + .child(indicator) + .on_click(cx.listener(move |this, _ev, _window, cx| { + if !this.collapsed_files.remove(&toggle_key) { + this.collapsed_files.insert(toggle_key.clone()); + } + // Expanding may unlock a diff that was skipped while collapsed. + this.ensure_diff_request(cx); + cx.notify(); + })); + + let mut container = v_flex().w_full().child(header); + + if !collapsed && let Some(entry) = entry { + let body: Option = if entry.is_binary || entry.too_large { + None // The header badge already says why there is no diff. + } else if entry.hunks.is_empty() { + Some( + div() + .px_3() + .py_1() + .text_xs() + .text_color(muted) + .child("No content changes") + .into_any_element(), + ) + } else { + let rem_size = window.rem_size(); + let is_dark = theme.background.l < 0.5; + let body_bg = if is_dark { + gpui::hsla(0.0, 0.0, 0.08, 1.0) + } else { + gpui::hsla(0.0, 0.0, 0.97, 1.0) + }; + let line_height_px = rems(1.25).to_pixels(rem_size).round(); + Some( + div() + .w_full() + .py_1() + .bg(body_bg) + .flex() + .flex_col() + .text_size(rems(0.78125)) + .line_height(line_height_px) + .font_family("Menlo") + .font_weight(FontWeight(400.0)) + .child(render_diff_hunks(&entry.hunks, theme, rem_size)) + .into_any_element(), + ) + }; + if let Some(body) = body { + container = container.child(body); + } + } + + container.into_any_element() + } + + /// The scrollable stack of per-repo sections with their stacked files. + fn render_sections(&mut self, window: &Window, cx: &mut Context) -> gpui::AnyElement { + let muted = cx.theme().muted_foreground; + let fg = cx.theme().foreground; + let border = cx.theme().border; + let branch_mode = matches!(self.mode, ReviewMode::BranchVsBase); + + let mut column = v_flex().size_full().overflow_y_scrollbar(); + + // Snapshot the per-section data needed while building children, so the + // listener closures (which borrow `this`) don't fight the loop borrow. + let section_count = self.repos.len(); + for ix in 0..section_count { + let (repo_root, label, collapsed, scan_state) = { + let s = &self.repos[ix]; + ( + s.repo_root.clone(), + s.label.clone(), + s.collapsed, + s.scan_state, + ) + }; + let _ = scan_state; + + // Sections are separated by a line ABOVE each section (not by a + // line between a section's header and its content). + let mut section_el = v_flex() + .w_full() + .when(ix > 0, |s| s.border_t_1().border_color(border)); + + let chevron = if collapsed { + "icons/chevron_right.svg" + } else { + "icons/chevron_down.svg" + }; + let toggle_root = repo_root.clone(); + let header = div() + .id(gpui::SharedString::from(format!("repo-header-{ix}"))) + .flex() + .flex_row() + .items_center() + .gap_1p5() + .px_2() + .py_1() + .cursor_pointer() + .hover(|s| s.bg(cx.theme().muted)) + .child(gpui::svg().size(px(12.)).path(chevron).text_color(muted)) + .child( + div() + .flex_1() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(fg) + .child(label), + ) + .child(self.render_scan_indicator(&self.repos[ix], cx)) + .on_click(cx.listener(move |this, _ev, _window, cx| { + let Some(s) = this.repos.iter_mut().find(|s| s.repo_root == toggle_root) else { + return; + }; + s.collapsed = !s.collapsed; + let expanded = !s.collapsed; + + // Persist per repo root (sections default to collapsed). + let root = toggle_root.clone(); + crate::update_ui_settings(cx, move |settings| { + if expanded { + if !settings.review_expanded_repos.contains(&root) { + settings.review_expanded_repos.push(root); + } + } else { + settings.review_expanded_repos.retain(|r| r != &root); + } + }); + + // Expanding may unlock diffs skipped while collapsed. + this.ensure_diff_request(cx); + cx.notify(); + })); + section_el = section_el.child(header); + + if !collapsed { + if branch_mode { + section_el = section_el.child( + div().px_2().py_1().child( + Select::new(&self.repos[ix].base_state) + .placeholder("Base") + .with_size(Size::XSmall) + .icon( + Icon::default() + .path("icons/chevron_up_down.svg") + .with_size(Size::XSmall) + .text_color(muted), + ) + .w_full(), + ), + ); + } + // A repo without changes shows just its header — the missing + // +/− badge already says "clean". + let files = self.repos[ix].files.clone(); + for file in &files { + section_el = + section_el.child(self.render_file_entry(&repo_root, file, window, cx)); + } + } + + column = column.child(section_el); + } + + column.into_any_element() + } +} + +impl Focusable for ReviewView { + fn focus_handle(&self, _cx: &gpui::App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for ReviewView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // Pull fresh backend data before laying out. Both syncs are a cheap + // generation compare when nothing changed. + self.sync_listing(window, cx); + self.sync_diff(cx); + + let muted = cx.theme().muted_foreground; + let border = cx.theme().border; + + // Before the first (fast) discovery response there is nothing to lay + // out yet — show explicit activity instead of an empty panel. + if !self.has_listing { + return v_flex() + .size_full() + .items_center() + .justify_center() + .gap_2() + .p_4() + .text_sm() + .text_color(muted) + .child(Self::scan_spinner("discovery", muted)) + .child("Looking for repositories…") + .into_any_element(); + } + + if !self.is_git_repo { + return v_flex() + .size_full() + .items_center() + .justify_center() + .p_4() + .text_sm() + .text_color(muted) + .child("Not a git repository") + .into_any_element(); + } + + // Header: compare-mode selector only (base selectors live per repo). + let header = div() + .flex() + .flex_row() + .items_center() + .gap_2() + .p_2() + .border_b_1() + .border_color(border) + .child( + Select::new(&self.mode_state) + .placeholder("Compare") + .with_size(Size::XSmall) + .icon( + Icon::default() + .path("icons/chevron_up_down.svg") + .with_size(Size::XSmall) + .text_color(muted), + ) + .min_w(px(130.)), + ); + + let body = self.render_sections(window, cx); + + v_flex() + .size_full() + .child(header) + .child(div().flex_1().min_h_0().child(body)) + .into_any_element() + } +} diff --git a/crates/ui_gpui/src/shared/file_icons.rs b/crates/ui_gpui/src/shared/file_icons.rs index 332f92a5..07343248 100644 --- a/crates/ui_gpui/src/shared/file_icons.rs +++ b/crates/ui_gpui/src/shared/file_icons.rs @@ -188,6 +188,44 @@ impl FileIcons { } } + /// Get an icon for a file given its name (or path). + /// + /// Resolution order mirrors editor conventions: an exact stem match + /// (e.g. `Cargo.toml`, `Dockerfile`) wins, then the longest matching + /// suffix (e.g. `rs`, `tar.gz`), then a generic file icon fallback. + pub fn get_icon_for_filename(&self, name: &str) -> Option { + // Use just the final path component for matching. + let file_name = name + .rsplit(['/', '\\']) + .next() + .unwrap_or(name) + .to_lowercase(); + + // 1) Exact stem (full filename) match. + if let Some(typ) = self.config.stems.get(&file_name) + && let Some(icon) = self.get_type_icon(typ) + { + return Some(icon); + } + + // 2) Suffix match. Try progressively shorter suffixes so multi-part + // extensions (`tar.gz`) are preferred over their tail (`gz`). + let parts: Vec<&str> = file_name.split('.').collect(); + if parts.len() > 1 { + for start in 1..parts.len() { + let suffix = parts[start..].join("."); + if let Some(typ) = self.config.suffixes.get(&suffix) + && let Some(icon) = self.get_type_icon(typ) + { + return Some(icon); + } + } + } + + // 3) Generic fallback. + self.get_type_icon(TOOL_GENERIC) + } + /// Get tool-specific icon based on tool name pub fn get_tool_icon(&self, tool_name: &str) -> Option { // MCP server tools (`mcp____`) share one generic icon; diff --git a/crates/ui_gpui/src/shared/mod.rs b/crates/ui_gpui/src/shared/mod.rs index 7323b984..94e9134b 100644 --- a/crates/ui_gpui/src/shared/mod.rs +++ b/crates/ui_gpui/src/shared/mod.rs @@ -5,6 +5,7 @@ pub mod context_indicator; pub mod file_icons; pub mod image; pub mod plan_banner; +pub mod review_cache; pub mod settings; pub mod theme; pub mod ui_state; diff --git a/crates/ui_gpui/src/shared/review_cache.rs b/crates/ui_gpui/src/shared/review_cache.rs new file mode 100644 index 00000000..d5f05088 --- /dev/null +++ b/crates/ui_gpui/src/shared/review_cache.rs @@ -0,0 +1,142 @@ +//! On-disk cache for Review panel scan results. +//! +//! One JSON file per `(repo, mode)` under `/review-cache/`. The +//! cache exists purely so the panel can show the last known state instantly +//! while a fresh background scan runs — entries are never trusted as current +//! and are always refreshed after being displayed. + +use code_assistant_core::session::{RepoReview, ReviewMode, ReviewScanState}; +use serde::{Deserialize, Serialize}; +use std::hash::{Hash, Hasher}; +use std::path::{Path, PathBuf}; +use tracing::{debug, warn}; + +/// Serialized form of one repo's last scan result. `repo_root` and `mode` are +/// stored so a hash collision can be detected on load. +#[derive(Debug, Serialize, Deserialize)] +struct CachedRepoScan { + repo_root: PathBuf, + mode: ReviewMode, + current_branch: Option, + base_candidates: Vec, + base: Option, + files: Vec, + stats: git::DiffStats, +} + +fn cache_dir() -> PathBuf { + code_assistant_core::config_dir::config_dir().join("review-cache") +} + +fn cache_path(repo_root: &Path, mode: ReviewMode) -> PathBuf { + let mut hasher = std::hash::DefaultHasher::new(); + repo_root.hash(&mut hasher); + mode.hash(&mut hasher); + cache_dir().join(format!("{:016x}.json", hasher.finish())) +} + +/// Load the cached scan for `(repo_root, mode)`, returned as a +/// [`ReviewScanState::Pending`] entry (data present, but not fresh). +/// Returns `None` when there is no valid cache entry. +pub fn load(repo_root: &Path, label: &str, mode: ReviewMode) -> Option { + let path = cache_path(repo_root, mode); + let json = std::fs::read_to_string(&path).ok()?; + let cached: CachedRepoScan = match serde_json::from_str(&json) { + Ok(c) => c, + Err(e) => { + warn!("Ignoring corrupt review cache {}: {}", path.display(), e); + return None; + } + }; + // Guard against hash collisions and stale mode mixups. + if cached.repo_root != repo_root || cached.mode != mode { + return None; + } + Some(RepoReview { + repo_root: cached.repo_root, + label: label.to_owned(), + current_branch: cached.current_branch, + base_candidates: cached.base_candidates, + base: cached.base, + files: cached.files, + stats: cached.stats, + scan_state: ReviewScanState::Pending, + }) +} + +/// Persist a finished scan for later instant display. Errors are logged only — +/// the cache is an optimization, never a requirement. +pub fn store(review: &RepoReview, mode: ReviewMode) { + let cached = CachedRepoScan { + repo_root: review.repo_root.clone(), + mode, + current_branch: review.current_branch.clone(), + base_candidates: review.base_candidates.clone(), + base: review.base.clone(), + files: review.files.clone(), + stats: review.stats, + }; + let path = cache_path(&review.repo_root, mode); + match code_assistant_core::utils::file_utils::atomic_write_json(&path, &cached) { + Ok(()) => debug!("Cached review scan to {}", path.display()), + Err(e) => warn!("Failed to write review cache {}: {}", path.display(), e), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Point the config dir at a temp dir for the duration of a test. + /// Serialized via a lock because the env var is process-global. + fn with_temp_config_dir(f: impl FnOnce() -> R) -> R { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _guard = LOCK.lock().unwrap(); + let dir = tempfile::tempdir().unwrap(); + // SAFETY: guarded by LOCK; tests touching this var run serialized. + unsafe { std::env::set_var("CODE_ASSISTANT_CONFIG_DIR", dir.path()) }; + let result = f(); + unsafe { std::env::remove_var("CODE_ASSISTANT_CONFIG_DIR") }; + result + } + + fn sample_review(root: &Path) -> RepoReview { + RepoReview { + repo_root: root.to_path_buf(), + label: "alpha".into(), + current_branch: Some("feature".into()), + base_candidates: vec!["main".into(), "origin/main".into()], + base: Some("origin/main".into()), + files: vec![git::ChangedFile { + path: "src/lib.rs".into(), + orig_path: None, + status: git::ChangeStatus::Modified, + }], + stats: git::DiffStats { + additions: 12, + deletions: 3, + }, + scan_state: ReviewScanState::Done, + } + } + + #[test] + fn store_load_roundtrip_marks_pending() { + with_temp_config_dir(|| { + let root = PathBuf::from("/tmp/some/repo"); + let review = sample_review(&root); + store(&review, ReviewMode::WorkingTree); + + let loaded = load(&root, "alpha", ReviewMode::WorkingTree).expect("cache hit"); + assert_eq!(loaded.scan_state, ReviewScanState::Pending); + assert_eq!(loaded.files, review.files); + assert_eq!(loaded.stats, review.stats); + assert_eq!(loaded.base.as_deref(), Some("origin/main")); + + // Different mode is a separate cache entry. + assert!(load(&root, "alpha", ReviewMode::BranchVsBase).is_none()); + // Unknown repo misses. + assert!(load(Path::new("/tmp/other"), "x", ReviewMode::WorkingTree).is_none()); + }); + } +} diff --git a/crates/ui_gpui/src/shared/settings.rs b/crates/ui_gpui/src/shared/settings.rs index 7c1fc69c..609fe33f 100644 --- a/crates/ui_gpui/src/shared/settings.rs +++ b/crates/ui_gpui/src/shared/settings.rs @@ -72,6 +72,20 @@ pub struct UiSettings { /// Used when no `--model` CLI argument is given. #[serde(default, skip_serializing_if = "Option::is_none")] pub default_model: Option, + + /// Persisted width (px) of the right Review sidebar. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub right_sidebar_width: Option, + + /// Persisted default base ref for "Branch vs base" review mode; seeds each + /// repo's per-session base override. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub review_default_base: Option, + + /// Repo roots whose Review section the user expanded — sections default + /// to collapsed. Absolute paths, so the state is per project/repo. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub review_expanded_repos: Vec, } fn default_theme_mode() -> ThemeModeSetting { @@ -89,6 +103,9 @@ impl Default for UiSettings { ui_scale: default_ui_scale(), window_bounds: None, default_model: None, + right_sidebar_width: None, + review_default_base: None, + review_expanded_repos: Vec::new(), } } } diff --git a/crates/ui_gpui/src/shared/ui_state.rs b/crates/ui_gpui/src/shared/ui_state.rs index bea3d817..18eb6cb5 100644 --- a/crates/ui_gpui/src/shared/ui_state.rs +++ b/crates/ui_gpui/src/shared/ui_state.rs @@ -72,6 +72,18 @@ pub struct UiSessionState { /// exactly as it was left across app restarts. #[serde(default, skip_serializing_if = "Option::is_none")] pub scroll: Option, + + /// Whether the right (review) sidebar is open for this session. + #[serde(default)] + pub right_panel_open: bool, + + /// Which view the right panel last showed (e.g. "review"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub right_panel_view: Option, + + /// Last review compare mode ("working_tree" or "branch_vs_base"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub review_compare_mode: Option, } // --------------------------------------------------------------------------- @@ -194,6 +206,37 @@ impl UiStateStore { true } + /// Return whether the right (review) sidebar is open for a session. + /// Loads from disk if the session hasn't been loaded yet. + pub fn get_right_panel_open(&mut self, session_id: &str) -> bool { + self.get(session_id).right_panel_open + } + + /// Set whether the right (review) sidebar is open for a session. + pub fn set_right_panel_open(&mut self, session_id: &str, open: bool) { + let state = self.states.entry(session_id.to_owned()).or_default(); + if state.right_panel_open == open { + return; + } + state.right_panel_open = open; + self.dirty.insert(session_id.to_owned()); + } + + /// Return the persisted review compare mode for a session. + pub fn get_review_compare_mode(&mut self, session_id: &str) -> Option { + self.get(session_id).review_compare_mode + } + + /// Persist the review compare mode for a session. + pub fn set_review_compare_mode(&mut self, session_id: &str, mode: String) { + let state = self.states.entry(session_id.to_owned()).or_default(); + if state.review_compare_mode.as_deref() == Some(mode.as_str()) { + return; + } + state.review_compare_mode = Some(mode); + self.dirty.insert(session_id.to_owned()); + } + /// Remove the in-memory state and on-disk file for a deleted session. pub fn remove_session(&mut self, session_id: &str) { self.states.remove(session_id); @@ -416,6 +459,24 @@ mod tests { assert_eq!(parsed.tool_collapse_overrides.get("t1"), Some(&true)); } + #[test] + fn test_set_review_compare_mode_roundtrip_and_dirty() { + let (mut store, _dir) = test_store(); + assert_eq!(store.get_review_compare_mode("s1"), None); + + store.set_review_compare_mode("s1", "branch_vs_base".to_owned()); + assert!(store.dirty.contains("s1")); + assert_eq!( + store.get_review_compare_mode("s1").as_deref(), + Some("branch_vs_base") + ); + + // Setting the identical value must not re-dirty the session. + store.dirty.clear(); + store.set_review_compare_mode("s1", "branch_vs_base".to_owned()); + assert!(!store.dirty.contains("s1")); + } + #[test] fn test_load_from_disk() { let (mut store, dir) = test_store(); @@ -425,6 +486,7 @@ mod tests { tool_collapse_overrides: HashMap::from([("t1".to_owned(), false)]), tool_diff_mode_overrides: HashMap::new(), scroll: None, + ..Default::default() }; let path = dir.path().join("s1.ui_state.json"); let mut f = std::fs::File::create(&path).unwrap(); diff --git a/crates/ui_gpui/src/tool_cards/diff_card.rs b/crates/ui_gpui/src/tool_cards/diff_card.rs index 4ca4d36a..1376b93e 100644 --- a/crates/ui_gpui/src/tool_cards/diff_card.rs +++ b/crates/ui_gpui/src/tool_cards/diff_card.rs @@ -537,13 +537,69 @@ fn normalize_for_diff(text: &str) -> String { format!("{trimmed}\n") } -fn render_unified_diff( - old_text: &str, - new_text: &str, - theme: &gpui_component::theme::Theme, - start_line: Option, - rem_size: gpui::Pixels, -) -> gpui::AnyElement { +/// 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 larger than this skip the word-level diff — pairing lines +/// across big rewrites produces noise, not signal (Zed caps similarly). +const MAX_WORD_DIFF_LINES: usize = 16; + +/// Expand one diff op into [`DiffLine`]s, with word-level emphasis for small +/// replace blocks. `iter_inline_changes` falls back to plain changes on its +/// own when the block's similarity ratio is too low for a useful word diff. +fn collect_change_lines<'a>( + diff: &'a TextDiff<'a, 'a, 'a, str>, + op: &similar::DiffOp, + out: &mut Vec, +) { + let block_lines = op.old_range().len().max(op.new_range().len()); + if block_lines <= MAX_WORD_DIFF_LINES { + for change in diff.iter_inline_changes(op) { + let mut text = String::new(); + let mut emphasis = Vec::new(); + for (emphasized, piece) in change.iter_strings_lossy() { + let start = text.len(); + text.push_str(&piece); + if emphasized { + emphasis.push(start..text.len()); + } + } + let trimmed_len = text.trim_end().len(); + text.truncate(trimmed_len); + emphasis.retain_mut(|r| { + r.end = r.end.min(trimmed_len); + r.start < r.end + }); + out.push(DiffLine { + tag: change.tag(), + text: text.into(), + emphasis, + }); + } + } 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); @@ -551,19 +607,139 @@ fn render_unified_diff( .newline_terminated(true) .diff_lines(&old_norm, &new_norm); - // Collect individual lines with their tags for line-number rendering - struct DiffLine { - tag: ChangeTag, - text: String, + let mut lines = Vec::new(); + for op in diff.ops() { + collect_change_lines(&diff, op, &mut lines); } - let mut diff_lines: Vec = Vec::new(); - for change in diff.iter_all_changes() { - diff_lines.push(DiffLine { - tag: change.tag(), - text: change.value().trim_end().to_string(), - }); + lines +} + +/// One hunk of a unified diff: a run of changed lines plus surrounding +/// context, positioned at `new_start` (1-based) in the new file. +#[derive(Debug, Clone)] +pub struct DiffHunk { + 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 { + 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 { + new_start: 1, + lines, + }] +} +/// Render already-computed hunks with real new-file line numbers, a shared +/// gutter width, and a slim "⋯" separator between hunks. +pub(crate) fn render_diff_hunks( + hunks: &[DiffHunk], + theme: &gpui_component::theme::Theme, + rem_size: gpui::Pixels, +) -> gpui::AnyElement { + let max_line = hunks + .iter() + .map(|h| { + h.new_start + + h.lines + .iter() + .filter(|l| l.tag != ChangeTag::Delete) + .count() + }) + .max() + .unwrap_or(1); + let gutter_width = max_line.to_string().len(); + + let mut column = div().flex().flex_col(); + for (ix, hunk) in hunks.iter().enumerate() { + if ix > 0 { + let (_, ctx_color) = unchanged_row_colors(theme); + column = column.child( + div() + .w_full() + .flex() + .justify_center() + .text_color(ctx_color.opacity(0.5)) + .child("⋯"), + ); + } + column = column.child(render_diff_rows( + &hunk.lines, + theme, + Some(hunk.new_start), + gutter_width, + rem_size, + )); + } + column.into_any() +} + +/// Compute and render a unified diff in one go. For per-frame rendering of +/// unchanged content, prefer caching [`compute_diff_lines`]'s result and +/// calling [`render_diff_lines`] instead. +pub(crate) fn render_unified_diff( + old_text: &str, + new_text: &str, + theme: &gpui_component::theme::Theme, + start_line: Option, + rem_size: gpui::Pixels, +) -> gpui::AnyElement { + render_diff_lines( + &compute_diff_lines(old_text, new_text), + theme, + start_line, + rem_size, + ) +} + +/// Build the element tree for already-computed diff lines. +pub(crate) fn render_diff_lines( + diff_lines: &[DiffLine], + theme: &gpui_component::theme::Theme, + start_line: Option, + rem_size: gpui::Pixels, +) -> gpui::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 @@ -575,7 +751,18 @@ fn render_unified_diff( } else { 0 }; + render_diff_rows(diff_lines, theme, start_line, gutter_width, rem_size) +} +/// Shared row builder: renders diff rows with numbering from `start_line` +/// (when given) into a fixed `gutter_width`-digit gutter. +fn render_diff_rows( + diff_lines: &[DiffLine], + theme: &gpui_component::theme::Theme, + start_line: Option, + gutter_width: usize, + rem_size: gpui::Pixels, +) -> gpui::AnyElement { // Track both old and new line numbers let mut old_line_num = start_line.unwrap_or(1); let mut new_line_num = start_line.unwrap_or(1); @@ -589,7 +776,7 @@ fn render_unified_diff( div() .flex() .flex_col() - .children(diff_lines.into_iter().map(|dl| { + .children(diff_lines.iter().map(|dl| { let (row_bg, text_color) = match dl.tag { ChangeTag::Equal => unchanged_row_colors(theme), ChangeTag::Delete => deleted_row_colors(theme), @@ -637,7 +824,25 @@ fn render_unified_diff( } // Content — overflow_x_hidden enables min-width:0 in flex so text - // wraps instead of pushing the row wider than the card. + // wraps instead of pushing the row wider than the card. Word-level + // changes get a stronger background via text-run highlights, which + // wrap with the text (unlike per-span elements). + let content: gpui::AnyElement = if dl.emphasis.is_empty() { + dl.text.clone().into_any_element() + } else { + let word_bg = word_emphasis_bg(dl.tag, theme); + gpui::StyledText::new(dl.text.clone()) + .with_highlights(dl.emphasis.iter().map(|range| { + ( + range.clone(), + gpui::HighlightStyle { + background_color: Some(word_bg), + ..Default::default() + }, + ) + })) + .into_any_element() + }; row = row.child( div() .flex_grow(1.0) @@ -645,7 +850,7 @@ fn render_unified_diff( .when(start_line.is_none(), |d| d.px_3()) .when(start_line.is_some(), |d| d.pl_1().pr_3()) .text_color(text_color) - .child(dl.text), + .child(content), ); row.into_any() @@ -878,7 +1083,9 @@ fn rgba_color(r: u8, g: u8, b: u8, a: u8) -> gpui::Hsla { .into() } -fn deleted_row_colors(theme: &gpui_component::theme::Theme) -> (Option, gpui::Hsla) { +pub(crate) fn deleted_row_colors( + theme: &gpui_component::theme::Theme, +) -> (Option, gpui::Hsla) { if theme.is_dark() { ( Some(rgba_color(0x80, 0x20, 0x20, 0x60)), @@ -892,7 +1099,9 @@ fn deleted_row_colors(theme: &gpui_component::theme::Theme) -> (Option (Option, gpui::Hsla) { +pub(crate) fn added_row_colors( + theme: &gpui_component::theme::Theme, +) -> (Option, gpui::Hsla) { if theme.is_dark() { ( Some(rgba_color(0x20, 0x60, 0x20, 0x60)), @@ -906,7 +1115,21 @@ fn added_row_colors(theme: &gpui_component::theme::Theme) -> (Option } } -fn unchanged_row_colors(theme: &gpui_component::theme::Theme) -> (Option, gpui::Hsla) { +/// Background for word-level (intra-line) changes: a stronger tint layered on +/// top of the row's add/delete background. +fn word_emphasis_bg(tag: ChangeTag, theme: &gpui_component::theme::Theme) -> gpui::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::transparent_black(), + } +} + +pub(crate) fn unchanged_row_colors( + theme: &gpui_component::theme::Theme, +) -> (Option, gpui::Hsla) { if theme.is_dark() { (None, rgba_color(0xFF, 0xFF, 0xFF, 0x99)) } else { @@ -918,6 +1141,69 @@ fn unchanged_row_colors(theme: &gpui_component::theme::Theme) -> (Option = (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].new_start, 1); + assert_eq!(hunks[1].new_start, 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"); + } + } + + #[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 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";