diff --git a/Cargo.lock b/Cargo.lock index 66caefcc..2417e21e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3173,6 +3173,7 @@ dependencies = [ "encoding_rs", "glob", "ignore", + "notify", "path-clean", "regex", "serde", diff --git a/crates/code_assistant_core/src/lib.rs b/crates/code_assistant_core/src/lib.rs index 92a31912..2532f445 100644 --- a/crates/code_assistant_core/src/lib.rs +++ b/crates/code_assistant_core/src/lib.rs @@ -17,6 +17,7 @@ pub mod config_dir; pub mod goal_commands; pub mod goals; pub mod injection; +pub mod line_comments; pub mod persistence; pub mod plugins; pub mod session; diff --git a/crates/code_assistant_core/src/line_comments.rs b/crates/code_assistant_core/src/line_comments.rs new file mode 100644 index 00000000..0f62dd80 --- /dev/null +++ b/crates/code_assistant_core/src/line_comments.rs @@ -0,0 +1,328 @@ +//! Comments the user attaches to lines of a file, or to a passage of a chat +//! message, before sending a message. +//! +//! The composer collects them as one [`DraftAttachment::LineComments`] +//! (crate::persistence::DraftAttachment). On send they become a single text +//! block wrapped in ``, carrying each comment's file, line +//! range, the selected lines themselves and the comment. The excerpt lets the +//! model see the code without reading the file and find the place again once +//! line numbers have shifted. [`parse`] reads such a block back, so a +//! frontend can render it compactly in the transcript. + +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// Lines kept at most per excerpt; longer selections keep their first and +/// last lines around an elision marker. +pub const MAX_EXCERPT_LINES: usize = 40; + +const OPEN_TAG: &str = ""; +const CLOSE_TAG: &str = ""; + +/// One comment on lines `start_line..=end_line` (1-based) of a file. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LineComment { + /// Unique within the draft, for editing and removing. + pub id: u64, + /// The file; absolute while in the draft, relative to the project root + /// once sent (when it lies inside the project). + pub file: PathBuf, + pub start_line: usize, + pub end_line: usize, + /// The lines count in the old version of the file (a comment on deleted + /// lines of a diff). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub old_side: bool, + /// Made on a diff (the Review view) rather than on the file itself; + /// tells a frontend where to show it again. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub in_diff: bool, + /// On a passage of a chat message rather than on a file: `excerpt` is the + /// quoted passage; `file` and the lines are unused. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub on_message: bool, + /// The selected lines as shown when the comment was made. + pub excerpt: String, + pub text: String, +} + +impl LineComment { + /// `"12"` or `"12-15"`. + pub fn lines_label(&self) -> String { + if self.start_line == self.end_line { + self.start_line.to_string() + } else { + format!("{}-{}", self.start_line, self.end_line) + } + } + + /// Where the comment is, for compact labels: `lib.rs:12-15`, or + /// `message` for a comment on a chat message. + pub fn location_label(&self) -> String { + if self.on_message { + "message".to_owned() + } else { + format!("{}:{}", self.file_name(), self.lines_label()) + } + } + + /// A comment on a chat message, quoting `quote`. + pub fn on_message(quote: impl Into) -> Self { + Self { + id: 0, + file: PathBuf::new(), + start_line: 0, + end_line: 0, + old_side: false, + in_diff: false, + on_message: true, + excerpt: quote.into(), + text: String::new(), + } + } + + /// The file's name, for compact labels. + pub fn file_name(&self) -> String { + self.file + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| self.file.display().to_string()) + } +} + +/// `comments` with their files relative to `project_root` where they lie +/// inside it. +pub fn relative_to(comments: &[LineComment], project_root: &Path) -> Vec { + comments + .iter() + .map(|c| { + let mut c = c.clone(); + if let Ok(rel) = c.file.strip_prefix(project_root) { + c.file = rel.to_path_buf(); + } + c + }) + .collect() +} + +/// The text block sent to the model for `comments`. +pub fn render(comments: &[LineComment]) -> String { + let mut out = String::from(OPEN_TAG); + out.push('\n'); + for c in comments { + if c.on_message { + out.push_str(&format!( + "\n\n{}\n\n{}\n\n", + cap_excerpt(&c.excerpt), + c.text.trim(), + )); + continue; + } + let side = if c.old_side { " side=\"old\"" } else { "" }; + out.push_str(&format!( + "\n\n{}\n\n{}\n\n", + c.file.display(), + c.lines_label(), + cap_excerpt(&c.excerpt), + c.text.trim(), + )); + } + out.push_str(CLOSE_TAG); + out +} + +/// Keep the first and last lines of a long excerpt. +fn cap_excerpt(excerpt: &str) -> String { + let lines: Vec<&str> = excerpt.lines().collect(); + if lines.len() <= MAX_EXCERPT_LINES { + return excerpt.trim_end_matches('\n').to_owned(); + } + let keep = MAX_EXCERPT_LINES / 2; + let elided = lines.len() - 2 * keep; + let mut out: Vec = lines[..keep].iter().map(|l| l.to_string()).collect(); + out.push(format!("… {elided} lines …")); + out.extend(lines[lines.len() - keep..].iter().map(|l| l.to_string())); + out.join("\n") +} + +/// Where `comment`'s excerpt is in `lines` now (1-based, inclusive): its +/// stored range when the lines there still match, else the occurrence +/// nearest to it, else `None` (the lines are gone). Trailing whitespace is +/// ignored. +pub fn locate(lines: &[&str], comment: &LineComment) -> Option<(usize, usize)> { + let excerpt: Vec<&str> = comment.excerpt.lines().map(str::trim_end).collect(); + if excerpt.is_empty() || excerpt.len() > lines.len() { + return None; + } + let matches_at = |start: usize| { + lines[start..start + excerpt.len()] + .iter() + .zip(&excerpt) + .all(|(line, wanted)| line.trim_end() == *wanted) + }; + let len = excerpt.len(); + let stored = comment.start_line.saturating_sub(1); + if stored + len <= lines.len() && matches_at(stored) { + return Some((stored + 1, stored + len)); + } + (0..=lines.len() - len) + .filter(|&start| matches_at(start)) + .min_by_key(|&start| start.abs_diff(stored)) + .map(|start| (start + 1, start + len)) +} + +/// A comment read back from a rendered block. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedComment { + /// Empty for a comment on a message. + pub path: String, + /// Empty for a comment on a message. + pub lines: String, + /// The commented lines, or the quoted passage of a message. + pub code: String, + pub text: String, + pub on_message: bool, +} + +/// Read a block produced by [`render`]; `None` for any other text. +pub fn parse(block: &str) -> Option> { + let body = block + .trim() + .strip_prefix(OPEN_TAG)? + .strip_suffix(CLOSE_TAG)?; + let mut comments = Vec::new(); + let mut rest = body; + while let Some(start) = rest.find("')?; + let head = &after[..head_end]; + let inner_and_rest = &after[head_end + 1..]; + let end = inner_and_rest.find("")?; + let inner = &inner_and_rest[..end]; + let on_message = attribute(head, "on").as_deref() == Some("message"); + let tag = if on_message { "quote" } else { "code" }; + let (open, close) = (format!("<{tag}>\n"), format!("\n")); + let code_start = inner.find(&open)? + open.len(); + let code_end = inner.find(&close)?; + comments.push(ParsedComment { + path: if on_message { + String::new() + } else { + attribute(head, "path")? + }, + lines: if on_message { + String::new() + } else { + attribute(head, "lines")? + }, + code: inner[code_start..code_end].to_owned(), + text: inner[code_end + close.len()..].trim().to_owned(), + on_message, + }); + rest = &inner_and_rest[end + "".len()..]; + } + Some(comments) +} + +fn attribute(head: &str, name: &str) -> Option { + let start = head.find(&format!("{name}=\""))? + name.len() + 2; + let len = head[start..].find('"')?; + Some(head[start..start + len].to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn comment(file: &str, lines: (usize, usize), excerpt: &str, text: &str) -> LineComment { + LineComment { + id: 1, + file: PathBuf::from(file), + start_line: lines.0, + end_line: lines.1, + old_side: false, + in_diff: false, + on_message: false, + excerpt: excerpt.to_owned(), + text: text.to_owned(), + } + } + + #[test] + fn renders_paths_lines_code_and_text_and_parses_back() { + let comments = vec![ + comment( + "/p/src/lib.rs", + (41, 42), + "let x = y.unwrap();\nx", + "no unwrap", + ), + comment("/elsewhere/a.md", (3, 3), "# Title", "rename"), + ]; + let block = render(&relative_to(&comments, Path::new("/p"))); + assert_eq!( + block, + "\n\ + \n\nlet x = y.unwrap();\nx\n\nno unwrap\n\n\ + \n\n# Title\n\nrename\n\n\ + " + ); + let parsed = parse(&block).unwrap(); + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[0].path, "src/lib.rs"); + assert_eq!(parsed[0].lines, "41-42"); + assert_eq!(parsed[0].code, "let x = y.unwrap();\nx"); + assert_eq!(parsed[0].text, "no unwrap"); + assert!(parse("just text").is_none()); + } + + #[test] + fn long_excerpts_keep_their_ends() { + let excerpt: Vec = (1..=100).map(|i| format!("l{i}")).collect(); + let capped = cap_excerpt(&excerpt.join("\n")); + let lines: Vec<&str> = capped.lines().collect(); + assert_eq!(lines.len(), MAX_EXCERPT_LINES + 1); + assert_eq!(lines[0], "l1"); + assert_eq!(lines[20], "… 60 lines …"); + assert_eq!(lines.last(), Some(&"l100")); + } + + #[test] + fn locate_follows_moved_lines() { + let c = comment("a.rs", (2, 3), "b\nc", "x"); + assert_eq!(locate(&["a", "b", "c"], &c), Some((2, 3))); + assert_eq!(locate(&["new", "a", "b ", "c"], &c), Some((3, 4))); + // Two occurrences: the nearer one wins. + assert_eq!( + locate( + &["b", "c", "x", "x", "x", "x", "b", "c"], + &comment("a.rs", (6, 7), "b\nc", "") + ), + Some((7, 8)) + ); + assert_eq!(locate(&["a", "z"], &c), None); + } + + #[test] + fn message_comments_quote_the_passage() { + let mut c = LineComment::on_message("Use a ledger first."); + c.text = "why not both?".into(); + let block = render(&[c.clone(), comment("a.rs", (1, 1), "x", "y")]); + assert!(block.contains( + "\n\nUse a ledger first.\n\nwhy not both?\n" + )); + let parsed = parse(&block).unwrap(); + assert!(parsed[0].on_message); + assert_eq!(parsed[0].code, "Use a ledger first."); + assert_eq!(parsed[0].text, "why not both?"); + assert!(!parsed[1].on_message); + assert_eq!(c.location_label(), "message"); + } + + #[test] + fn old_side_comments_say_so() { + let mut c = comment("a.rs", (5, 5), "gone", "why removed?"); + c.old_side = true; + assert!(render(&[c]).contains("lines=\"5\" side=\"old\"")); + } +} diff --git a/crates/code_assistant_core/src/persistence/mod.rs b/crates/code_assistant_core/src/persistence/mod.rs index 3626bf91..253cd8fe 100644 --- a/crates/code_assistant_core/src/persistence/mod.rs +++ b/crates/code_assistant_core/src/persistence/mod.rs @@ -1377,6 +1377,11 @@ pub enum DraftAttachment { filename: String, mime_type: String, }, + /// Comments on lines of files; the composer keeps all of them in one. + #[serde(rename = "line_comments")] + LineComments { + comments: Vec, + }, } /// What the composer of a session holds while the user has not sent it. diff --git a/crates/code_assistant_core/src/session/mod.rs b/crates/code_assistant_core/src/session/mod.rs index a9fa9976..591e18cb 100644 --- a/crates/code_assistant_core/src/session/mod.rs +++ b/crates/code_assistant_core/src/session/mod.rs @@ -32,7 +32,10 @@ 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 service::{ + DirEntry, DirListing, EntryKind, FileContent, ProjectDir, ProjectFile, ProjectFiles, + RepoReview, ReviewMode, ReviewScanState, TreeWatcher, 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 b35c22d6..0262ce2d 100644 --- a/crates/code_assistant_core/src/session/service.rs +++ b/crates/code_assistant_core/src/session/service.rs @@ -1674,15 +1674,20 @@ async fn user_message_blocks( message: &str, attachments: &[DraftAttachment], ) -> Result> { - let mut blocks = content_blocks_from(message, attachments); - let project_name = { + let (project_name, project_root) = { let mut manager = ctx.manager.lock().await; manager.ensure_session_loaded(session_id)?; - manager + let config = &manager .get_session(session_id) - .map(|s| s.session.config.initial_project.clone()) .ok_or_else(|| anyhow!("Session {session_id} not found"))? + .session + .config; + ( + config.initial_project.clone(), + config.effective_project_path().cloned(), + ) }; + let mut blocks = content_blocks_from(message, attachments, project_root.as_deref()); let pm = (ctx.runtime.project_manager_factory)(); let Some(payload) = resolve_skill_trigger(pm.as_ref(), &project_name, &SkillsConfig::load(), message) @@ -2188,6 +2193,11 @@ async fn run_command(ctx: ServiceCtx, command: Command, permit: tokio::sync::Own } mod browsers; +mod files; +pub use files::{ + DirEntry, DirListing, EntryKind, FileContent, ProjectDir, ProjectFile, ProjectFiles, + TreeWatcher, +}; mod lifecycle; mod new_context; diff --git a/crates/code_assistant_core/src/session/service/files.rs b/crates/code_assistant_core/src/session/service/files.rs new file mode 100644 index 00000000..28b8f013 --- /dev/null +++ b/crates/code_assistant_core/src/session/service/files.rs @@ -0,0 +1,173 @@ +//! Read-only access to a session's project files, for a file viewer. The +//! root is the session's effective project path (the worktree when it has +//! one), so the view follows a worktree switch. + +use super::*; +use fs_explorer::browse; +pub use fs_explorer::browse::{DirEntry, DirListing, EntryKind, FileContent}; +pub use fs_explorer::watch::TreeWatcher; + +/// One directory level of a session's project. +#[derive(Debug, Clone)] +pub struct ProjectDir { + /// The project root the listing is relative to. + pub root: PathBuf, + pub listing: DirListing, +} + +/// One file of a session's project. +#[derive(Debug, Clone)] +pub struct ProjectFile { + pub root: PathBuf, + pub content: FileContent, +} + +/// All files of a session's project that gitignore keeps. +#[derive(Debug, Clone)] +pub struct ProjectFiles { + pub root: PathBuf, + /// `/`-separated paths relative to `root`. + pub files: Vec, + /// The list was cut at [`browse::MAX_LISTED_FILES`]. + pub truncated: bool, +} + +impl SessionService { + /// List the directory `rel_dir` of the session's project (`""` for the + /// project root). + pub async fn list_project_dir( + &self, + session_id: String, + rel_dir: PathBuf, + ) -> Result { + self.with_project_root(session_id, move |root| { + let listing = browse::list_dir(&root, &rel_dir)?; + Ok(ProjectDir { root, listing }) + }) + .await + } + + /// Read the file `rel_path` of the session's project. + pub async fn read_project_file( + &self, + session_id: String, + rel_path: PathBuf, + ) -> Result { + self.with_project_root(session_id, move |root| { + let content = browse::read_file(&root, &rel_path)?; + Ok(ProjectFile { root, content }) + }) + .await + } + + /// Replace the content of the existing file `rel_path` of the session's + /// project (the user saving an edit in a file viewer). + pub async fn write_project_file( + &self, + session_id: String, + rel_path: PathBuf, + text: String, + ) -> Result<()> { + self.with_project_root(session_id, move |root| { + browse::write_file(&root, &rel_path, &text) + }) + .await + } + + /// List every file of the session's project that gitignore keeps. + pub async fn list_project_files(&self, session_id: String) -> Result { + self.with_project_root(session_id, move |root| { + let (files, truncated) = browse::list_files(&root)?; + Ok(ProjectFiles { + root, + files, + truncated, + }) + }) + .await + } + + /// Run `f` with the session's project root on a blocking thread, so a + /// large directory never holds up the service. + async fn with_project_root(&self, session_id: String, f: F) -> Result + where + F: FnOnce(PathBuf) -> Result + Send + 'static, + T: Send + 'static, + { + self.call_io(move |ctx| async move { + let root = { + let manager = ctx.manager.lock().await; + session_effective_path(&manager, &session_id)? + }; + tokio::task::spawn_blocking(move || f(root)) + .await + .context("Project file access was aborted")? + }) + .await + } +} + +#[cfg(test)] +mod tests { + use super::super::tests::test_service_with_manager; + use crate::session::SessionConfig; + use fs_explorer::browse::{EntryKind, FileContent}; + use std::path::PathBuf; + + #[tokio::test] + async fn reads_the_sessions_project() { + let sessions = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + std::fs::create_dir(project.path().join("src")).unwrap(); + std::fs::write(project.path().join("src/lib.rs"), "fn a() {}\n").unwrap(); + + let (service, _) = test_service_with_manager(sessions.path()); + let config = SessionConfig { + init_path: Some(project.path().to_path_buf()), + ..SessionConfig::default() + }; + let id = service + .create_session_with_config(None, config, None) + .await + .unwrap(); + + let dir = service + .list_project_dir(id.clone(), PathBuf::new()) + .await + .unwrap(); + assert_eq!(dir.root, project.path()); + assert_eq!(dir.listing.entries.len(), 1); + assert_eq!(dir.listing.entries[0].name, "src"); + assert_eq!(dir.listing.entries[0].kind, EntryKind::Dir); + + let file = service + .read_project_file(id.clone(), PathBuf::from("src/lib.rs")) + .await + .unwrap(); + assert_eq!(file.content, FileContent::Text("fn a() {}\n".into())); + + service + .write_project_file( + id.clone(), + PathBuf::from("src/lib.rs"), + "fn b() {}\n".into(), + ) + .await + .unwrap(); + let file = service + .read_project_file(id.clone(), PathBuf::from("src/lib.rs")) + .await + .unwrap(); + assert_eq!(file.content, FileContent::Text("fn b() {}\n".into())); + + let files = service.list_project_files(id.clone()).await.unwrap(); + assert_eq!(files.files, vec!["src/lib.rs".to_string()]); + + assert!( + service + .read_project_file(id, PathBuf::from("../escape")) + .await + .is_err() + ); + } +} diff --git a/crates/code_assistant_core/src/session/service/new_context.rs b/crates/code_assistant_core/src/session/service/new_context.rs index 33581e98..57ad759f 100644 --- a/crates/code_assistant_core/src/session/service/new_context.rs +++ b/crates/code_assistant_core/src/session/service/new_context.rs @@ -158,7 +158,7 @@ impl SessionService { let from = from.to_string(); self.call_session(to.clone(), move |ctx| async move { if !prompt.trim().is_empty() { - let blocks = content_blocks_from(&prompt, &[]); + let blocks = content_blocks_from(&prompt, &[], None); append_and_run(&ctx, &to, &prompt, blocks, &[], None, RunOptions::default()) .await?; } @@ -194,7 +194,7 @@ async fn start_new_context_impl( // message, so the generation request is the history as it is. An // edited message branches off like any other. NewContextCommand::Handoff { .. } => { - let mut blocks = content_blocks_from(message, &[]); + let mut blocks = content_blocks_from(message, &[], None); blocks.push(llm::ContentBlock::new_text( crate::session::new_context::handoff_request(), )); diff --git a/crates/code_assistant_core/src/utils/content.rs b/crates/code_assistant_core/src/utils/content.rs index a9544478..381bd49d 100644 --- a/crates/code_assistant_core/src/utils/content.rs +++ b/crates/code_assistant_core/src/utils/content.rs @@ -1,10 +1,17 @@ use crate::persistence::DraftAttachment; use llm::ContentBlock; +use std::path::Path; use std::time::SystemTime; pub use agent_core::text_summary_from_blocks; -pub fn content_blocks_from(message: &str, attachments: &[DraftAttachment]) -> Vec { +/// The content blocks of a user message. Line comments name their files +/// relative to `project_root` where they lie inside it. +pub fn content_blocks_from( + message: &str, + attachments: &[DraftAttachment], + project_root: Option<&Path>, +) -> Vec { let mut blocks = Vec::new(); if !message.is_empty() { @@ -44,6 +51,16 @@ pub fn content_blocks_from(message: &str, attachments: &[DraftAttachment]) -> Ve "File: {filename}\n{content}" ))); } + DraftAttachment::LineComments { comments } if !comments.is_empty() => { + let comments = match project_root { + Some(root) => crate::line_comments::relative_to(comments, root), + None => comments.clone(), + }; + blocks.push(ContentBlock::new_text(crate::line_comments::render( + &comments, + ))); + } + DraftAttachment::LineComments { .. } => {} } } diff --git a/crates/fs_explorer/Cargo.toml b/crates/fs_explorer/Cargo.toml index 1cb531c1..fe3daab0 100644 --- a/crates/fs_explorer/Cargo.toml +++ b/crates/fs_explorer/Cargo.toml @@ -20,6 +20,7 @@ serde = { version = "1.0.215", features = ["derive"] } tracing = "0.1.40" path-clean = "1.0.1" walkdir = "2.5" +notify = { version = "7", features = ["macos_fsevent"] } transmutation = { version = "0.3", features = ["office"], optional = true } [dev-dependencies] diff --git a/crates/fs_explorer/src/browse.rs b/crates/fs_explorer/src/browse.rs new file mode 100644 index 00000000..84e20665 --- /dev/null +++ b/crates/fs_explorer/src/browse.rs @@ -0,0 +1,293 @@ +//! Read-only browsing of a project for a file viewer: one directory level at +//! a time, a single file's text, and the flat list of files for a name +//! filter. Every path is relative to a root and may not leave it. + +use crate::encoding::{read_file_with_encoding, write_file_with_encoding}; +use anyhow::{Result, anyhow, bail}; +use ignore::WalkBuilder; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::ffi::OsString; +use std::path::{Component, Path, PathBuf}; + +/// Bytes read at most for one file; larger files are reported as too large. +pub const MAX_VIEW_FILE_BYTES: u64 = 2 * 1024 * 1024; + +/// Entries returned at most for one directory. +pub const MAX_DIR_ENTRIES: usize = 5_000; + +/// Files returned at most by [`list_files`]. +pub const MAX_LISTED_FILES: usize = 50_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum EntryKind { + Dir, + File, +} + +/// One entry of a directory listing. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DirEntry { + pub name: String, + pub kind: EntryKind, + /// Excluded by gitignore (or the `.git` directory itself). + pub ignored: bool, +} + +/// One directory level: directories first, then files, each by name. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DirListing { + pub entries: Vec, + /// Entries left out beyond [`MAX_DIR_ENTRIES`]. + pub omitted: usize, +} + +/// A file's content as far as a viewer can show it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FileContent { + Text(String), + Binary, + TooLarge { size: u64 }, +} + +/// Resolve `rel` under `root`, refusing absolute paths, `..`, and symlinks +/// that lead outside the root. +pub fn resolve_inside(root: &Path, rel: &Path) -> Result { + for component in rel.components() { + match component { + Component::Normal(_) | Component::CurDir => {} + _ => bail!("Path {} is not inside the project", rel.display()), + } + } + let path = root.join(rel); + let canonical_root = root.canonicalize()?; + let canonical = path.canonicalize()?; + if !canonical.starts_with(&canonical_root) { + bail!("Path {} leads outside the project", rel.display()); + } + Ok(path) +} + +/// List the directory `rel` under `root` (`""` for the root itself). +pub fn list_dir(root: &Path, rel: &Path) -> Result { + let dir = resolve_inside(root, rel)?; + if !dir.is_dir() { + bail!("{} is not a directory", rel.display()); + } + + // The walker yields what gitignore keeps; everything else is ignored. + let kept: HashSet = WalkBuilder::new(&dir) + .max_depth(Some(1)) + .hidden(false) + .require_git(false) + .build() + .flatten() + .filter(|entry| entry.depth() == 1) + .map(|entry| entry.file_name().to_owned()) + .collect(); + + let mut entries: Vec = std::fs::read_dir(&dir)? + .flatten() + .map(|entry| { + let name = entry.file_name(); + // `metadata` follows symlinks, so a link to a directory is one. + let is_dir = entry.path().metadata().is_ok_and(|m| m.is_dir()); + DirEntry { + ignored: name == ".git" || !kept.contains(&name), + name: name.to_string_lossy().into_owned(), + kind: if is_dir { + EntryKind::Dir + } else { + EntryKind::File + }, + } + }) + .collect(); + entries.sort_by(|a, b| { + (a.kind != EntryKind::Dir) + .cmp(&(b.kind != EntryKind::Dir)) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + .then_with(|| a.name.cmp(&b.name)) + }); + let omitted = entries.len().saturating_sub(MAX_DIR_ENTRIES); + entries.truncate(MAX_DIR_ENTRIES); + Ok(DirListing { entries, omitted }) +} + +/// Read the file `rel` under `root` for display. +pub fn read_file(root: &Path, rel: &Path) -> Result { + let path = resolve_inside(root, rel)?; + let metadata = path.metadata()?; + if !metadata.is_file() { + bail!("{} is not a file", rel.display()); + } + if metadata.len() > MAX_VIEW_FILE_BYTES { + return Ok(FileContent::TooLarge { + size: metadata.len(), + }); + } + let bytes = std::fs::read(&path)?; + let sample = &bytes[..bytes.len().min(8 * 1024)]; + if content_inspector::inspect(sample).is_binary() { + return Ok(FileContent::Binary); + } + let (text, _) = read_file_with_encoding(&path)?; + Ok(FileContent::Text(text)) +} + +/// Replace the content of the existing file `rel` under `root`, keeping the +/// encoding it has on disk. +pub fn write_file(root: &Path, rel: &Path, text: &str) -> Result<()> { + let path = resolve_inside(root, rel)?; + if !path.is_file() { + bail!("{} is not a file", rel.display()); + } + let (_, encoding) = read_file_with_encoding(&path)?; + write_file_with_encoding(&path, text, &encoding) +} + +/// Every file under `root` that gitignore keeps, as `/`-separated relative +/// paths in walk order, at most [`MAX_LISTED_FILES`]. The flag tells whether +/// the list was cut. +pub fn list_files(root: &Path) -> Result<(Vec, bool)> { + if !root.is_dir() { + return Err(anyhow!("{} is not a directory", root.display())); + } + let mut files = Vec::new(); + let walker = WalkBuilder::new(root) + .hidden(false) + .require_git(false) + .filter_entry(|entry| entry.file_name() != ".git") + .build(); + for entry in walker.flatten() { + if !entry.file_type().is_some_and(|t| t.is_file()) { + continue; + } + if files.len() == MAX_LISTED_FILES { + return Ok((files, true)); + } + if let Ok(rel) = entry.path().strip_prefix(root) { + let parts: Vec<_> = rel.iter().map(|p| p.to_string_lossy()).collect(); + files.push(parts.join("/")); + } + } + Ok((files, false)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn project() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + fs::create_dir_all(root.join("src/nested")).unwrap(); + fs::create_dir_all(root.join("target/debug")).unwrap(); + fs::create_dir_all(root.join(".git")).unwrap(); + fs::write(root.join(".gitignore"), "target\n*.log\n").unwrap(); + fs::write(root.join("README.md"), "hello\n").unwrap(); + fs::write(root.join("app.log"), "noise\n").unwrap(); + fs::write(root.join("src/lib.rs"), "fn main() {}\n").unwrap(); + fs::write(root.join("src/nested/b.rs"), "").unwrap(); + fs::write(root.join("target/debug/out"), "").unwrap(); + dir + } + + fn names(listing: &DirListing) -> Vec<(&str, bool)> { + listing + .entries + .iter() + .map(|e| (e.name.as_str(), e.ignored)) + .collect() + } + + #[test] + fn lists_directories_first_and_marks_ignored_entries() { + let dir = project(); + let listing = list_dir(dir.path(), Path::new("")).unwrap(); + assert_eq!( + names(&listing), + vec![ + (".git", true), + ("src", false), + ("target", true), + (".gitignore", false), + ("app.log", true), + ("README.md", false), + ] + ); + assert_eq!(listing.entries[1].kind, EntryKind::Dir); + assert_eq!(listing.entries[3].kind, EntryKind::File); + } + + #[test] + fn lists_a_subdirectory() { + let dir = project(); + let listing = list_dir(dir.path(), Path::new("src")).unwrap(); + assert_eq!(names(&listing), vec![("nested", false), ("lib.rs", false)]); + } + + #[test] + fn refuses_paths_outside_the_root() { + let dir = project(); + assert!(list_dir(dir.path(), Path::new("..")).is_err()); + assert!(read_file(dir.path(), Path::new("/etc/hosts")).is_err()); + assert!(read_file(dir.path(), Path::new("src/../../x")).is_err()); + } + + #[cfg(unix)] + #[test] + fn refuses_symlinks_out_of_the_root() { + let dir = project(); + let outside = tempfile::tempdir().unwrap(); + fs::write(outside.path().join("secret"), "x").unwrap(); + std::os::unix::fs::symlink(outside.path(), dir.path().join("link")).unwrap(); + assert!(read_file(dir.path(), Path::new("link/secret")).is_err()); + } + + #[test] + fn reads_text_and_flags_binary_and_large_files() { + let dir = project(); + assert_eq!( + read_file(dir.path(), Path::new("src/lib.rs")).unwrap(), + FileContent::Text("fn main() {}\n".into()) + ); + fs::write(dir.path().join("bin"), [0u8, 159, 146, 150, 0, 1]).unwrap(); + assert_eq!( + read_file(dir.path(), Path::new("bin")).unwrap(), + FileContent::Binary + ); + let big = vec![b'a'; MAX_VIEW_FILE_BYTES as usize + 1]; + fs::write(dir.path().join("big.txt"), big).unwrap(); + assert!(matches!( + read_file(dir.path(), Path::new("big.txt")).unwrap(), + FileContent::TooLarge { .. } + )); + } + + #[test] + fn writes_existing_files_inside_the_root_only() { + let dir = project(); + write_file(dir.path(), Path::new("src/lib.rs"), "fn b() {}\n").unwrap(); + assert_eq!( + fs::read_to_string(dir.path().join("src/lib.rs")).unwrap(), + "fn b() {}\n" + ); + assert!(write_file(dir.path(), Path::new("src/new.rs"), "x").is_err()); + assert!(write_file(dir.path(), Path::new("../x"), "x").is_err()); + assert!(write_file(dir.path(), Path::new("src"), "x").is_err()); + } + + #[test] + fn lists_files_kept_by_gitignore() { + let dir = project(); + let (mut files, cut) = list_files(dir.path()).unwrap(); + files.sort(); + assert!(!cut); + assert_eq!( + files, + vec![".gitignore", "README.md", "src/lib.rs", "src/nested/b.rs"] + ); + } +} diff --git a/crates/fs_explorer/src/lib.rs b/crates/fs_explorer/src/lib.rs index 987d8fad..c3b6a7f0 100644 --- a/crates/fs_explorer/src/lib.rs +++ b/crates/fs_explorer/src/lib.rs @@ -1,9 +1,11 @@ +pub mod browse; #[cfg(feature = "document-conversion")] pub mod document_search; pub mod encoding; mod explorer; pub mod file_updater; pub mod types; +pub mod watch; pub use explorer::{Explorer, is_path_gitignored}; pub use file_updater::*; diff --git a/crates/fs_explorer/src/watch.rs b/crates/fs_explorer/src/watch.rs new file mode 100644 index 00000000..50aad9f9 --- /dev/null +++ b/crates/fs_explorer/src/watch.rs @@ -0,0 +1,133 @@ +//! Watch a project tree and report which paths changed. +//! +//! A [`TreeWatcher`] watches a root recursively and, once a burst of events +//! has been quiet for the debounce period (or after four periods of +//! continuous activity), calls back with the changed paths relative to the +//! root. Events inside `.git` directories are dropped. + +use anyhow::{Context, Result}; +use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher}; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; +use tracing::warn; + +/// Watches a directory tree. Dropping it stops watching; a callback already +/// in progress finishes. +pub struct TreeWatcher { + _watcher: RecommendedWatcher, +} + +impl TreeWatcher { + /// Start watching `root`. `on_change` runs on a background thread with + /// the changed paths, relative to `root`. + pub fn start( + root: &Path, + debounce: Duration, + on_change: impl Fn(BTreeSet) + Send + 'static, + ) -> Result { + // Event paths are canonical (on macOS `/private/var/…` for + // `/var/…`), so both spellings of the root are stripped. + let roots: Vec = [Some(root.to_path_buf()), root.canonicalize().ok()] + .into_iter() + .flatten() + .collect(); + let (tx, rx) = mpsc::channel::(); + let mut watcher = + notify::recommended_watcher(move |res: Result| match res { + Ok(event) => { + for path in event.paths { + if let Some(rel) = relative(&path, &roots) + && !rel.components().any(|c| c.as_os_str() == ".git") + { + let _ = tx.send(rel); + } + } + } + Err(e) => warn!("Tree watcher error: {e}"), + }) + .context("Failed to create filesystem watcher")?; + watcher + .watch(root, RecursiveMode::Recursive) + .with_context(|| format!("Failed to watch {}", root.display()))?; + std::thread::Builder::new() + .name("tree-watcher".into()) + .spawn(move || debounce_loop(rx, debounce, on_change)) + .context("Failed to spawn watcher thread")?; + Ok(Self { _watcher: watcher }) + } +} + +fn relative(path: &Path, roots: &[PathBuf]) -> Option { + roots + .iter() + .find_map(|root| path.strip_prefix(root).ok()) + .map(Path::to_path_buf) +} + +/// Collect paths until the channel has been quiet for `debounce` (at most +/// `4 × debounce` after the first), then report them. +fn debounce_loop( + rx: mpsc::Receiver, + debounce: Duration, + on_change: impl Fn(BTreeSet), +) { + let max_wait = debounce * 4; + while let Ok(first) = rx.recv() { + let mut changed = BTreeSet::from([first]); + let deadline = Instant::now() + max_wait; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + match rx.recv_timeout(debounce.min(remaining)) { + Ok(path) => { + changed.insert(path); + if Instant::now() >= deadline { + break; + } + } + Err(mpsc::RecvTimeoutError::Timeout) => break, + Err(mpsc::RecvTimeoutError::Disconnected) => return, + } + } + on_change(changed); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + + #[test] + fn reports_changed_paths_relative_to_the_root() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("src")).unwrap(); + let seen = Arc::new(Mutex::new(BTreeSet::new())); + let sink = seen.clone(); + let _watcher = TreeWatcher::start(dir.path(), Duration::from_millis(50), move |paths| { + sink.lock().unwrap().extend(paths); + }) + .unwrap(); + // FSEvents may deliver events from just before the watch started; + // give it a moment to settle before writing. + std::thread::sleep(Duration::from_millis(200)); + std::fs::write(dir.path().join("src/a.rs"), "x").unwrap(); + + let deadline = Instant::now() + Duration::from_secs(5); + while !seen.lock().unwrap().contains(Path::new("src/a.rs")) { + assert!(Instant::now() < deadline, "no event for src/a.rs"); + std::thread::sleep(Duration::from_millis(20)); + } + } + + #[test] + fn relative_strips_either_root() { + let roots = vec![PathBuf::from("/var/x"), PathBuf::from("/private/var/x")]; + assert_eq!( + relative(Path::new("/private/var/x/a/b"), &roots), + Some(PathBuf::from("a/b")) + ); + assert_eq!(relative(Path::new("/other"), &roots), None); + } +} diff --git a/crates/ui_gpui/src/app/event_loop.rs b/crates/ui_gpui/src/app/event_loop.rs index 50ca746f..86fd51a8 100644 --- a/crates/ui_gpui/src/app/event_loop.rs +++ b/crates/ui_gpui/src/app/event_loop.rs @@ -124,6 +124,15 @@ impl Gpui { let file_text = format!("File: {filename}\n{content}"); new_message.add_text_block(&file_text, cx); } + code_assistant_core::persistence::DraftAttachment::LineComments { + comments, + } => { + if !comments.is_empty() { + let block = + code_assistant_core::line_comments::render(&comments); + new_message.add_text_block(&block, cx); + } + } } } diff --git a/crates/ui_gpui/src/blocks/comments.rs b/crates/ui_gpui/src/blocks/comments.rs new file mode 100644 index 00000000..af44db5e --- /dev/null +++ b/crates/ui_gpui/src/blocks/comments.rs @@ -0,0 +1,203 @@ +//! Comments on passages of chat messages. +//! +//! Releasing the mouse over selected text in a message shows the selection +//! pill there; its comment button opens the comment card at the same spot. +//! A comment carries the selected passage (Markdown source) as its quote. +//! A message containing the quote of a pending comment gets a border and a +//! badge; the badge opens the comment again. + +use super::BlockView; +use crate::comments::editor::{CommentEditor, CommentEditorEvent}; +use crate::comments::{CommentChange, floating, message_comments, report, selection_pill}; +use code_assistant_core::line_comments::LineComment; +use gpui_kit::component::ActiveTheme; +use gpui_kit::{ + AnyElement, ClipboardItem, Context, Pixels, Point, SharedString, Window, div, point, + prelude::*, px, +}; + +/// Where a card opened from the badge floats, relative to the block. +const BADGE_CARD_OFFSET: Point = Point { + x: px(16.), + y: px(24.), +}; + +impl BlockView { + /// The text of a text block. + pub(crate) fn text_content(&self) -> Option<&str> { + match &*self.block { + super::BlockData::TextBlock(text) => Some(&text.content), + _ => None, + } + } + + /// The selected passage of this block's text, if any. + fn current_selection(&self, cx: &gpui_kit::App) -> Option { + let text = self.markdown_state.as_ref()?.read(cx).selected_text(); + let text = text.trim(); + (!text.is_empty()).then(|| text.to_owned()) + } + + /// The mouse was released over the text: show the pill where it was + /// released when text is selected now. + pub(super) fn on_text_mouse_up(&mut self, position: Point, cx: &mut Context) { + let passage = self.current_selection(cx); + let offset = self + .block_origin + .get() + .filter(|_| passage.is_some()) + .map(|origin| position - origin); + if offset != self.selection_offset || passage != self.selected_passage { + self.selection_offset = offset; + self.selected_passage = passage; + cx.notify(); + } + } + + /// Pending comments whose quote is in `content`. + pub(super) fn comments_on(content: &str, cx: &gpui_kit::App) -> Vec { + message_comments(cx) + .into_iter() + .filter(|c| !c.excerpt.trim().is_empty() && content.contains(c.excerpt.trim())) + .collect() + } + + /// Open `comment` (new or existing) in a card at `offset` from the block. + pub(crate) fn open_comment_editor( + &mut self, + comment: LineComment, + offset: Point, + window: &mut Window, + cx: &mut Context, + ) { + let editor = cx.new(|cx| CommentEditor::new(comment, window, cx)); + self._comment_editor_subscription = Some(cx.subscribe( + &editor, + |this, _, event: &CommentEditorEvent, cx| { + match event { + CommentEditorEvent::Save(comment) => { + report(CommentChange::Upsert(comment.clone()), cx) + } + CommentEditorEvent::Delete(id) => report(CommentChange::Remove(*id), cx), + CommentEditorEvent::Cancel => {} + } + this.comment_editor = None; + this._comment_editor_subscription = None; + this.selection_offset = None; + cx.notify(); + }, + )); + self.comment_editor = Some((editor, offset)); + cx.notify(); + } + + /// Open `comment` from outside (the composer's list). + pub(crate) fn reveal_comment( + &mut self, + comment: LineComment, + window: &mut Window, + cx: &mut Context, + ) { + self.open_comment_editor(comment, BADGE_CARD_OFFSET, window, cx); + } + + /// The pill or the card, floating at its place in the block. + pub(super) fn render_text_floating( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let origin = self.block_origin.get(); + if let Some((editor, offset)) = &self.comment_editor { + let position = origin.map(|o| o + *offset + point(px(0.), px(6.))); + self.anchor.track(position, window); + return position.map(|p| floating(p, editor.clone())); + } + let position = origin + .zip(self.selection_offset) + .map(|(o, offset)| o + offset + point(px(0.), px(6.))); + self.anchor.track(position, window); + let view = cx.entity().downgrade(); + let view_for_comment = view.clone(); + // A press anywhere else dismisses the pill. It does not follow the + // window's text selection, which a press on the pill itself may clear + // before its click lands. + let dismiss = cx.listener(|this, _: &gpui_kit::MouseDownEvent, _, cx| { + this.selection_offset = None; + this.selected_passage = None; + cx.notify(); + }); + Some(floating( + position?, + div().on_mouse_down_out(dismiss).child(selection_pill( + SharedString::from(format!("message-selection-{}", cx.entity_id())), + move |_, cx| { + view.update(cx, |view, cx| { + if let Some(text) = view.selected_passage.clone() { + cx.write_to_clipboard(ClipboardItem::new_string(text)); + } + }) + .ok(); + }, + move |window, cx| { + view_for_comment + .update(cx, |view, cx| { + let (Some(text), Some(offset)) = + (view.selected_passage.clone(), view.selection_offset) + else { + return; + }; + view.open_comment_editor( + LineComment::on_message(text), + offset, + window, + cx, + ); + }) + .ok(); + }, + cx, + )), + )) + } + + /// A small badge for the comments on this block; opens the first. + pub(super) fn render_comment_badge( + &self, + comments: &[LineComment], + cx: &mut Context, + ) -> Option { + let first = comments.first()?.clone(); + let theme = cx.theme(); + Some( + div() + .id("message-comment-badge") + .absolute() + .top_1() + .right(px(30.)) + .flex() + .items_center() + .gap_1() + .px_1p5() + .h(px(22.)) + .rounded(px(6.)) + .border_1() + .border_color(theme.warning.opacity(0.6)) + .bg(theme.background.opacity(0.9)) + .cursor_pointer() + .text_xs() + .text_color(theme.muted_foreground) + .child( + gpui_kit::svg() + .size(px(12.)) + .path("icons/message_bubbles.svg") + .text_color(theme.warning), + ) + .child(comments.len().to_string()) + .on_click(cx.listener(move |this, _, window, cx| { + this.open_comment_editor(first.clone(), BADGE_CARD_OFFSET, window, cx); + })) + .into_any_element(), + ) + } +} diff --git a/crates/ui_gpui/src/blocks/mod.rs b/crates/ui_gpui/src/blocks/mod.rs index ad072823..0c73bd62 100644 --- a/crates/ui_gpui/src/blocks/mod.rs +++ b/crates/ui_gpui/src/blocks/mod.rs @@ -1,3 +1,4 @@ +mod comments; pub mod container; pub mod data; mod render; @@ -229,6 +230,24 @@ pub struct BlockView { focus_handle: FocusHandle, /// Whether [`Self::load_deferred_output`] asked for the tool's output. deferred_output_requested: bool, + /// A text block of line comments shows them listed instead of folded. + comments_expanded: bool, + /// Window origin of a text block, written each paint, so the selection + /// pill and the comment card can float at a place within it. + block_origin: Rc>>>, + /// Where the mouse was released over selected text, relative to the + /// block; the selection pill shows there. + selection_offset: Option>, + /// The passage selected when the mouse was released; kept because a + /// press on the pill may clear the window's text selection. + selected_passage: Option, + /// The open comment card and its place relative to the block. + comment_editor: Option<( + Entity, + gpui_kit::Point, + )>, + _comment_editor_subscription: Option, + anchor: crate::comments::AnchorTracker, } impl BlockView { @@ -283,6 +302,13 @@ impl BlockView { diff_dragging: false, focus_handle: cx.focus_handle(), deferred_output_requested: false, + comments_expanded: false, + block_origin: Rc::default(), + selection_offset: None, + selected_passage: None, + comment_editor: None, + _comment_editor_subscription: None, + anchor: Default::default(), } } diff --git a/crates/ui_gpui/src/blocks/render.rs b/crates/ui_gpui/src/blocks/render.rs index 668d0cb3..1f7ec122 100644 --- a/crates/ui_gpui/src/blocks/render.rs +++ b/crates/ui_gpui/src/blocks/render.rs @@ -432,14 +432,40 @@ impl BlockView { let block = Rc::clone(&self.block); match &*block { BlockData::TextBlock(block) => { + if let Some(comments) = code_assistant_core::line_comments::parse(&block.content) { + return self.render_line_comments(&comments, cx); + } let group_name = SharedString::from(format!("text-block-{}", cx.entity_id())); let body = div() .text_color(cx.theme().foreground) .child(self.markdown_view(&block.content, true, cx)) .into_any_element(); + let comments = Self::comments_on(&block.content, cx); + let warning = cx.theme().warning; + let origin = self.block_origin.clone(); div() .mt_3() + .relative() + .when(!comments.is_empty(), |d| { + d.pl_2().border_l_2().border_color(warning.opacity(0.7)) + }) + .child( + gpui_kit::canvas( + move |bounds, _, _| origin.set(Some(bounds.origin)), + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ) + .on_mouse_up( + gpui_kit::MouseButton::Left, + cx.listener(|this, event: &gpui_kit::MouseUpEvent, _, cx| { + this.on_text_mouse_up(event.position, cx) + }), + ) .child(self.with_copy_button(group_name, body, cx)) + .children(self.render_comment_badge(&comments, cx)) + .children(self.render_text_floating(window, cx)) .into_any_element() } BlockData::ThinkingBlock(block) => { @@ -848,3 +874,97 @@ impl BlockView { } } } + +impl BlockView { + /// A user message's line comments: one line saying how many, which + /// expands to each comment with its file, lines, code and text. + fn render_line_comments( + &mut self, + comments: &[code_assistant_core::line_comments::ParsedComment], + cx: &mut Context, + ) -> gpui_kit::AnyElement { + let theme = cx.theme(); + let expanded = self.comments_expanded; + let label = match comments { + [c] if c.on_message => "1 comment on a message".to_owned(), + [c] => format!("1 comment on {}:{}", c.path, c.lines), + _ => format!("{} line comments", comments.len()), + }; + let header = div() + .id(SharedString::from(format!( + "line-comments-{}", + cx.entity_id() + ))) + .flex() + .items_center() + .gap_1p5() + .cursor_pointer() + .text_sm() + .text_color(theme.muted_foreground) + .hover(|s| s.text_color(theme.foreground)) + .child( + gpui_kit::svg() + .size(px(14.)) + .path("icons/message_bubbles.svg") + .text_color(theme.muted_foreground), + ) + .child(label) + .child( + gpui_kit::svg() + .size(px(10.)) + .path(if expanded { + "icons/chevron_down.svg" + } else { + "icons/chevron_right.svg" + }) + .text_color(theme.muted_foreground), + ) + .on_click(cx.listener(|this, _, _, cx| { + this.comments_expanded = !this.comments_expanded; + cx.notify(); + })); + let items = expanded.then(|| { + comments.iter().map(|c| { + div() + .flex() + .flex_col() + .gap_1() + .pl_2() + .border_l_2() + .border_color(theme.warning.opacity(0.6)) + .child(div().text_xs().text_color(theme.muted_foreground).child( + if c.on_message { + "on a message".to_owned() + } else { + format!("{}:{}", c.path, c.lines) + }, + )) + .child( + div() + .p_1p5() + .rounded_md() + .bg(theme.muted) + .font_family("Menlo") + .text_xs() + .text_color(theme.foreground) + .whitespace_normal() + .children(c.code.lines().map(|l| div().child(l.to_owned()))), + ) + .child( + div() + .text_sm() + .text_color(theme.foreground) + .child(c.text.clone()), + ) + }) + }); + div() + .mt_3() + .flex() + .flex_col() + .gap_2() + .child(header) + .children(items.into_iter().flatten()) + .into_any_element() + } +} diff --git a/crates/ui_gpui/src/comments/editor.rs b/crates/ui_gpui/src/comments/editor.rs new file mode 100644 index 00000000..2fa3616f --- /dev/null +++ b/crates/ui_gpui/src/comments/editor.rs @@ -0,0 +1,155 @@ +//! The small card in which the user writes or edits one comment, floating +//! next to the commented lines or passage ([`super::floating`]). +//! Cmd/Ctrl-Enter saves; Escape or a press outside the card cancels. + +use code_assistant_core::line_comments::LineComment; +use gpui_kit::component::button::{Button, ButtonVariants}; +use gpui_kit::component::input::{Escape, InputEvent, Textarea, TextareaState}; +use gpui_kit::component::{ActiveTheme, Sizable}; +use gpui_kit::{ + Context, Entity, EventEmitter, FocusHandle, Focusable, Render, Subscription, Window, div, + prelude::*, px, +}; + +pub enum CommentEditorEvent { + /// The comment with the entered text. + Save(LineComment), + /// Remove the (existing) comment. + Delete(u64), + Cancel, +} + +pub struct CommentEditor { + /// The comment being written; `id` 0 for a new one. + comment: LineComment, + input: Entity, + _subscription: Subscription, +} + +impl EventEmitter for CommentEditor {} + +impl CommentEditor { + pub fn new(comment: LineComment, window: &mut Window, cx: &mut Context) -> Self { + let text = comment.text.clone(); + let input = cx.new(|cx| { + TextareaState::new(window, cx) + .auto_grow(2, 8) + .placeholder("Add a comment…") + .default_value(text) + }); + input.update(cx, |input, cx| input.focus(window, cx)); + let subscription = cx.subscribe_in(&input, window, |this, _, event, _, cx| { + if let InputEvent::PressEnter { + secondary: true, .. + } = event + { + this.save(cx); + } + }); + Self { + comment, + input, + _subscription: subscription, + } + } + + pub fn comment(&self) -> &LineComment { + &self.comment + } + + fn save(&mut self, cx: &mut Context) { + let text = self.input.read(cx).value().trim().to_owned(); + if text.is_empty() { + return; + } + let mut comment = self.comment.clone(); + comment.text = text; + cx.emit(CommentEditorEvent::Save(comment)); + } +} + +impl Focusable for CommentEditor { + fn focus_handle(&self, cx: &gpui_kit::App) -> FocusHandle { + self.input.read(cx).focus_handle(cx) + } +} + +impl Render for CommentEditor { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + let existing = self.comment.id != 0; + let id = self.comment.id; + let title = format!( + "{} {}", + if existing { + "Edit comment on" + } else { + "Comment on" + }, + self.comment.location_label() + ); + div() + .id("comment-editor") + .occlude() + .w(px(340.)) + .flex() + .flex_col() + .gap_1p5() + .p_2() + .rounded_lg() + .border_1() + .border_color(theme.border) + .bg(theme.popover) + .shadow_lg() + // A press anywhere else closes the card, like Cancel. + .on_mouse_down_out(cx.listener(|_, _, _, cx| { + cx.emit(CommentEditorEvent::Cancel); + })) + .capture_action(cx.listener(|_, _: &Escape, _, cx| { + cx.emit(CommentEditorEvent::Cancel); + cx.stop_propagation(); + })) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(title), + ) + .child(Textarea::new(&self.input)) + .child( + div() + .flex() + .items_center() + .gap_1() + .when(existing, |d| { + d.child( + Button::new("comment-delete") + .label("Delete") + .xsmall() + .ghost() + .on_click(cx.listener(move |_, _, _, cx| { + cx.emit(CommentEditorEvent::Delete(id)); + })), + ) + }) + .child(div().flex_1()) + .child( + Button::new("comment-cancel") + .label("Cancel") + .xsmall() + .ghost() + .on_click(cx.listener(|_, _, _, cx| { + cx.emit(CommentEditorEvent::Cancel); + })), + ) + .child( + Button::new("comment-save") + .label(if existing { "Update" } else { "Add comment" }) + .xsmall() + .primary() + .tooltip("⌘↵") + .on_click(cx.listener(|this, _, _, cx| this.save(cx))), + ), + ) + } +} diff --git a/crates/ui_gpui/src/comments/mod.rs b/crates/ui_gpui/src/comments/mod.rs new file mode 100644 index 00000000..9539f124 --- /dev/null +++ b/crates/ui_gpui/src/comments/mod.rs @@ -0,0 +1,149 @@ +//! Comments the user attaches to file lines or chat passages, as far as +//! they are shared between the views that make them: the floating selection +//! pill (copy, comment), the floating comment card ([`editor`]) and the +//! app-wide plumbing. +//! +//! The draft's comments live in the composer. The right panel's views report +//! changes through their own events; message blocks deep in the transcript +//! use the [`CommentBus`], which the main screen listens to. The current +//! comments are mirrored in the [`CurrentComments`] global so message blocks +//! can mark what was commented. + +pub mod editor; + +use code_assistant_core::line_comments::LineComment; +use gpui_kit::component::{ActiveTheme, Icon, Sizable, Size}; +use gpui_kit::{ + AnyElement, App, Entity, EventEmitter, Global, Pixels, Point, SharedString, Window, anchored, + deferred, div, prelude::*, px, +}; + +/// A change to the draft's comments. +#[derive(Clone, Debug)] +pub enum CommentChange { + /// Add (`id` 0) or replace a comment. + Upsert(LineComment), + Remove(u64), +} + +/// Comment changes from anywhere in the UI. +pub struct CommentBus; + +impl EventEmitter for CommentBus {} + +#[derive(Clone)] +pub struct CommentBusGlobal(pub Entity); + +impl Global for CommentBusGlobal {} + +/// Report `change` on the [`CommentBus`]; a no-op before the main screen +/// exists. +pub fn report(change: CommentChange, cx: &mut App) { + if let Some(bus) = cx.try_global::().map(|g| g.0.clone()) { + bus.update(cx, |_, cx| cx.emit(change)); + } +} + +/// The comments of the shown session's draft. +#[derive(Default)] +pub struct CurrentComments(pub Vec); + +impl Global for CurrentComments {} + +/// The draft's comments on chat messages. +pub fn message_comments(cx: &App) -> Vec { + cx.try_global::() + .map(|c| c.0.iter().filter(|c| c.on_message).cloned().collect()) + .unwrap_or_default() +} + +/// `child` floating at `position` (window coordinates) above everything, +/// kept inside the window. +pub fn floating(position: Point, child: impl IntoElement) -> AnyElement { + deferred( + anchored() + .position(position) + .snap_to_window_with_margin(px(8.)) + .child(child), + ) + .with_priority(2) + .into_any_element() +} + +/// Keeps a floating element next to content that may move under it: the +/// position is computed from the last frame's layout, so when it moved, +/// one more frame is drawn to catch up. +#[derive(Default)] +pub struct AnchorTracker { + last: Option>, +} + +impl AnchorTracker { + /// Where the anchor was last seen. + pub fn last(&self) -> Option> { + self.last + } + + /// Note where the anchor is now; `None` when it is out of view. + pub fn track(&mut self, position: Option>, window: &mut Window) { + if position != self.last { + self.last = position; + window.request_animation_frame(); + } + } +} + +/// The small pill shown at the end of a selection: copy and comment. +pub fn selection_pill( + id: impl Into, + on_copy: impl Fn(&mut Window, &mut App) + 'static, + on_comment: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> impl IntoElement { + let id: SharedString = id.into(); + let theme = cx.theme(); + let button = |suffix: &str, icon: &'static str, tooltip: &'static str| { + div() + .id(SharedString::from(format!("{id}-{suffix}"))) + .debug_selector({ + let selector = format!("{id}-{suffix}"); + move || selector.clone() + }) + .size(px(24.)) + .flex() + .items_center() + .justify_center() + .rounded_md() + .cursor_pointer() + .hover(|s| s.bg(theme.muted)) + .tooltip(move |window, cx| { + gpui_kit::component::tooltip::Tooltip::new(tooltip).build(window, cx) + }) + .child( + Icon::default() + .path(SharedString::from(icon)) + .with_size(Size::Small) + .text_color(theme.muted_foreground), + ) + }; + div() + .id(id.clone()) + .occlude() + .flex() + .items_center() + .gap_0p5() + .p_0p5() + .rounded_lg() + .border_1() + .border_color(theme.border) + .bg(theme.popover) + .shadow_md() + .child( + button("copy", "icons/copy.svg", "Copy") + .on_click(move |_, window, cx| on_copy(window, cx)), + ) + .child( + button("comment", "icons/message_circle_plus.svg", "Comment (⌘⇧M)") + .on_click(move |_, window, cx| on_comment(window, cx)), + ) +} diff --git a/crates/ui_gpui/src/input/attachment.rs b/crates/ui_gpui/src/input/attachment.rs index 6e868eac..12124d89 100644 --- a/crates/ui_gpui/src/input/attachment.rs +++ b/crates/ui_gpui/src/input/attachment.rs @@ -109,6 +109,8 @@ impl AttachmentView { .child(filename.clone()), ) .into_any_element(), + // Shown as the input's comments chip, never as a tile. + DraftAttachment::LineComments { .. } => div().into_any_element(), } } } diff --git a/crates/ui_gpui/src/input/comments.rs b/crates/ui_gpui/src/input/comments.rs new file mode 100644 index 00000000..1bd97b79 --- /dev/null +++ b/crates/ui_gpui/src/input/comments.rs @@ -0,0 +1,360 @@ +//! Line comments in the composer. +//! +//! All comments of a draft live in one [`DraftAttachment::LineComments`] +//! among the input's attachments, so they persist and send with the draft. +//! They show as one chip in the attachment row: a short preview for a single +//! comment, a count for several. Clicking the chip lists them; each entry +//! reveals its place in the right panel or removes the comment. + +use super::{InputArea, InputAreaEvent}; +use code_assistant_core::line_comments::LineComment; +use code_assistant_core::persistence::DraftAttachment; +use gpui_kit::component::{ActiveTheme, Icon, Sizable, Size, ThemeStyled}; +use gpui_kit::{ + Anchor, AnyElement, Context, Edges, SharedString, anchored, deferred, div, prelude::*, px, +}; +use std::collections::BTreeSet; + +/// Characters of a comment's text shown in the chip. +const PREVIEW_CHARS: usize = 40; + +impl InputArea { + /// The draft's line comments, in the order they were made. + pub fn comments(&self) -> &[LineComment] { + self.attachments + .iter() + .find_map(|a| match a { + DraftAttachment::LineComments { comments } => Some(comments.as_slice()), + _ => None, + }) + .unwrap_or_default() + } + + fn comments_mut(&mut self) -> &mut Vec { + let ix = match self + .attachments + .iter() + .position(|a| matches!(a, DraftAttachment::LineComments { .. })) + { + Some(ix) => ix, + None => { + self.attachments.push(DraftAttachment::LineComments { + comments: Vec::new(), + }); + self.attachments.len() - 1 + } + }; + match &mut self.attachments[ix] { + DraftAttachment::LineComments { comments } => comments, + _ => unreachable!("position matched a LineComments attachment"), + } + } + + /// Add a comment, or replace the one with the same id. + pub fn upsert_comment(&mut self, mut comment: LineComment, cx: &mut Context) { + let comments = self.comments_mut(); + match comments.iter_mut().find(|c| c.id == comment.id) { + Some(existing) => *existing = comment, + None => { + comment.id = comments.iter().map(|c| c.id).max().unwrap_or(0) + 1; + comments.push(comment); + } + } + self.comments_changed(cx); + } + + pub fn remove_comment(&mut self, id: u64, cx: &mut Context) { + self.comments_mut().retain(|c| c.id != id); + self.comments_changed(cx); + } + + fn clear_comments(&mut self, cx: &mut Context) { + self.comments_mut().clear(); + self.comments_changed(cx); + } + + /// Drop an empty comment list, save the draft and tell the panel. + fn comments_changed(&mut self, cx: &mut Context) { + self.attachments.retain( + |a| !matches!(a, DraftAttachment::LineComments { comments } if comments.is_empty()), + ); + if self.comments().is_empty() { + self.comments_popover_open = false; + } + self.rebuild_attachment_views(cx); + self.emit_content_changed(cx); + cx.emit(InputAreaEvent::CommentsChanged { + comments: self.comments().to_vec(), + }); + cx.notify(); + } + + /// The chip's label: a preview of a single comment, else a count. + pub(super) fn comments_label(comments: &[LineComment]) -> String { + match comments { + [] => String::new(), + [c] => { + let text: String = c.text.chars().take(PREVIEW_CHARS).collect(); + let ellipsis = if c.text.chars().count() > PREVIEW_CHARS { + "…" + } else { + "" + }; + format!("{} · {text}{ellipsis}", c.location_label()) + } + _ if comments.iter().any(|c| c.on_message) => { + format!("{} comments", comments.len()) + } + _ => { + let files: BTreeSet<_> = comments.iter().map(|c| &c.file).collect(); + let in_files = if files.len() == 1 { + format!("in {}", comments[0].file_name()) + } else { + format!("in {} files", files.len()) + }; + format!("{} comments {in_files}", comments.len()) + } + } + } + + pub(super) fn render_comments_chip(&self, cx: &mut Context) -> Option { + let comments = self.comments(); + if comments.is_empty() { + return None; + } + let theme = cx.theme(); + let label = Self::comments_label(comments); + Some( + div() + .relative() + .child( + div() + .id("comments-chip") + .flex() + .items_center() + .gap_1p5() + .h(px(28.)) + .max_w(px(360.)) + .pl_2() + .pr_1() + .rounded_md() + .border_1() + .border_color(theme.border) + .bg(theme.popover) + .cursor_pointer() + .hover(|s| s.bg(theme.muted)) + .child( + Icon::default() + .path(SharedString::from("icons/message_bubbles.svg")) + .with_size(Size::XSmall) + .text_color(theme.muted_foreground), + ) + .child( + div() + .min_w_0() + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .text_xs() + .text_color(theme.foreground) + .child(label), + ) + .child( + div() + .id("comments-chip-clear") + .flex_none() + .size(px(18.)) + .flex() + .items_center() + .justify_center() + .rounded_sm() + .hover(|s| s.bg(theme.danger.opacity(0.1))) + .child( + Icon::default() + .path(SharedString::from("icons/close.svg")) + .with_size(Size::XSmall) + .text_color(theme.muted_foreground), + ) + .on_click(cx.listener(|this, _, _, cx| { + cx.stop_propagation(); + this.clear_comments(cx); + })), + ) + .on_click(cx.listener(|this, _, _, cx| { + this.comments_popover_open = !this.comments_popover_open; + cx.notify(); + })), + ) + .children( + self.comments_popover_open + .then(|| self.render_comments_popover(cx)), + ) + .into_any_element(), + ) + } + + fn render_comments_popover(&self, cx: &mut Context) -> AnyElement { + let theme = cx.theme(); + let items: Vec = self + .comments() + .iter() + .map(|c| { + let first_line = c + .excerpt + .lines() + .map(str::trim) + .find(|l| !l.is_empty()) + .unwrap_or_default() + .to_owned(); + let reveal = c.clone(); + let id = c.id; + div() + .id(("comment-entry", c.id as usize)) + .flex() + .items_start() + .gap_2() + .p_1p5() + .rounded_md() + .cursor_pointer() + .hover(|s| s.bg(theme.muted)) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .gap_0p5() + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(c.location_label()), + ) + .child( + div() + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .text_xs() + .font_family("Menlo") + .text_color(theme.muted_foreground) + .child(first_line), + ) + .child( + div() + .text_sm() + .text_color(theme.foreground) + .child(c.text.clone()), + ), + ) + .child( + div() + .id(("comment-remove", c.id as usize)) + .flex_none() + .size(px(20.)) + .flex() + .items_center() + .justify_center() + .rounded_sm() + .hover(|s| s.bg(theme.danger.opacity(0.1))) + .child( + Icon::default() + .path(SharedString::from("icons/trash.svg")) + .with_size(Size::XSmall) + .text_color(theme.danger), + ) + .on_click(cx.listener(move |this, _, _, cx| { + cx.stop_propagation(); + this.remove_comment(id, cx); + })), + ) + .on_click(cx.listener(move |this, _, _, cx| { + this.comments_popover_open = false; + cx.emit(InputAreaEvent::RevealComment { + comment: reveal.clone(), + }); + cx.notify(); + })) + .into_any_element() + }) + .collect(); + deferred( + anchored() + .anchor(Anchor::BottomLeft) + .snap_to_window_with_margin(Edges::all(px(8.))) + .child( + div() + .id("comments-popover") + .occlude() + .popover_style(cx) + .shadow_md() + .p_1() + .mb_1() + .w(px(380.)) + .max_h(px(320.)) + .overflow_y_scroll() + .flex() + .flex_col() + .gap_0p5() + .on_mouse_down_out(cx.listener(|this, _, _, cx| { + this.comments_popover_open = false; + cx.notify(); + })) + .children(items), + ), + ) + .into_any_element() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn comment(file: &str, text: &str) -> LineComment { + LineComment { + id: 0, + file: PathBuf::from(file), + start_line: 3, + end_line: 5, + old_side: false, + in_diff: false, + on_message: false, + excerpt: "x".into(), + text: text.into(), + } + } + + #[test] + fn label_previews_one_comment_and_counts_several() { + assert_eq!( + InputArea::comments_label(&[comment("/p/src/lib.rs", "no unwrap here")]), + "lib.rs:3-5 · no unwrap here" + ); + let long = "a".repeat(50); + assert!(InputArea::comments_label(&[comment("/p/a.rs", &long)]).ends_with("a…")); + assert_eq!( + InputArea::comments_label(&[comment("/p/a.rs", "x"), comment("/p/a.rs", "y")]), + "2 comments in a.rs" + ); + let mut on_message = LineComment::on_message("a passage"); + on_message.text = "why?".into(); + assert_eq!( + InputArea::comments_label(std::slice::from_ref(&on_message)), + "message · why?" + ); + assert_eq!( + InputArea::comments_label(&[on_message, comment("/p/a.rs", "x")]), + "2 comments" + ); + assert_eq!( + InputArea::comments_label(&[ + comment("/p/a.rs", "x"), + comment("/p/b.rs", "y"), + comment("/p/b.rs", "z") + ]), + "3 comments in 2 files" + ); + } +} diff --git a/crates/ui_gpui/src/input/mod.rs b/crates/ui_gpui/src/input/mod.rs index d8275af8..779418fb 100644 --- a/crates/ui_gpui/src/input/mod.rs +++ b/crates/ui_gpui/src/input/mod.rs @@ -1,4 +1,5 @@ pub mod attachment; +mod comments; pub mod mcp_selector; pub mod model_selector; pub mod permission_selector; @@ -74,6 +75,15 @@ pub enum InputAreaEvent { WorktreeCreateRequested, /// Worktree selector opened — request fresh data from backend WorktreeRefreshRequested, + /// The draft's line comments changed (added, edited, removed, sent or + /// loaded with a draft). + CommentsChanged { + comments: Vec, + }, + /// The user picked a comment in the chip's list: show its place. + RevealComment { + comment: code_assistant_core::line_comments::LineComment, + }, } /// The composer's slash menu (see [`skill_completion`]). @@ -99,6 +109,8 @@ pub struct InputArea { current_permission_tier: PermissionTier, attachments: Vec, attachment_views: Vec>, + /// The line comments chip shows its list. + comments_popover_open: bool, focus_handle: FocusHandle, // Agent state for button rendering @@ -172,6 +184,7 @@ impl InputArea { current_permission_tier: PermissionTier::default(), attachments: Vec::new(), attachment_views: Vec::new(), + comments_popover_open: false, focus_handle: cx.focus_handle(), agent_is_running: false, @@ -207,7 +220,11 @@ impl InputArea { // Update attachments self.attachments = attachments; + self.comments_popover_open = false; self.rebuild_attachment_views(cx); + cx.emit(InputAreaEvent::CommentsChanged { + comments: self.comments().to_vec(), + }); } /// Set content for editing a message (creates a branch). @@ -326,8 +343,15 @@ impl InputArea { self.refresh_slash_menu(cx); // Clear attachments + let had_comments = !self.comments().is_empty(); self.attachments.clear(); self.attachment_views.clear(); + self.comments_popover_open = false; + if had_comments { + cx.emit(InputAreaEvent::CommentsChanged { + comments: Vec::new(), + }); + } // Clear edit mode self.clear_edit_mode(); @@ -479,6 +503,10 @@ impl InputArea { self.attachment_views.clear(); for (index, attachment) in self.attachments.iter().enumerate() { + // Comments show as one chip, not as a tile. + if matches!(attachment, DraftAttachment::LineComments { .. }) { + continue; + } let attachment_view = cx.new(|cx| AttachmentView::new(attachment.clone(), index, cx)); // Subscribe to attachment events @@ -904,6 +932,7 @@ impl InputArea { }) // Attachments area - show image previews when available .when(!self.attachments.is_empty(), |parent| { + let comments_chip = self.render_comments_chip(cx); parent.child( div() .p_2() @@ -911,9 +940,11 @@ impl InputArea { .border_color(cx.theme().border) .flex() .flex_row() + .items_end() .gap_2() .flex_wrap() - .children(self.attachment_views.iter().cloned()), + .children(self.attachment_views.iter().cloned()) + .children(comments_chip), ) }) // Main input row: [text field + selectors] [buttons] diff --git a/crates/ui_gpui/src/lib.rs b/crates/ui_gpui/src/lib.rs index e2278c38..fff3c082 100644 --- a/crates/ui_gpui/src/lib.rs +++ b/crates/ui_gpui/src/lib.rs @@ -2,6 +2,7 @@ pub mod shared; mod app; pub mod blocks; +pub mod comments; pub mod input; pub mod main_screen; pub mod messages; diff --git a/crates/ui_gpui/src/main_screen/mod.rs b/crates/ui_gpui/src/main_screen/mod.rs index d95453aa..cc090a5d 100644 --- a/crates/ui_gpui/src/main_screen/mod.rs +++ b/crates/ui_gpui/src/main_screen/mod.rs @@ -228,6 +228,9 @@ pub struct MainScreen { context_limit_cache: Option<(String, u32)>, // Subscription to input area events _input_area_subscription: Subscription, + _right_panel_subscription: Subscription, + _open_file_subscription: Subscription, + _comment_bus_subscription: Subscription, _plan_banner_subscription: Subscription, _project_sidebar_subscription: Subscription, _new_project_dialog_subscription: Option, @@ -281,6 +284,33 @@ impl MainScreen { // Create the right (review) sidebar panel. let right_panel = cx.new(|cx| right_panel::RightPanel::new(window, cx)); + // File paths clicked anywhere (e.g. in tool cards) open in the panel. + let open_file_bus = cx.new(|_| crate::shared::open_file::OpenFileBus); + cx.set_global(crate::shared::open_file::OpenFileGlobal( + open_file_bus.clone(), + )); + let open_file_subscription = cx.subscribe( + &open_file_bus, + |this: &mut Self, _, request: &crate::shared::open_file::OpenFileRequest, cx| { + this.open_file_in_panel(request.clone(), cx); + }, + ); + // Comments made on chat messages change the composer's draft too. + let comment_bus = cx.new(|_| crate::comments::CommentBus); + cx.set_global(crate::comments::CommentBusGlobal(comment_bus.clone())); + let comment_bus_subscription = cx.subscribe( + &comment_bus, + |this: &mut Self, _, change: &crate::comments::CommentChange, cx| { + this.apply_comment_change(change, cx); + }, + ); + // Comment edits in the panel change the composer's draft. + let right_panel_subscription = cx.subscribe( + &right_panel, + |this: &mut Self, _, change: &right_panel::CommentChange, cx| { + this.apply_comment_change(change, cx); + }, + ); let mut root_view = Self { input_area, @@ -319,6 +349,9 @@ impl MainScreen { resize_start_width: 0.0, context_limit_cache: None, _input_area_subscription: input_area_subscription, + _right_panel_subscription: right_panel_subscription, + _open_file_subscription: open_file_subscription, + _comment_bus_subscription: comment_bus_subscription, _plan_banner_subscription: plan_banner_subscription, _project_sidebar_subscription: project_sidebar_subscription, _new_project_dialog_subscription: None, @@ -352,6 +385,109 @@ impl MainScreen { _window: &mut gpui_kit::Window, cx: &mut Context, ) { + self.toggle_right_sidebar(cx); + } + + /// Apply a comment change from the panel or the transcript to the draft. + fn apply_comment_change( + &mut self, + change: &crate::comments::CommentChange, + cx: &mut Context, + ) { + self.input_area.update(cx, |input, cx| match change { + crate::comments::CommentChange::Upsert(comment) => { + input.upsert_comment(comment.clone(), cx) + } + crate::comments::CommentChange::Remove(id) => input.remove_comment(*id, cx), + }); + } + + /// Scroll to the message quoted by `comment` and open the comment there. + fn reveal_message_comment( + &mut self, + comment: code_assistant_core::line_comments::LineComment, + window: &mut gpui_kit::Window, + cx: &mut Context, + ) { + let Some(gpui) = cx.try_global::() else { + return; + }; + let containers = gpui.message_queue.lock().unwrap().clone(); + let quote = comment.excerpt.trim().to_owned(); + let found = containers.iter().enumerate().find_map(|(ix, container)| { + container + .read(cx) + .elements() + .into_iter() + .find(|block| { + block + .read(cx) + .text_content() + .is_some_and(|text| text.contains("e)) + }) + .map(|block| (ix, block)) + }); + let Some((ix, block)) = found else { + gpui.display_status("The commented message is not shown"); + return; + }; + self.messages_view.read(cx).reveal_message(ix); + block.update(cx, |block, cx| block.reveal_comment(comment, window, cx)); + } + + /// Show a file in the right panel's Files view, opening the panel. A file + /// of another project than the session's is not shown. + fn open_file_in_panel( + &mut self, + request: crate::shared::open_file::OpenFileRequest, + cx: &mut Context, + ) { + let session_project = self + .current_session_id + .as_ref() + .and_then(|id| self.sessions.iter().find(|s| &s.id == id)) + .map(|s| s.initial_project.clone()); + if let (Some(project), Some(session_project)) = (&request.project, &session_project) + && !project.is_empty() + && project != session_project + { + if let Some(gpui) = cx.try_global::() { + gpui.display_status(format!( + "{} belongs to project {project}, not this session's", + request.path + )); + } + return; + } + if self.current_session_id.is_none() { + return; + } + if self.right_sidebar_collapsed { + self.toggle_right_sidebar(cx); + } + self.right_panel.update(cx, |panel, cx| { + panel.open_file(request.path, request.line, cx) + }); + } + + /// A click on a view in the title bar's switch. + fn on_right_panel_view_clicked( + &mut self, + view: right_panel::RightPanelView, + cx: &mut Context, + ) { + if self.right_sidebar_collapsed { + self.toggle_right_sidebar(cx); + } else if self.right_panel.read(cx).active_view() == view { + self.toggle_right_sidebar(cx); + return; + } + self.right_panel + .update(cx, |panel, cx| panel.set_active_view(view, cx)); + cx.notify(); + } + + fn toggle_right_sidebar(&mut self, cx: &mut Context) { let should_expand = self.right_sidebar_collapsed; self.right_sidebar_collapsed = !self.right_sidebar_collapsed; self.right_animator.start(should_expand); @@ -670,6 +806,25 @@ impl MainScreen { self.report_user_activity(session_id, cx); } } + InputAreaEvent::CommentsChanged { comments } => { + // Message blocks read the comments to mark what was commented. + cx.set_global(crate::comments::CurrentComments(comments.clone())); + cx.refresh_windows(); + let comments = comments.clone(); + self.right_panel + .update(cx, |panel, cx| panel.set_comments(comments, cx)); + } + InputAreaEvent::RevealComment { comment } if comment.on_message => { + self.reveal_message_comment(comment.clone(), _window, cx); + } + InputAreaEvent::RevealComment { comment } => { + if self.right_sidebar_collapsed { + self.toggle_right_sidebar(cx); + } + let comment = comment.clone(); + self.right_panel + .update(cx, |panel, cx| panel.reveal_comment(comment, cx)); + } InputAreaEvent::FocusRequested => { // Handle focus request if needed } @@ -1912,31 +2067,30 @@ 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)), - ) + // Right panel views: a click opens the panel on + // a view or switches to it; clicking the shown + // one closes the panel. + .child({ + let shown = (!self.right_sidebar_collapsed) + .then(|| self.right_panel.read(cx).active_view()); + let screen = cx.entity().downgrade(); + div().mr_1().child( + crate::shared::segmented_switch::segmented_toggle( + "right-panel-view", + right_panel::RightPanelView::ALL, + shown, + move |view, _, cx| { + screen + .update(cx, |screen, cx| { + screen.on_right_panel_view_clicked(view, cx) + }) + .ok(); + }, + window, + cx, + ), + ) + }) .child( div() .id("about-btn") diff --git a/crates/ui_gpui/src/main_screen/right_panel/file_filter.rs b/crates/ui_gpui/src/main_screen/right_panel/file_filter.rs new file mode 100644 index 00000000..0223dd99 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/file_filter.rs @@ -0,0 +1,135 @@ +//! Fuzzy matching of project paths for the Files view's filter field. +//! +//! A query matches a path when its characters appear in order (ignoring +//! case). Matches in the file name, at word starts and in runs score higher, +//! so `svc` finds `session/service.rs` before `src/vendor/c.rs`. + +/// One matching path and the byte offsets of the matched characters. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct FileMatch { + pub path: String, + pub positions: Vec, + score: i64, +} + +/// The best `limit` matches of `query` among `paths`, best first. Spaces in +/// the query are ignored. +pub(crate) fn filter_paths(paths: &[String], query: &str, limit: usize) -> Vec { + let query: Vec = query + .chars() + .filter(|c| !c.is_whitespace()) + .flat_map(char::to_lowercase) + .collect(); + if query.is_empty() { + return Vec::new(); + } + let mut matches: Vec = paths + .iter() + .filter_map(|path| match_path(path, &query)) + .collect(); + matches.sort_by(|a, b| { + b.score + .cmp(&a.score) + .then_with(|| a.path.len().cmp(&b.path.len())) + .then_with(|| a.path.cmp(&b.path)) + }); + matches.truncate(limit); + matches +} + +fn match_path(path: &str, query: &[char]) -> Option { + let name_start = path.rfind('/').map_or(0, |i| i + 1); + // Prefer matching the query's tail in the file name: walk backwards so + // each query character takes the rightmost possible position. + let chars: Vec<(usize, char)> = path.char_indices().collect(); + let mut positions = Vec::with_capacity(query.len()); + let mut qi = query.len(); + for &(offset, c) in chars.iter().rev() { + if qi == 0 { + break; + } + if c.to_lowercase().eq(std::iter::once(query[qi - 1])) { + positions.push(offset); + qi -= 1; + } + } + if qi > 0 { + return None; + } + positions.reverse(); + + let mut score = 0i64; + let mut previous: Option = None; + for &offset in &positions { + score += 1; + if offset >= name_start { + score += 2; + } + let at_word_start = offset == 0 + || path[..offset] + .chars() + .next_back() + .is_some_and(|p| matches!(p, '/' | '_' | '-' | '.' | ' ')); + if at_word_start { + score += 3; + } + if let Some(prev) = previous { + let gap = path[prev..offset].chars().count().saturating_sub(1); + if gap == 0 { + score += 4; + } else { + score -= gap.min(8) as i64 / 2; + } + } + previous = Some(offset); + } + Some(FileMatch { + path: path.to_owned(), + positions, + score, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn paths(list: &[&str]) -> Vec { + list.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn matches_in_order_ignoring_case() { + let all = paths(&["src/Lib.rs", "README.md", "src/main.rs"]); + let found: Vec<_> = filter_paths(&all, "LIB", 10) + .into_iter() + .map(|m| m.path) + .collect(); + assert_eq!(found, vec!["src/Lib.rs"]); + assert!(filter_paths(&all, "zz", 10).is_empty()); + assert!(filter_paths(&all, " ", 10).is_empty()); + } + + #[test] + fn prefers_file_names_and_runs() { + let all = paths(&[ + "src/vendor/c.rs", + "crates/core/src/session/service.rs", + "docs/service-notes.md", + ]); + let found: Vec<_> = filter_paths(&all, "service.rs", 10) + .into_iter() + .map(|m| m.path) + .collect(); + assert_eq!(found[0], "crates/core/src/session/service.rs"); + } + + #[test] + fn reports_matched_positions_and_limits_results() { + let all = paths(&["a/b.rs", "a/bb.rs", "a/bbb.rs"]); + let found = filter_paths(&all, "b.rs", 2); + assert_eq!(found.len(), 2); + assert_eq!(found[0].path, "a/b.rs"); + assert_eq!(found[0].positions, vec![2, 3, 4, 5]); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/file_viewer.rs b/crates/ui_gpui/src/main_screen/right_panel/file_viewer.rs new file mode 100644 index 00000000..079181bb --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/file_viewer.rs @@ -0,0 +1,741 @@ +//! One project file in a code editor: line numbers, syntax highlighting, +//! search, editing with auto-save, and line comments. +//! +//! Edits are saved once typing pauses for [`AUTO_SAVE_DELAY`], when another +//! file is opened, and at once on Cmd/Ctrl-S. While the file changed on disk +//! under unsaved edits, nothing is saved until the user picks a side. +//! +//! The text is read and written through the `SessionService`. The editor is +//! created in `render` once a load arrives (creating it needs the window). +//! A reload while there are no unsaved edits replaces the text in place and +//! keeps the scroll position; with unsaved edits the view only notes that the +//! file changed on disk and lets the user choose. +//! +//! Comments on this file show as tinted lines (editor decorations, which +//! follow edits). After a load or save each comment is found again by its +//! excerpt ([`line_comments::locate`]); one that moved is updated. + +use crate::Gpui; +use crate::comments::editor::{CommentEditor, CommentEditorEvent}; +use crate::comments::{AnchorTracker, CommentChange, floating, selection_pill}; +use crate::tool_cards::diff_syntax::language_for_path; +use code_assistant_core::line_comments::{self, LineComment}; +use code_assistant_core::session::FileContent; +use gpui_kit::component::button::{Button, ButtonVariants}; +use gpui_kit::component::input::{ + Editor, EditorState, InputEvent, RopeExt, TextDecoration, TextDecorationCollection, +}; +use gpui_kit::component::{ActiveTheme, Sizable, v_flex}; +use gpui_kit::{ + Context, Entity, EventEmitter, FocusHandle, Focusable, HighlightStyle, KeyDownEvent, Pixels, + Point, Render, Subscription, Task, Window, div, point, prelude::*, px, +}; +use std::path::PathBuf; +use std::time::Duration; + +/// Quiet period after the last edit before it is saved. +const AUTO_SAVE_DELAY: Duration = Duration::from_millis(1000); + +/// What the viewer shows. +enum ViewerState { + Empty, + Loading, + /// Text read from disk, waiting for `render` to put it into the editor. + Loaded(String), + Editing, + Binary, + TooLarge(u64), + Failed(String), +} + +pub struct FileViewer { + session_id: Option, + /// The shown file, `/`-separated and relative to `root`. + path: Option, + root: Option, + state: ViewerState, + editor: Option>, + /// Tinted comment lines. + marks: Option, + /// The text as last read from or written to disk. + saved_text: String, + dirty: bool, + /// The file changed on disk while there were unsaved edits. + disk_changed: bool, + save_error: Option, + /// Every comment of the draft; the ones on this file are marked. + comments: Vec, + comment_editor: Option>, + /// A comment to select once the editor holds its file. + pending_reveal: Option, + /// A line (1-based) to select once the editor holds its file. + pending_line: Option, + load_task: Option>, + save_task: Option>, + auto_save_task: Option>, + focus_handle: FocusHandle, + _editor_subscriptions: Vec, + _comment_editor_subscription: Option, + /// Keeps the selection pill and the comment card next to their lines. + anchor: AnchorTracker, + /// The selection whose pill a press elsewhere dismissed. + pill_dismissed: Option>, +} + +impl EventEmitter for FileViewer {} + +impl FileViewer { + pub fn new(cx: &mut Context) -> Self { + Self { + session_id: None, + path: None, + root: None, + state: ViewerState::Empty, + editor: None, + marks: None, + saved_text: String::new(), + dirty: false, + disk_changed: false, + save_error: None, + comments: Vec::new(), + comment_editor: None, + pending_reveal: None, + pending_line: None, + load_task: None, + save_task: None, + auto_save_task: None, + focus_handle: cx.focus_handle(), + _editor_subscriptions: Vec::new(), + _comment_editor_subscription: None, + anchor: AnchorTracker::default(), + pill_dismissed: None, + } + } + + pub fn path(&self) -> Option<&str> { + self.path.as_deref() + } + + pub fn is_dirty(&self) -> bool { + self.dirty + } + + /// The shown file's absolute path, once its root is known. + fn file(&self) -> Option { + Some(self.root.as_ref()?.join(self.path.as_ref()?)) + } + + /// Show `path` of `session_id`'s project; `None` shows nothing. Unsaved + /// edits of the previous file are saved first. + pub fn open( + &mut self, + session_id: Option, + path: Option, + cx: &mut Context, + ) { + if self.session_id == session_id && self.path == path { + return; + } + // Edits of the file being left are saved, not dropped. + self.save_detached(cx); + self.session_id = session_id; + self.path = path; + self.editor = None; + self.marks = None; + self._editor_subscriptions.clear(); + self.dirty = false; + self.disk_changed = false; + self.save_error = None; + self.close_comment_editor(cx); + self.state = if self.path.is_some() { + ViewerState::Loading + } else { + ViewerState::Empty + }; + self.load(cx); + cx.notify(); + } + + /// Read the shown file again (it changed on disk). + pub fn reload(&mut self, cx: &mut Context) { + self.load(cx); + } + + pub fn set_comments(&mut self, comments: Vec, cx: &mut Context) { + if self.comments != comments { + self.comments = comments; + self.reanchor_and_mark(cx); + cx.notify(); + } + } + + /// Select `comment`'s lines and open it for editing, once the file is in + /// the editor. + pub fn reveal(&mut self, comment: LineComment, cx: &mut Context) { + self.pending_reveal = Some(comment); + cx.notify(); + } + + /// Select line `line` (1-based) once the file is in the editor. + pub fn reveal_line(&mut self, line: usize, cx: &mut Context) { + self.pending_line = Some(line); + cx.notify(); + } + + fn load(&mut self, cx: &mut Context) { + let (Some(session_id), Some(path)) = (self.session_id.clone(), self.path.clone()) else { + self.load_task = None; + return; + }; + let Some(service) = cx + .try_global::() + .and_then(|gpui| gpui.session_service()) + else { + return; + }; + self.load_task = Some(cx.spawn(async move |this, cx| { + let result = service + .read_project_file(session_id, PathBuf::from(&path)) + .await; + this.update(cx, |this, cx| { + if this.path.as_deref() != Some(path.as_str()) { + return; + } + match result { + Ok(file) => { + this.root = Some(file.root); + match file.content { + FileContent::Text(text) => this.loaded(text, cx), + FileContent::Binary => this.state = ViewerState::Binary, + FileContent::TooLarge { size } => { + this.state = ViewerState::TooLarge(size) + } + } + } + Err(e) => this.state = ViewerState::Failed(format!("{e:#}")), + } + cx.notify(); + }) + .ok(); + })); + } + + fn loaded(&mut self, text: String, cx: &mut Context) { + if text == self.saved_text && matches!(self.state, ViewerState::Editing) { + // Our own save coming back through the watcher. + return; + } + if self.dirty && matches!(self.state, ViewerState::Editing) { + let current = self.editor.as_ref().map(|e| e.read(cx).value().to_string()); + if current.as_deref() != Some(text.as_str()) { + self.disk_changed = true; + } + return; + } + self.state = ViewerState::Loaded(text); + } + + /// Put loaded text into the editor; called from `render`. + fn apply_loaded(&mut self, text: String, window: &mut Window, cx: &mut Context) { + self.saved_text = text.clone(); + self.dirty = false; + self.disk_changed = false; + match &self.editor { + Some(editor) => editor.update(cx, |editor, cx| { + let scroll = editor.scroll_offset(); + let selection = editor.selected_range(); + editor.set_value(text, window, cx); + editor.set_selected_range(selection, cx); + editor.set_scroll_offset(scroll, cx); + }), + None => { + let language = self + .path + .as_deref() + .and_then(language_for_path) + .unwrap_or("text"); + let editor = cx.new(|cx| { + EditorState::new(window, cx) + .language(language) + .line_number(true) + .searchable(true) + .default_value(text) + }); + self._editor_subscriptions = vec![ + cx.subscribe(&editor, |this, editor, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + let dirty = editor.read(cx).value().as_ref() != this.saved_text; + if dirty != this.dirty { + this.dirty = dirty; + cx.notify(); + } + if dirty { + this.schedule_auto_save(cx); + } + } + }), + // Selection moves change what the header offers. + cx.observe(&editor, |_, _, cx| cx.notify()), + ]; + self.marks = Some(editor.update(cx, |editor, cx| { + editor.create_decorations_collection(Vec::new(), cx) + })); + self.editor = Some(editor); + } + } + self.state = ViewerState::Editing; + self.reanchor_and_mark(cx); + } + + /// Find this file's comments again in the editor's text, update the ones + /// that moved, and tint their lines. + fn reanchor_and_mark(&mut self, cx: &mut Context) { + let (Some(editor), Some(marks), Some(file)) = (&self.editor, &self.marks, self.file()) + else { + return; + }; + let text = editor.read(cx).text().clone(); + let source = text.to_string(); + let lines: Vec<&str> = source.lines().collect(); + let color = cx.theme().warning.opacity(0.16); + let mut decorations = Vec::new(); + let mut moved = Vec::new(); + for comment in self + .comments + .iter() + .filter(|c| !c.in_diff && c.file == file) + { + let Some((start, end)) = line_comments::locate(&lines, comment) else { + continue; + }; + if (start, end) != (comment.start_line, comment.end_line) { + let mut updated = comment.clone(); + updated.start_line = start; + updated.end_line = end; + moved.push(updated); + } + decorations.push(TextDecoration::new( + text.line_start_offset(start - 1)..text.line_end_offset(end - 1), + HighlightStyle { + background_color: Some(color), + ..Default::default() + }, + )); + } + marks.set(decorations, cx); + for comment in moved { + cx.emit(CommentChange::Upsert(comment)); + } + } + + /// The selected lines (1-based, inclusive) and their text; the cursor's + /// line without a selection. + fn selected_lines(&self, cx: &gpui_kit::App) -> Option<(usize, usize, String)> { + let editor = self.editor.as_ref()?.read(cx); + let text = editor.text(); + let range = editor.selected_range(); + let start = text.offset_to_point(range.start).row; + let mut end = text.offset_to_point(range.end).row; + // A selection ending at the start of a line does not include it. + if end > start && text.line_start_offset(end) == range.end { + end -= 1; + } + let excerpt = text + .slice(text.line_start_offset(start)..text.line_end_offset(end)) + .to_string(); + Some((start + 1, end + 1, excerpt)) + } + + /// The comment on this file that covers the cursor's line, if any. + fn comment_at_cursor(&self, cx: &gpui_kit::App) -> Option { + let (start, end, _) = self.selected_lines(cx)?; + let file = self.file()?; + self.comments + .iter() + .find(|c| !c.in_diff && c.file == file && c.start_line <= end && start <= c.end_line) + .cloned() + } + + /// Open the comment editor for the selected lines (or the comment there). + pub fn start_comment(&mut self, window: &mut Window, cx: &mut Context) { + let comment = match self.comment_at_cursor(cx) { + Some(existing) => existing, + None => { + let (Some((start, end, excerpt)), Some(file)) = + (self.selected_lines(cx), self.file()) + else { + return; + }; + LineComment { + id: 0, + file, + start_line: start, + end_line: end, + old_side: false, + in_diff: false, + on_message: false, + excerpt, + text: String::new(), + } + } + }; + self.open_comment_editor(comment, window, cx); + } + + fn open_comment_editor( + &mut self, + comment: LineComment, + window: &mut Window, + cx: &mut Context, + ) { + let editor = cx.new(|cx| CommentEditor::new(comment, window, cx)); + self._comment_editor_subscription = Some(cx.subscribe_in( + &editor, + window, + |this, _, event: &CommentEditorEvent, window, cx| { + match event { + CommentEditorEvent::Save(comment) => { + cx.emit(CommentChange::Upsert(comment.clone())) + } + CommentEditorEvent::Delete(id) => cx.emit(CommentChange::Remove(*id)), + CommentEditorEvent::Cancel => {} + } + this.close_comment_editor(cx); + if let Some(editor) = &this.editor { + editor.update(cx, |editor, cx| editor.focus(window, cx)); + } + }, + )); + self.comment_editor = Some(editor); + cx.notify(); + } + + fn close_comment_editor(&mut self, cx: &mut Context) { + self.comment_editor = None; + self._comment_editor_subscription = None; + cx.notify(); + } + + /// Where the selection pill and the comment card float, in window + /// coordinates, from the editor's last layout: the pill under the end of + /// a non-empty selection while it is in view; the card under the + /// commented lines (kept inside the editor). + fn floating_positions( + &self, + cx: &gpui_kit::App, + ) -> (Option>, Option>) { + let Some(editor) = &self.editor else { + return (None, None); + }; + let editor = editor.read(cx); + let visible = editor.input_bounds(); + if let Some(card) = &self.comment_editor { + let comment = card.read(cx).comment(); + let text = editor.text(); + let row = comment.end_line.max(1).min(text.lines_len().max(1)) - 1; + let offset = text.line_start_offset(row); + let position = editor.range_to_bounds(&(offset..offset)).map(|b| { + let y = (b.bottom() + px(4.)).clamp(visible.top(), visible.bottom() - px(40.)); + point(b.left(), y) + }); + return (None, position); + } + let range = editor.selected_range(); + if range.is_empty() || self.pill_dismissed.as_ref() == Some(&range) { + return (None, None); + } + let pill = editor + .range_to_bounds(&(range.end..range.end)) + .filter(|b| visible.contains(&b.origin)) + .map(|b| point(b.left(), b.bottom() + px(2.))); + (pill, None) + } + + fn copy_selection(&self, cx: &mut gpui_kit::App) { + if let Some(editor) = &self.editor { + let text = editor.read(cx).selected_value().to_string(); + if !text.is_empty() { + cx.write_to_clipboard(gpui_kit::ClipboardItem::new_string(text)); + } + } + } + + /// Select a revealed comment's lines and open it. + fn apply_reveal(&mut self, comment: LineComment, window: &mut Window, cx: &mut Context) { + let Some(editor) = &self.editor else { + return; + }; + let lines = comment.start_line.max(1) - 1..comment.end_line.max(1) - 1; + editor.update(cx, |editor, cx| { + let text = editor.text(); + let last = text.lines_len().saturating_sub(1); + let range = text.line_start_offset(lines.start.min(last)) + ..text.line_end_offset(lines.end.min(last)); + editor.set_selected_range(range, cx); + }); + self.open_comment_editor(comment, window, cx); + } + + /// Save once no edit came for [`AUTO_SAVE_DELAY`]. + fn schedule_auto_save(&mut self, cx: &mut Context) { + self.auto_save_task = Some(cx.spawn(async move |this, cx| { + cx.background_executor().timer(AUTO_SAVE_DELAY).await; + this.update(cx, |this, cx| { + if this.dirty && !this.disk_changed { + this.save(cx); + } + }) + .ok(); + })); + } + + /// Write unsaved edits without waiting for the result (the view is about + /// to show something else). + fn save_detached(&mut self, cx: &mut Context) { + self.auto_save_task = None; + if !self.dirty || self.disk_changed { + return; + } + let (Some(editor), Some(session_id), Some(path)) = + (&self.editor, self.session_id.clone(), self.path.clone()) + else { + return; + }; + let Some(service) = cx + .try_global::() + .and_then(|gpui| gpui.session_service()) + else { + return; + }; + let text = editor.read(cx).value().to_string(); + cx.background_spawn(async move { + if let Err(e) = service + .write_project_file(session_id, PathBuf::from(&path), text) + .await + { + tracing::warn!("Files view: could not save {path}: {e:#}"); + } + }) + .detach(); + self.dirty = false; + } + + pub fn save(&mut self, cx: &mut Context) { + self.auto_save_task = None; + let (Some(editor), Some(session_id), Some(path)) = + (&self.editor, self.session_id.clone(), self.path.clone()) + else { + return; + }; + if !self.dirty && !self.disk_changed { + return; + } + let Some(service) = cx + .try_global::() + .and_then(|gpui| gpui.session_service()) + else { + return; + }; + let text = editor.read(cx).value().to_string(); + self.save_task = Some(cx.spawn(async move |this, cx| { + let result = service + .write_project_file(session_id, PathBuf::from(&path), text.clone()) + .await; + this.update(cx, |this, cx| { + if this.path.as_deref() != Some(path.as_str()) { + return; + } + match result { + Ok(()) => { + this.saved_text = text; + this.dirty = this + .editor + .as_ref() + .is_some_and(|e| e.read(cx).value().as_ref() != this.saved_text); + this.disk_changed = false; + this.save_error = None; + this.reanchor_and_mark(cx); + } + Err(e) => this.save_error = Some(format!("Could not save: {e:#}")), + } + cx.notify(); + }) + .ok(); + })); + } + + /// Drop unsaved edits and show the file as it is on disk. + fn discard_edits(&mut self, cx: &mut Context) { + self.dirty = false; + self.disk_changed = false; + self.load(cx); + cx.notify(); + } + + fn notice( + &self, + text: impl Into, + cx: &Context, + ) -> gpui_kit::AnyElement { + v_flex() + .size_full() + .items_center() + .justify_center() + .p_4() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(text.into()) + .into_any_element() + } + + fn render_banner(&self, cx: &mut Context) -> Option { + let theme = cx.theme(); + let (text, actions) = if let Some(error) = &self.save_error { + (error.clone(), false) + } else if self.disk_changed { + ("The file changed on disk.".to_owned(), true) + } else { + return None; + }; + Some( + div() + .flex_none() + .flex() + .items_center() + .gap_1() + .px_2() + .py_1() + .bg(theme.warning.opacity(0.12)) + .text_xs() + .text_color(theme.foreground) + .child(div().flex_1().child(text)) + .when(actions, |d| { + d.child( + Button::new("file-reload-disk") + .label("Reload") + .xsmall() + .ghost() + .on_click(cx.listener(|this, _, _, cx| this.discard_edits(cx))), + ) + .child( + Button::new("file-keep-mine") + .label("Overwrite") + .xsmall() + .ghost() + .on_click(cx.listener(|this, _, _, cx| this.save(cx))), + ) + }) + .into_any_element(), + ) + } +} + +impl FileViewer { + /// The selection pill or the comment card, floating next to the lines. + fn render_floating( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let (pill, card) = self.floating_positions(cx); + self.anchor.track(pill.or(card), window); + if let (Some(position), Some(editor)) = (card, &self.comment_editor) { + return Some(floating(position, editor.clone())); + } + let position = pill?; + let viewer = cx.entity().downgrade(); + let viewer_for_comment = viewer.clone(); + // A press elsewhere hides the pill until the selection changes. + let dismiss = cx.listener(|this, _: &gpui_kit::MouseDownEvent, _, cx| { + this.pill_dismissed = this.editor.as_ref().map(|e| e.read(cx).selected_range()); + cx.notify(); + }); + Some(floating( + position, + div().on_mouse_down_out(dismiss).child(selection_pill( + "file-selection", + move |_, cx| { + viewer + .update(cx, |viewer, cx| viewer.copy_selection(cx)) + .ok(); + }, + move |window, cx| { + viewer_for_comment + .update(cx, |viewer, cx| viewer.start_comment(window, cx)) + .ok(); + }, + cx, + )), + )) + } +} + +impl Focusable for FileViewer { + fn focus_handle(&self, _cx: &gpui_kit::App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for FileViewer { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if matches!(self.state, ViewerState::Loaded(_)) { + let ViewerState::Loaded(text) = + std::mem::replace(&mut self.state, ViewerState::Editing) + else { + unreachable!() + }; + self.apply_loaded(text, window, cx); + } + if self.editor.is_some() + && let Some(comment) = self.pending_reveal.take() + { + self.apply_reveal(comment, window, cx); + } + if let Some(editor) = &self.editor + && let Some(line) = self.pending_line.take() + { + editor.update(cx, |editor, cx| { + let text = editor.text(); + let row = line.clamp(1, text.lines_len().max(1)) - 1; + let range = text.line_start_offset(row)..text.line_end_offset(row); + editor.set_selected_range(range, cx); + editor.focus(window, cx); + }); + } + + let body = match &self.state { + ViewerState::Empty => self.notice("Select a file", cx), + ViewerState::Loading | ViewerState::Loaded(_) => self.notice("Loading…", cx), + ViewerState::Binary => self.notice("Binary file", cx), + ViewerState::TooLarge(size) => { + self.notice(format!("File too large to show ({} KB)", size / 1024), cx) + } + ViewerState::Failed(error) => self.notice(error.clone(), cx), + ViewerState::Editing => match &self.editor { + Some(editor) => Editor::new(editor) + .bordered(false) + .text_size(gpui_kit::rems(0.78125)) + .font_family("Menlo") + .size_full() + .into_any_element(), + None => self.notice("Loading…", cx), + }, + }; + v_flex() + .size_full() + .track_focus(&self.focus_handle) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { + let ks = &event.keystroke; + if ks.modifiers.secondary() && !ks.modifiers.shift && ks.key == "s" { + this.save(cx); + cx.stop_propagation(); + } else if ks.modifiers.secondary() && ks.modifiers.shift && ks.key == "m" { + this.start_comment(window, cx); + cx.stop_propagation(); + } + })) + .children(self.render_banner(cx)) + .child(div().flex_1().min_h_0().child(body)) + .children(self.render_floating(window, cx)) + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/files_view.rs b/crates/ui_gpui/src/main_screen/right_panel/files_view.rs new file mode 100644 index 00000000..e114cd41 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/files_view.rs @@ -0,0 +1,1020 @@ +//! The "Files" view of the right panel: the session's project as a lazy +//! tree on the left, the selected file on the right ([`FileViewer`]). +//! +//! Directories load one level at a time through the `SessionService` when +//! expanded. A filter field above the tree switches it to a flat list of +//! fuzzy matches over every file gitignore keeps. A [`TreeWatcher`] on the +//! project root re-lists loaded directories and reloads the open file when +//! they change on disk. The open file, the expanded directories and whether +//! ignored entries show are remembered per session. + +use super::file_filter::{FileMatch, filter_paths}; +use super::file_viewer::FileViewer; +use crate::Gpui; +use crate::comments::CommentChange; +use crate::shared::{file_icons, ui_state}; +use code_assistant_core::line_comments::LineComment; +use code_assistant_core::session::{DirListing, EntryKind, TreeWatcher}; +use gpui_kit::component::button::{Button, ButtonVariants}; +use gpui_kit::component::input::{Input, InputEvent, InputState}; +use gpui_kit::component::scroll::ScrollableElement; +use gpui_kit::component::{ActiveTheme, Icon, Sizable, Size}; +use gpui_kit::{ + Context, Entity, FocusHandle, Focusable, MouseButton, MouseMoveEvent, Pixels, Render, + SharedString, StyledText, Subscription, Task, UniformListScrollHandle, Window, div, prelude::*, + px, uniform_list, +}; +use std::collections::{BTreeSet, HashMap}; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +/// Filter results shown at most. +const MAX_MATCHES: usize = 200; +/// Quiet period before the watcher reports a burst of changes. +const WATCH_DEBOUNCE: Duration = Duration::from_millis(300); +const DEFAULT_TREE_WIDTH: Pixels = px(170.); +const MIN_TREE_WIDTH: Pixels = px(100.); +const MIN_VIEWER_WIDTH: Pixels = px(160.); + +/// A directory's listing state, keyed by its `/`-separated relative path +/// (`""` for the root). +enum DirState { + Loading, + Loaded(DirListing), + Failed(String), +} + +/// One row of the tree. +#[derive(Clone)] +enum TreeRow { + Entry { + path: String, + name: SharedString, + depth: usize, + kind: EntryKind, + ignored: bool, + expanded: bool, + }, + /// A loading, failure or "N more" note under a directory. + Note { depth: usize, text: SharedString }, +} + +/// Join a directory and an entry name into a relative path. +fn join(dir: &str, name: &str) -> String { + if dir.is_empty() { + name.to_owned() + } else { + format!("{dir}/{name}") + } +} + +/// The parent directory of a relative path (`""` for top-level entries). +fn parent(path: &str) -> &str { + path.rfind('/').map_or("", |i| &path[..i]) +} + +pub struct FilesView { + session_id: Option, + /// The project root, once the first listing named it. + root: Option, + dirs: HashMap, + expanded: BTreeSet, + show_ignored: bool, + rows: Vec, + tree_scroll: UniformListScrollHandle, + viewer: Entity, + + tree_visible: bool, + tree_width: Pixels, + /// Pointer x and tree width when a divider drag began. + resizing: Option<(Pixels, Pixels)>, + + filter: Entity, + query: String, + /// Every file gitignore keeps; loaded when the filter is first used and + /// dropped when the tree changes. + all_files: Option>>, + files_task: Option>, + matches: Vec, + /// The filter field still shows the previous session's text. + clear_filter: bool, + /// A comment to reveal once the project root is known. + pending_reveal: Option, + /// An absolute path (and line) to open once the project root is known. + pending_open: Option<(String, Option)>, + + watcher: Option<(PathBuf, TreeWatcher)>, + watch_task: Option>, + load_tasks: HashMap>, + focus_handle: FocusHandle, + _subscriptions: Vec, +} + +impl FilesView { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let viewer = cx.new(FileViewer::new); + let filter = cx.new(|cx| InputState::new(window, cx).placeholder("Filter files")); + let subscriptions = vec![ + cx.subscribe_in(&filter, window, Self::on_filter_event), + // The header shows the viewer's state. + cx.observe(&viewer, |_, _, cx| cx.notify()), + cx.subscribe(&viewer, |_, _, change: &CommentChange, cx| { + cx.emit(change.clone()) + }), + ]; + Self { + session_id: None, + root: None, + dirs: HashMap::new(), + expanded: BTreeSet::new(), + show_ignored: false, + rows: Vec::new(), + tree_scroll: UniformListScrollHandle::new(), + viewer, + tree_visible: true, + tree_width: DEFAULT_TREE_WIDTH, + resizing: None, + filter, + query: String::new(), + all_files: None, + files_task: None, + matches: Vec::new(), + clear_filter: false, + pending_reveal: None, + pending_open: None, + watcher: None, + watch_task: None, + load_tasks: HashMap::new(), + focus_handle: cx.focus_handle(), + _subscriptions: subscriptions, + } + } + + #[cfg(test)] + pub fn viewer(&self) -> &Entity { + &self.viewer + } + + pub fn set_comments(&mut self, comments: Vec, cx: &mut Context) { + self.viewer + .update(cx, |viewer, cx| viewer.set_comments(comments, cx)); + } + + /// Open `path`, relative to the project root or absolute inside it, and + /// select `line` (1-based). An absolute path waits for the root. + pub fn open_path(&mut self, path: String, line: Option, cx: &mut Context) { + let rel = if std::path::Path::new(&path).is_absolute() { + let Some(root) = self.root.clone() else { + self.pending_open = Some((path, line)); + return; + }; + match std::path::Path::new(&path).strip_prefix(&root) { + Ok(rel) => rel + .iter() + .map(|c| c.to_string_lossy()) + .collect::>() + .join("/"), + Err(_) => return, + } + } else { + path.trim_start_matches("./").to_owned() + }; + self.open_file(rel, cx); + if let Some(line) = line { + self.viewer + .update(cx, |viewer, cx| viewer.reveal_line(line, cx)); + } + } + + /// Open the file of `comment` and its lines. Waits for the project root + /// when the view was just attached. + pub fn reveal_comment(&mut self, comment: LineComment, cx: &mut Context) { + let Some(root) = self.root.clone() else { + self.pending_reveal = Some(comment); + return; + }; + let Ok(rel) = comment.file.strip_prefix(&root) else { + return; + }; + let path = rel + .iter() + .map(|c| c.to_string_lossy()) + .collect::>() + .join("/"); + self.open_file(path, cx); + self.viewer + .update(cx, |viewer, cx| viewer.reveal(comment, cx)); + } + + /// Show `session_id`'s project; `None` detaches the view (no watcher). + pub fn set_session(&mut self, session_id: Option, cx: &mut Context) { + if self.session_id == session_id { + return; + } + self.session_id = session_id.clone(); + self.root = None; + self.dirs.clear(); + self.load_tasks.clear(); + self.all_files = None; + self.files_task = None; + self.matches.clear(); + self.query.clear(); + self.watcher = None; + self.watch_task = None; + // Emptying the field needs a window; render does it. + self.clear_filter = true; + + let saved = session_id + .as_deref() + .and_then(|id| ui_state::read(cx, |store| store.get_files_view(id))) + .unwrap_or_default(); + self.expanded = saved.expanded.into_iter().collect(); + self.show_ignored = saved.show_ignored; + let session_for_viewer = session_id.clone(); + self.viewer.update(cx, |viewer, cx| { + viewer.open(session_for_viewer, saved.open_file, cx) + }); + if session_id.is_some() { + self.load_dir(String::new(), cx); + for dir in self.expanded.clone() { + self.load_dir(dir, cx); + } + } + self.rebuild_rows(); + cx.notify(); + } + + /// Re-list the loaded directories and reload the open file. + pub fn reload(&mut self, cx: &mut Context) { + let loaded: Vec = self.dirs.keys().cloned().collect(); + for dir in loaded { + self.load_dir(dir, cx); + } + self.all_files = None; + self.refresh_matches(cx); + self.viewer.update(cx, |viewer, cx| viewer.reload(cx)); + } + + /// Open `path` (relative, `/`-separated) in the viewer and reveal it in + /// the tree. + pub fn open_file(&mut self, path: String, cx: &mut Context) { + // Expand the ancestors so the file shows in the tree. + let mut dir = parent(&path).to_owned(); + while !dir.is_empty() { + if self.expanded.insert(dir.clone()) && !self.dirs.contains_key(&dir) { + self.load_dir(dir.clone(), cx); + } + dir = parent(&dir).to_owned(); + } + let session_id = self.session_id.clone(); + self.viewer.update(cx, |viewer, cx| { + viewer.open(session_id, Some(path.clone()), cx) + }); + self.rebuild_rows(); + if let Some(ix) = self + .rows + .iter() + .position(|row| matches!(row, TreeRow::Entry { path: p, .. } if *p == path)) + { + self.tree_scroll + .scroll_to_item(ix, gpui_kit::ScrollStrategy::Center); + } + self.persist(cx); + cx.notify(); + } + + fn persist(&self, cx: &mut Context) { + let Some(session_id) = &self.session_id else { + return; + }; + let state = ui_state::FilesViewState { + open_file: self.viewer.read(cx).path().map(str::to_owned), + expanded: self.expanded.iter().cloned().collect(), + show_ignored: self.show_ignored, + }; + ui_state::update(cx, |store| store.set_files_view(session_id, state)); + } + + fn load_dir(&mut self, dir: String, cx: &mut Context) { + let Some(session_id) = self.session_id.clone() else { + return; + }; + let Some(service) = cx + .try_global::() + .and_then(|gpui| gpui.session_service()) + else { + return; + }; + // Keep showing the old listing while a re-list runs. + self.dirs.entry(dir.clone()).or_insert(DirState::Loading); + let key = dir.clone(); + let task = cx.spawn(async move |this, cx| { + let result = service + .list_project_dir(session_id.clone(), PathBuf::from(&dir)) + .await; + this.update(cx, |this, cx| { + if this.session_id.as_deref() != Some(session_id.as_str()) { + return; + } + this.load_tasks.remove(&dir); + match result { + Ok(listed) => { + if this.root.as_ref() != Some(&listed.root) { + this.set_root(listed.root, &dir, cx); + } + this.dirs.insert(dir, DirState::Loaded(listed.listing)); + } + Err(e) => { + // A directory that vanished collapses quietly. + if dir.is_empty() { + this.dirs.insert(dir, DirState::Failed(format!("{e:#}"))); + } else { + this.dirs.remove(&dir); + this.expanded.remove(&dir); + } + } + } + this.rebuild_rows(); + cx.notify(); + }) + .ok(); + }); + self.load_tasks.insert(key, task); + } + + /// The project root became known or moved (a worktree switch): drop + /// listings of the old root and watch the new one. + fn set_root(&mut self, root: PathBuf, loaded_dir: &str, cx: &mut Context) { + let moved = self.root.is_some(); + self.root = Some(root.clone()); + if moved { + self.dirs.retain(|dir, _| dir == loaded_dir); + self.all_files = None; + for dir in std::iter::once(String::new()).chain(self.expanded.clone()) { + if dir != loaded_dir { + self.load_dir(dir, cx); + } + } + self.viewer.update(cx, |viewer, cx| viewer.reload(cx)); + } + self.start_watcher(root, cx); + if let Some(comment) = self.pending_reveal.take() { + self.reveal_comment(comment, cx); + } + if let Some((path, line)) = self.pending_open.take() { + self.open_path(path, line, cx); + } + } + + fn start_watcher(&mut self, root: PathBuf, cx: &mut Context) { + if self.watcher.as_ref().is_some_and(|(r, _)| *r == root) { + return; + } + let (tx, rx) = async_channel::unbounded::>(); + match TreeWatcher::start(&root, WATCH_DEBOUNCE, move |changed| { + let _ = tx.send_blocking(changed); + }) { + Ok(watcher) => { + self.watcher = Some((root, watcher)); + self.watch_task = Some(cx.spawn(async move |this, cx| { + while let Ok(changed) = rx.recv().await { + if this + .update(cx, |this, cx| this.on_changed(changed, cx)) + .is_err() + { + break; + } + } + })); + } + Err(e) => { + tracing::warn!("Files view: watcher unavailable: {e:#}"); + self.watcher = None; + self.watch_task = None; + } + } + } + + /// Re-list loaded directories whose entries changed and reload the open + /// file when it changed. + fn on_changed(&mut self, changed: BTreeSet, cx: &mut Context) { + let changed: Vec = changed + .iter() + .map(|p| { + p.iter() + .map(|c| c.to_string_lossy()) + .collect::>() + .join("/") + }) + .collect(); + let mut relist = BTreeSet::new(); + for path in &changed { + let dir = parent(path); + if self.dirs.contains_key(dir) { + relist.insert(dir.to_owned()); + } + if self.dirs.contains_key(path.as_str()) { + relist.insert(path.clone()); + } + } + // Content edits change no listing, but a file appearing or vanishing + // may change the filter's file list. + if !relist.is_empty() { + self.all_files = None; + self.refresh_matches(cx); + } + for dir in relist { + self.load_dir(dir, cx); + } + let open = self.viewer.read(cx).path().map(str::to_owned); + if open.is_some_and(|open| changed.contains(&open)) { + self.viewer.update(cx, |viewer, cx| viewer.reload(cx)); + } + } + + fn toggle_dir(&mut self, path: String, cx: &mut Context) { + if !self.expanded.remove(&path) { + self.expanded.insert(path.clone()); + if !self.dirs.contains_key(&path) { + self.load_dir(path, cx); + } + } + self.rebuild_rows(); + self.persist(cx); + cx.notify(); + } + + fn toggle_ignored(&mut self, cx: &mut Context) { + self.show_ignored = !self.show_ignored; + self.rebuild_rows(); + self.persist(cx); + cx.notify(); + } + + /// Flatten the expanded part of the tree into rows. + fn rebuild_rows(&mut self) { + let mut rows = Vec::new(); + self.push_rows("", 0, &mut rows); + self.rows = rows; + } + + fn push_rows(&self, dir: &str, depth: usize, rows: &mut Vec) { + match self.dirs.get(dir) { + None | Some(DirState::Loading) => rows.push(TreeRow::Note { + depth, + text: "Loading…".into(), + }), + Some(DirState::Failed(error)) => rows.push(TreeRow::Note { + depth, + text: error.clone().into(), + }), + Some(DirState::Loaded(listing)) => { + for entry in &listing.entries { + if entry.ignored && !self.show_ignored { + continue; + } + let path = join(dir, &entry.name); + let expanded = entry.kind == EntryKind::Dir && self.expanded.contains(&path); + rows.push(TreeRow::Entry { + path: path.clone(), + name: entry.name.clone().into(), + depth, + kind: entry.kind, + ignored: entry.ignored, + expanded, + }); + if expanded { + self.push_rows(&path, depth + 1, rows); + } + } + if listing.omitted > 0 { + rows.push(TreeRow::Note { + depth, + text: format!("{} more not shown", listing.omitted).into(), + }); + } + } + } + } + + fn on_filter_event( + &mut self, + _input: &Entity, + event: &InputEvent, + _window: &mut Window, + cx: &mut Context, + ) { + match event { + InputEvent::Change => { + let query = self.filter.read(cx).value().trim().to_owned(); + if query != self.query { + self.query = query; + self.refresh_matches(cx); + } + } + InputEvent::PressEnter { .. } => { + if let Some(first) = self.matches.first() { + let path = first.path.clone(); + self.open_file(path, cx); + } + } + _ => {} + } + } + + /// Recompute the filter matches, loading the file list first when needed. + fn refresh_matches(&mut self, cx: &mut Context) { + if self.query.is_empty() { + self.matches.clear(); + cx.notify(); + return; + } + match &self.all_files { + Some(files) => { + self.matches = filter_paths(files, &self.query, MAX_MATCHES); + cx.notify(); + } + None => self.load_all_files(cx), + } + } + + fn load_all_files(&mut self, cx: &mut Context) { + let Some(session_id) = self.session_id.clone() else { + return; + }; + let Some(service) = cx + .try_global::() + .and_then(|gpui| gpui.session_service()) + else { + return; + }; + self.files_task = Some(cx.spawn(async move |this, cx| { + let result = service.list_project_files(session_id.clone()).await; + this.update(cx, |this, cx| { + if this.session_id.as_deref() != Some(session_id.as_str()) { + return; + } + match result { + Ok(files) => { + this.all_files = Some(Arc::new(files.files)); + this.refresh_matches(cx); + } + Err(e) => tracing::warn!("Files view: cannot list files: {e:#}"), + } + }) + .ok(); + })); + } + + // ----------------------------------------------------------------------- + // Rendering + // ----------------------------------------------------------------------- + + fn render_tree_row( + &self, + row: &TreeRow, + ix: usize, + cx: &Context, + ) -> gpui_kit::AnyElement { + let theme = cx.theme(); + let indent = |depth: usize| px(8. + depth as f32 * 12.); + match row { + TreeRow::Note { depth, text } => div() + .h(px(22.)) + .flex() + .items_center() + .pl(indent(*depth) + px(14.)) + .text_xs() + .text_color(theme.muted_foreground) + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .child(text.clone()) + .into_any_element(), + TreeRow::Entry { + path, + name, + depth, + kind, + ignored, + expanded, + } => { + let is_dir = *kind == EntryKind::Dir; + let selected = !is_dir && self.viewer.read(cx).path() == Some(path.as_str()); + let icon = if is_dir { + file_icons::get().get_type_icon(if *expanded { + "expanded_folder" + } else { + "collapsed_folder" + }) + } else { + file_icons::get().get_icon_for_filename(name) + }; + let chevron = is_dir.then(|| { + gpui_kit::svg() + .size(px(10.)) + .path(if *expanded { + "icons/chevron_down.svg" + } else { + "icons/chevron_right.svg" + }) + .text_color(theme.muted_foreground) + }); + let fg = if *ignored { + theme.muted_foreground + } else { + theme.foreground + }; + let path = path.clone(); + div() + .id(("files-tree-row", ix)) + .h(px(22.)) + .w_full() + .flex() + .items_center() + .gap_1() + .pl(indent(*depth)) + .pr_2() + .cursor_pointer() + .when(selected, |d| d.bg(theme.list_active)) + .hover(|s| s.bg(theme.list_hover)) + .child(div().w(px(10.)).flex_none().children(chevron)) + .child(file_icons::render_icon( + &icon, + 14.0, + theme.muted_foreground, + "·", + )) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .text_xs() + .text_color(fg) + .child(name.clone()), + ) + .on_click(cx.listener(move |this, _, _, cx| { + if is_dir { + this.toggle_dir(path.clone(), cx); + } else { + this.open_file(path.clone(), cx); + } + })) + .into_any_element() + } + } + } + + fn render_match(&self, m: &FileMatch, ix: usize, cx: &Context) -> gpui_kit::AnyElement { + let theme = cx.theme(); + let selected = self.viewer.read(cx).path() == Some(m.path.as_str()); + let name_start = m.path.rfind('/').map_or(0, |i| i + 1); + let name = &m.path[name_start..]; + let dir = &m.path[..name_start.saturating_sub(1)]; + let highlight = gpui_kit::HighlightStyle { + color: Some(theme.primary), + font_weight: Some(gpui_kit::FontWeight::BOLD), + ..Default::default() + }; + let name_highlights: Vec<_> = m + .positions + .iter() + .filter(|&&p| p >= name_start) + .map(|&p| { + let len = m.path[p..].chars().next().map_or(1, char::len_utf8); + (p - name_start..p - name_start + len, highlight) + }) + .collect(); + let icon = file_icons::get().get_icon_for_filename(name); + let path = m.path.clone(); + div() + .id(("files-match-row", ix)) + .h(px(22.)) + .w_full() + .flex() + .items_center() + .gap_1() + .px_2() + .cursor_pointer() + .when(selected, |d| d.bg(theme.list_active)) + .hover(|s| s.bg(theme.list_hover)) + .child(file_icons::render_icon( + &icon, + 14.0, + theme.muted_foreground, + "·", + )) + .child( + div() + .flex_none() + .text_xs() + .text_color(theme.foreground) + .child( + StyledText::new(SharedString::from(name.to_owned())) + .with_highlights(name_highlights), + ), + ) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .text_xs() + .text_color(theme.muted_foreground) + .child(dir.to_owned()), + ) + .on_click(cx.listener(move |this, _, _, cx| this.open_file(path.clone(), cx))) + .into_any_element() + } + + fn render_tree(&self, cx: &mut Context) -> gpui_kit::AnyElement { + let filtering = !self.query.is_empty(); + let count = if filtering { + self.matches.len() + } else { + self.rows.len() + }; + if filtering && count == 0 { + let text = if self.all_files.is_none() { + "Searching…" + } else { + "No matching files" + }; + return div() + .p_2() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(text) + .into_any_element(); + } + div() + .size_full() + .child( + uniform_list( + "files-tree", + count, + cx.processor( + move |this: &mut Self, range: std::ops::Range, _, cx| { + range + .filter_map(|ix| { + if filtering { + this.matches.get(ix).map(|m| this.render_match(m, ix, cx)) + } else { + this.rows + .get(ix) + .map(|row| this.render_tree_row(row, ix, cx)) + } + }) + .collect::>() + }, + ), + ) + .track_scroll(&self.tree_scroll) + .size_full(), + ) + .vertical_scrollbar(&self.tree_scroll) + .into_any_element() + } + + fn render_toolbar(&self, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + let tree_visible = self.tree_visible; + let show_ignored = self.show_ignored; + div() + .flex_none() + .flex() + .items_center() + .gap_1() + .px_2() + .py_1() + .border_b_1() + .border_color(theme.border) + .child( + Button::new("files-toggle-tree") + .icon( + Icon::default() + .path(SharedString::from(if tree_visible { + "icons/panel_left_close.svg" + } else { + "icons/panel_left_open.svg" + })) + .with_size(Size::XSmall), + ) + .ghost() + .xsmall() + .tooltip(if tree_visible { + "Hide file tree" + } else { + "Show file tree" + }) + .on_click(cx.listener(|this, _, _, cx| { + this.tree_visible = !this.tree_visible; + cx.notify(); + })), + ) + .child( + div().flex_1().min_w_0().child( + Input::new(&self.filter) + .with_size(Size::XSmall) + .prefix( + Icon::default() + .path(SharedString::from("icons/magnifying_glass.svg")) + .with_size(Size::XSmall) + .text_color(theme.muted_foreground), + ) + .cleanable(true), + ), + ) + .child( + Button::new("files-toggle-ignored") + .label(".gitignore") + .xsmall() + .map(|b| if show_ignored { b.primary() } else { b.ghost() }) + .tooltip(if show_ignored { + "Hide gitignored files" + } else { + "Show gitignored files" + }) + .on_click(cx.listener(|this, _, _, cx| this.toggle_ignored(cx))), + ) + } + + /// Path of the open file and the actions on its selection. + fn render_viewer_header(&self, cx: &mut Context) -> Option { + let theme = cx.theme(); + let viewer = self.viewer.read(cx); + let path = viewer.path()?.to_owned(); + let dirty = viewer.is_dirty(); + Some( + div() + .flex_none() + .flex() + .items_center() + .gap_1() + .px_2() + .py_0p5() + .border_b_1() + .border_color(theme.border) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .text_xs() + .text_color(theme.muted_foreground) + .child(path), + ) + .when(dirty, |d| { + d.child( + div() + .size(px(7.)) + .rounded_full() + .bg(theme.muted_foreground) + .id("files-unsaved") + .tooltip(|window, cx| { + gpui_kit::component::tooltip::Tooltip::new("Unsaved edits") + .build(window, cx) + }), + ) + }), + ) + } + + fn on_resize_move( + &mut self, + event: &MouseMoveEvent, + window: &mut Window, + cx: &mut Context, + ) { + let Some((start_x, start_width)) = self.resizing else { + return; + }; + if !event.dragging() { + self.resizing = None; + cx.notify(); + return; + } + let max = (window.bounds().size.width - MIN_VIEWER_WIDTH).max(MIN_TREE_WIDTH); + self.tree_width = (start_width + event.position.x - start_x).clamp(MIN_TREE_WIDTH, max); + cx.notify(); + } +} + +impl gpui_kit::EventEmitter for FilesView {} + +impl Focusable for FilesView { + fn focus_handle(&self, _cx: &gpui_kit::App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for FilesView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if std::mem::take(&mut self.clear_filter) { + self.filter + .update(cx, |input, cx| input.set_value("", window, cx)); + } + let theme = cx.theme(); + let border = theme.border; + let handle_color = if self.resizing.is_some() { + theme.drag_border + } else { + theme.border + }; + + if let Some(DirState::Failed(error)) = self.dirs.get("") { + return div() + .size_full() + .flex() + .items_center() + .justify_center() + .p_4() + .text_sm() + .text_color(theme.muted_foreground) + .child(error.clone()) + .into_any_element(); + } + + let tree = self.tree_visible.then(|| { + div() + .flex_none() + .h_full() + .w(self.tree_width) + .child(self.render_tree(cx)) + }); + let divider = self.tree_visible.then(|| { + div() + .id("files-divider") + .flex_none() + .h_full() + .w(px(5.)) + .cursor_col_resize() + .child(div().mx_auto().h_full().w(px(1.)).bg(handle_color)) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, event: &gpui_kit::MouseDownEvent, _, cx| { + this.resizing = Some((event.position.x, this.tree_width)); + cx.notify(); + }), + ) + }); + let viewer_column = div() + .flex_1() + .min_w_0() + .h_full() + .flex() + .flex_col() + .when(!self.tree_visible, |d| d.border_l_0()) + .children(self.render_viewer_header(cx)) + .child(div().flex_1().min_h_0().child(self.viewer.clone())); + + div() + .size_full() + .flex() + .flex_col() + .track_focus(&self.focus_handle) + .child(self.render_toolbar(cx)) + .child( + div() + .flex_1() + .min_h_0() + .flex() + .flex_row() + .border_color(border) + .on_mouse_move(cx.listener(Self::on_resize_move)) + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, _, _, cx| { + if this.resizing.take().is_some() { + cx.notify(); + } + }), + ) + .children(tree) + .children(divider) + .child(viewer_column), + ) + .into_any_element() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn paths_join_and_split() { + assert_eq!(join("", "a"), "a"); + assert_eq!(join("a/b", "c"), "a/b/c"); + assert_eq!(parent("a/b/c"), "a/b"); + assert_eq!(parent("a"), ""); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/line_selection.rs b/crates/ui_gpui/src/main_screen/right_panel/line_selection.rs new file mode 100644 index 00000000..cfbb6892 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/line_selection.rs @@ -0,0 +1,233 @@ +//! Whole-line selection over [`DiffRows`](crate::tool_cards::diff_rows::DiffRows) +//! chunks, shared by the right panel's views. A view keeps one +//! [`LineSelection`] and builds each chunk's [`RowSelection`] with +//! [`row_selection`]; the element reports pointer positions as flat line +//! indices, which `key` ties to one file. A selection never spans files. + +use crate::tool_cards::diff_rows::{EndCallback, LineCallback, RowSelection}; +use gpui_kit::{App, Context, FocusHandle, Hsla, Window}; + +use std::ops::Range; +use std::rc::Rc; + +/// A selection of flat lines `anchor..=head` (either may be the smaller) in +/// the file named by `key`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Selected { + pub key: K, + pub anchor: usize, + pub head: usize, +} + +impl Selected { + /// Inclusive `(low, high)` line range. + pub fn range(&self) -> (usize, usize) { + (self.anchor.min(self.head), self.anchor.max(self.head)) + } +} + +/// The selection state of one view. +#[derive(Debug)] +pub(crate) struct LineSelection { + selected: Option>, + /// Whether the pointer button is down for an in-progress drag. + dragging: bool, +} + +impl Default for LineSelection { + fn default() -> Self { + Self { + selected: None, + dragging: false, + } + } +} + +impl LineSelection { + pub fn get(&self) -> Option<&Selected> { + self.selected.as_ref() + } + + pub fn is_dragging(&self) -> bool { + self.dragging + } + + pub fn clear(&mut self) { + self.selected = None; + self.dragging = false; + } + + /// Drop the selection when `keep` rejects its file. + pub fn retain(&mut self, keep: impl FnOnce(&K) -> bool) { + if self.selected.as_ref().is_some_and(|s| !keep(&s.key)) { + self.clear(); + } + } + + /// Start a selection at `line` of `key`, replacing any previous one. + /// With `extend` (shift held) and a selection in the same file, move its + /// head instead. + pub fn begin(&mut self, key: K, line: usize, extend: bool) { + match &mut self.selected { + Some(sel) if extend && sel.key == key => sel.head = line, + _ => { + self.selected = Some(Selected { + key, + anchor: line, + head: line, + }) + } + } + self.dragging = true; + } + + /// Move the head to `line` while a drag in `key` is in progress; returns + /// whether anything changed. + pub fn extend(&mut self, key: &K, line: usize) -> bool { + if !self.dragging { + return false; + } + match &mut self.selected { + Some(sel) if &sel.key == key && sel.head != line => { + sel.head = line; + true + } + _ => false, + } + } + + /// End the drag; the selection itself stays. + pub fn end(&mut self) { + self.dragging = false; + } + + /// The rows of a chunk (`row_count` rows starting at flat line + /// `base_line` of `key`) to paint as selected, relative to the chunk. + pub fn highlight(&self, key: &K, base_line: usize, row_count: usize) -> Range { + let Some(sel) = self.selected.as_ref().filter(|s| &s.key == key) else { + return 0..0; + }; + let (lo, hi) = sel.range(); + let start = lo.max(base_line); + let end = hi.min(base_line + row_count.saturating_sub(1)); + if row_count > 0 && start <= end { + (start - base_line)..(end - base_line + 1) + } else { + 0..0 + } + } +} + +/// A view that owns a [`LineSelection`]. +pub(crate) trait SelectsLines: 'static + Sized { + type Key: Clone + PartialEq + 'static; + + fn line_selection(&mut self) -> &mut LineSelection; +} + +/// How a chunk paints: the selected rows (from [`LineSelection::highlight`]) +/// and the commented ones, both relative to the chunk. +pub(crate) struct ChunkMarks { + pub highlight: Range, + pub color: Hsla, + pub marked: Vec>, + pub mark_color: Hsla, + /// Set for the chunk holding the selection's last row; see + /// [`RowSelection::anchor`]. + pub anchor: Option>>>>, +} + +/// The [`RowSelection`] for one chunk of `key`'s rows starting at flat line +/// `base_line`, wired to the view's [`LineSelection`]. Pressing focuses +/// `focus` (so copy shortcuts reach the view); shift-press extends the +/// selection. +pub(crate) fn row_selection( + key: V::Key, + base_line: usize, + marks: ChunkMarks, + focus: FocusHandle, + cx: &Context, +) -> RowSelection { + // The element's closures are 'static: they reach the view through a weak + // handle, so they never keep it alive. + let entity = cx.entity().downgrade(); + let on_start: LineCallback = { + let entity = entity.clone(); + let key = key.clone(); + Rc::new(move |line, window: &mut Window, cx: &mut App| { + window.focus(&focus, cx); + let extend = window.modifiers().shift; + entity + .update(cx, |view, cx| { + view.line_selection().begin(key.clone(), line, extend); + cx.notify(); + }) + .ok(); + }) + }; + let on_drag: LineCallback = { + let entity = entity.clone(); + Rc::new(move |line, _window, cx| { + entity + .update(cx, |view, cx| { + if view.line_selection().extend(&key, line) { + cx.notify(); + } + }) + .ok(); + }) + }; + let on_end: EndCallback = Rc::new(move |_window, cx| { + entity + .update(cx, |view, cx| { + view.line_selection().end(); + cx.notify(); + }) + .ok(); + }); + RowSelection { + base_line, + highlight: marks.highlight, + color: marks.color, + marked: marks.marked, + mark_color: marks.mark_color, + anchor: marks.anchor, + on_start, + on_drag, + on_end, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn begin_extend_and_highlight() { + let mut sel = LineSelection::default(); + sel.begin("a", 5, false); + assert!(sel.extend(&"a", 8)); + assert!(!sel.extend(&"b", 9), "a drag never crosses files"); + sel.end(); + assert!(!sel.extend(&"a", 2), "no drag after release"); + assert_eq!(sel.get().unwrap().range(), (5, 8)); + // Chunk of rows 0..6 holds lines 5 and 6 of the selection. + assert_eq!(sel.highlight(&"a", 0, 7), 5..7); + assert_eq!(sel.highlight(&"a", 7, 10), 0..2); + assert_eq!(sel.highlight(&"a", 9, 10), 0..0); + assert_eq!(sel.highlight(&"b", 0, 10), 0..0); + } + + #[test] + fn shift_press_extends_in_the_same_file_only() { + let mut sel = LineSelection::default(); + sel.begin("a", 5, false); + sel.end(); + sel.begin("a", 9, true); + assert_eq!(sel.get().unwrap().range(), (5, 9)); + sel.begin("b", 3, true); + assert_eq!(sel.get().unwrap().range(), (3, 3)); + sel.retain(|k| *k == "a"); + assert!(sel.get().is_none()); + } +} diff --git a/crates/ui_gpui/src/main_screen/right_panel/mod.rs b/crates/ui_gpui/src/main_screen/right_panel/mod.rs index 79ae4483..4cfc77c8 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/mod.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/mod.rs @@ -1,14 +1,23 @@ -//! The right sidebar's view switcher. +//! The right sidebar's views. //! -//! The sidebar hosts the [`review_view::ReviewView`] and, with the -//! `browser-panel` feature, the agent's browser ([`browser::BrowserPanel`]), -//! switched in a header above them. The shown view is remembered per session. +//! The sidebar hosts the [`review_view::ReviewView`], the project's files +//! ([`files_view::FilesView`]) and, with the `browser-panel` feature, the +//! agent's browser ([`browser::BrowserPanel`]). The title bar switches +//! between them ([`RightPanelView::ALL`]). The shown view is remembered per +//! session. #[cfg(feature = "browser-panel")] mod browser; +mod file_filter; +mod file_viewer; +pub mod files_view; +mod line_selection; mod review_rows; pub mod review_view; +pub use crate::comments::CommentChange; +use code_assistant_core::line_comments::LineComment; +use files_view::FilesView; use gpui_kit::{Context, Entity, FocusHandle, Focusable, Render, Window, div, prelude::*}; use review_view::ReviewView; @@ -16,15 +25,25 @@ use review_view::ReviewView; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RightPanelView { Review, + Files, #[cfg(feature = "browser-panel")] Browser, } impl RightPanelView { + /// Every view with its label, in switch order. + pub const ALL: &[(RightPanelView, &'static str)] = &[ + (RightPanelView::Review, "Review"), + (RightPanelView::Files, "Files"), + #[cfg(feature = "browser-panel")] + (RightPanelView::Browser, "Browser"), + ]; + /// Stable string used for persistence. pub fn as_str(self) -> &'static str { match self { RightPanelView::Review => "review", + RightPanelView::Files => "files", #[cfg(feature = "browser-panel")] RightPanelView::Browser => "browser", } @@ -34,6 +53,7 @@ impl RightPanelView { #[allow(clippy::should_implement_trait)] pub fn from_str(s: &str) -> Self { match s { + "files" => RightPanelView::Files, #[cfg(feature = "browser-panel")] "browser" => RightPanelView::Browser, _ => RightPanelView::Review, @@ -44,23 +64,38 @@ impl RightPanelView { pub struct RightPanel { active_view: RightPanelView, review_view: Entity, + files_view: Entity, #[cfg(feature = "browser-panel")] browser: Entity, - #[cfg(feature = "browser-panel")] session_id: Option, #[cfg(feature = "browser-panel")] _browser_events: gpui_kit::Subscription, + _comment_subscriptions: Vec, focus_handle: FocusHandle, } +/// The views report comment edits; the main screen applies them to the +/// composer, which owns the draft's comments. +impl gpui_kit::EventEmitter for RightPanel {} + impl RightPanel { pub fn new(window: &mut Window, cx: &mut Context) -> Self { let review_view = cx.new(|cx| ReviewView::new(window, cx)); + let files_view = cx.new(|cx| FilesView::new(window, cx)); + let comment_subscriptions = vec![ + cx.subscribe(&review_view, |_, _, change: &CommentChange, cx| { + cx.emit(change.clone()) + }), + cx.subscribe(&files_view, |_, _, change: &CommentChange, cx| { + cx.emit(change.clone()) + }), + ]; #[cfg(feature = "browser-panel")] let browser = cx.new(browser::BrowserPanel::new); Self { active_view: RightPanelView::Review, review_view, + files_view, #[cfg(feature = "browser-panel")] _browser_events: cx.subscribe(&browser, |this, _, event, cx| match event { // The agent started browsing: show it. @@ -70,13 +105,52 @@ impl RightPanel { }), #[cfg(feature = "browser-panel")] browser, - #[cfg(feature = "browser-panel")] session_id: None, + _comment_subscriptions: comment_subscriptions, focus_handle: cx.focus_handle(), } } - #[allow(dead_code)] + /// The draft's comments, for the views' markers. + pub fn set_comments(&mut self, comments: Vec, cx: &mut Context) { + self.files_view + .update(cx, |v, cx| v.set_comments(comments.clone(), cx)); + self.review_view + .update(cx, |v, cx| v.set_comments(comments, cx)); + } + + /// The file the Files view shows, if any. + #[cfg(test)] + pub fn files_open_path(&self, cx: &gpui_kit::App) -> Option { + self.files_view + .read(cx) + .viewer() + .read(cx) + .path() + .map(str::to_owned) + } + + /// Show `path` (project-relative or absolute) in the Files view, with + /// `line` (1-based) selected when given. + pub fn open_file(&mut self, path: String, line: Option, cx: &mut Context) { + self.set_active_view(RightPanelView::Files, cx); + self.files_view + .update(cx, |v, cx| v.open_path(path, line, cx)); + } + + /// Show where `comment` was made and open it for editing. + pub fn reveal_comment(&mut self, comment: LineComment, cx: &mut Context) { + if comment.in_diff { + self.set_active_view(RightPanelView::Review, cx); + self.review_view + .update(cx, |v, cx| v.reveal_comment(comment, cx)); + } else { + self.set_active_view(RightPanelView::Files, cx); + self.files_view + .update(cx, |v, cx| v.reveal_comment(comment, cx)); + } + } + pub fn active_view(&self) -> RightPanelView { self.active_view } @@ -86,71 +160,52 @@ impl RightPanel { return; } self.active_view = view; - #[cfg(feature = "browser-panel")] if let Some(session_id) = &self.session_id { crate::shared::ui_state::update(cx, |store| { store.set_right_panel_view(session_id, view.as_str()) }); } + self.sync_files_session(cx); cx.notify(); } - /// Point the active view(s) at a session. + /// Point the views at a session. pub fn set_session(&mut self, session_id: Option, cx: &mut Context) { - #[cfg(feature = "browser-panel")] - { - self.active_view = session_id - .as_deref() - .and_then(|id| { - crate::shared::ui_state::read(cx, |store| store.get_right_panel_view(id)) - .flatten() - }) - .map_or(RightPanelView::Review, |view| { - RightPanelView::from_str(&view) - }); - self.session_id = session_id.clone(); - self.browser.update(cx, |browser, cx| { - browser.set_session(session_id.clone(), cx) + self.active_view = session_id + .as_deref() + .and_then(|id| { + crate::shared::ui_state::read(cx, |store| store.get_right_panel_view(id)).flatten() + }) + .map_or(RightPanelView::Review, |view| { + RightPanelView::from_str(&view) }); - cx.notify(); - } + self.session_id = session_id.clone(); + #[cfg(feature = "browser-panel")] + self.browser.update(cx, |browser, cx| { + browser.set_session(session_id.clone(), cx) + }); + self.sync_files_session(cx); self.review_view .update(cx, |v, cx| v.set_session(session_id, cx)); + cx.notify(); + } + + /// The Files view follows the session only while it is shown, so a + /// hidden one neither lists nor watches. + fn sync_files_session(&mut self, cx: &mut Context) { + let session_id = (self.active_view == RightPanelView::Files) + .then(|| self.session_id.clone()) + .flatten(); + self.files_view + .update(cx, |files, cx| files.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)); - } - - #[cfg(feature = "browser-panel")] - fn render_header(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - use gpui_kit::component::ActiveTheme; - let panel = cx.entity().downgrade(); - div() - .flex_none() - .flex() - .items_center() - .px_2() - .py(gpui_kit::px(8.)) - .bg(cx.theme().title_bar) - .border_b_1() - .border_color(cx.theme().border) - .child(crate::shared::segmented_switch::segmented_switch( - "right-panel-view", - &[ - (RightPanelView::Review, "Review"), - (RightPanelView::Browser, "Browser"), - ], - self.active_view, - move |view, _, cx| { - panel - .update(cx, |panel, cx| panel.set_active_view(view, cx)) - .ok(); - }, - window, - cx, - )) + if self.active_view == RightPanelView::Files { + self.files_view.update(cx, |v, cx| v.reload(cx)); + } } } @@ -164,18 +219,10 @@ 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(), + RightPanelView::Files => self.files_view.clone().into_any_element(), #[cfg(feature = "browser-panel")] RightPanelView::Browser => self.browser.clone().into_any_element(), }; - #[cfg(feature = "browser-panel")] - let panel = div() - .flex() - .flex_col() - .size_full() - .child(self.render_header(_window, _cx)) - .child(div().flex_1().min_h_0().child(body)); - #[cfg(not(feature = "browser-panel"))] - let panel = div().size_full().child(body); - panel + 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 index 42a6c2ee..de2088eb 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/review_view.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/review_view.rs @@ -26,13 +26,19 @@ //! listing entry changed is stale and re-requested, but keeps rendering until //! its replacement arrives, so nothing flickers. +mod comments; + +use super::line_selection::ChunkMarks; +use super::line_selection::{LineSelection, SelectsLines, row_selection}; use super::review_rows::{ DiffBody, FileOutline, RepoOutline, ReviewRow, changed_span, files_by_proximity, flatten, }; +use crate::comments::CommentChange; +use crate::comments::editor::CommentEditor; use crate::shared::file_icons; use crate::tool_cards::diff_card::{added_row_colors, deleted_row_colors, render_diff_chunk}; -use crate::tool_cards::diff_rows::{EndCallback, LineCallback, RowSelection}; use crate::{Gpui, PreparedReviewDiff, RepoReviewData}; +use code_assistant_core::line_comments::LineComment; use code_assistant_core::session::{ReviewMode, ReviewScanState}; use git::{ChangeStatus, ChangedFile}; use gpui_kit::component::{ @@ -168,24 +174,6 @@ struct LoadedDiff { stamp: u64, } -/// A whole-line text selection within a single file's diff. `anchor` and -/// `head` are indices into that file's flattened hunk lines (the concatenation -/// of every hunk's lines, which is exactly what the diff chunks render); either -/// end may be the smaller. Selection never spans files. -#[derive(Clone)] -struct DiffSelection { - file: FileKey, - anchor: usize, - head: usize, -} - -impl DiffSelection { - /// Inclusive `(low, high)` line range. - fn range(&self) -> (usize, usize) { - (self.anchor.min(self.head), self.anchor.max(self.head)) - } -} - pub struct ReviewView { session_id: Option, mode_state: Entity>>, @@ -220,11 +208,22 @@ pub struct ReviewView { rows: Vec, list_state: ListState, - /// Current whole-line text selection in a file's diff, if any. Copying - /// (Cmd/Ctrl-C or the header button) reads from this. - selection: Option, - /// Whether the pointer button is down for an in-progress drag-select. - dragging: bool, + /// Current whole-line text selection in a file's diff, if any; indices + /// are flat lines of the file's hunks. Copying (Cmd/Ctrl-C or the header + /// button) reads from this. + selection: LineSelection, + /// Every comment of the draft; rows they cover are marked. + comments: Vec, + comment_editor: Option>, + _comment_editor_subscription: Option, + /// A comment to show once its file's diff is loaded. + pending_reveal: Option, + /// Window bounds of the selection's last row, written when it is painted. + selection_anchor: Rc>>>, + /// Keeps the selection pill and the comment card next to the rows. + anchor: crate::comments::AnchorTracker, + /// The selection whose pill a press elsewhere dismissed. + pill_dismissed: Option>, /// Filesystem watcher on the listed repos (keyed by their roots so a /// changed set restarts it). Dropping it stops watching. @@ -273,8 +272,14 @@ impl ReviewView { next_diff_stamp: 0, rows: Vec::new(), list_state: ListState::new(0, ListAlignment::Top, REVIEW_LIST_OVERDRAW).measure_all(), - selection: None, - dragging: false, + selection: LineSelection::default(), + comments: Vec::new(), + comment_editor: None, + _comment_editor_subscription: None, + pending_reveal: None, + selection_anchor: Rc::default(), + anchor: Default::default(), + pill_dismissed: None, watcher: None, watch_task: None, listing_generation: GENERATION_UNSEEN, @@ -299,8 +304,7 @@ impl ReviewView { // Start the new session scrolled to the top. self.rows.clear(); self.list_state.reset(0); - self.selection = None; - self.dragging = false; + self.selection.clear(); // A new session lists its own repos; the watcher follows the listing. self.watcher = None; self.watch_task = None; @@ -545,12 +549,7 @@ impl ReviewView { { self.in_flight = None; } - if let Some(sel) = &self.selection - && !live.contains(&sel.file) - { - self.selection = None; - self.dragging = false; - } + self.selection.retain(|file| live.contains(file)); // Apply the persisted default base to any repo that has no explicit // override yet and whose resolved base differs. Seeding the override @@ -1090,34 +1089,24 @@ impl ReviewView { /// Begin a new selection at flat line `line` in `file`, replacing any /// previous one and marking a drag as in progress. + #[cfg(test)] fn begin_selection(&mut self, file: FileKey, line: usize, cx: &mut Context) { - self.selection = Some(DiffSelection { - file, - anchor: line, - head: line, - }); - self.dragging = true; + self.selection.begin(file, line, false); cx.notify(); } - /// Extend the in-progress selection to flat line `line`, but only while a - /// drag that started in the same file is active. + /// Extend the in-progress selection to flat line `line`. + #[cfg(test)] fn extend_selection(&mut self, file: &FileKey, line: usize, cx: &mut Context) { - if !self.dragging { - return; - } - if let Some(sel) = &mut self.selection - && &sel.file == file - && sel.head != line - { - sel.head = line; + if self.selection.extend(file, line) { cx.notify(); } } /// End the current drag; the selection itself is kept for copying. + #[cfg(test)] fn end_selection(&mut self) { - self.dragging = false; + self.selection.end(); } /// A file's diff lines flattened across its hunks in render order — the @@ -1137,8 +1126,8 @@ impl ReviewView { /// The selected lines joined by newlines, or `None` when there is nothing /// to copy (no selection, or the file's diff is no longer loaded). fn selected_text(&self) -> Option { - let sel = self.selection.as_ref()?; - let lines = self.file_lines(&sel.file)?; + let sel = self.selection.get()?; + let lines = self.file_lines(&sel.key)?; if lines.is_empty() { return None; } @@ -1157,7 +1146,7 @@ impl ReviewView { /// One chunk of a file's diff body. The first and last chunk carry the /// body's vertical padding, so the chunks read as one block. The rows are /// selectable: pointer events map to flat line indices in `key`'s diff and - /// drive the view's [`DiffSelection`]. + /// drive the view's [`LineSelection`]. fn render_chunk( &self, key: &FileKey, @@ -1187,62 +1176,16 @@ impl ReviewView { .map(|h| h.lines.len()) .sum::() + chunk.lines.start; - let row_count = chunk.lines.len(); - let highlight = self - .selection - .as_ref() - .filter(|s| &s.file == key) - .map(|s| { - let (lo, hi) = s.range(); - let start = lo.max(base_line); - let end = hi.min(base_line + row_count.saturating_sub(1)); - if row_count > 0 && start <= end { - (start - base_line)..(end - base_line + 1) - } else { - 0..0 - } - }) - .unwrap_or(0..0); - - // Pointer callbacks reach the view through a weak handle, so the - // (static) closures held by the element never borrow it. - let entity = cx.entity().downgrade(); - let on_start: LineCallback = { - let entity = entity.clone(); - let focus = self.focus_handle.clone(); - let key = key.clone(); - Rc::new(move |line, window, cx: &mut gpui_kit::App| { - window.focus(&focus, cx); - if let Some(view) = entity.upgrade() { - view.update(cx, |this, cx| this.begin_selection(key.clone(), line, cx)); - } - }) - }; - let on_drag: LineCallback = { - let entity = entity.clone(); - let key = key.clone(); - Rc::new(move |line, _window, cx| { - if let Some(view) = entity.upgrade() { - view.update(cx, |this, cx| this.extend_selection(&key, line, cx)); - } - }) - }; - let on_end: EndCallback = { - Rc::new(move |_window, cx| { - if let Some(view) = entity.upgrade() { - view.update(cx, |this, _cx| this.end_selection()); - } - }) - }; - - let selection = RowSelection { - base_line, - highlight, + let marks = ChunkMarks { + highlight: self.selection.highlight(key, base_line, chunk.lines.len()), color: theme.selection, - on_start, - on_drag, - on_end, + marked: self.chunk_marks(key, prepared, base_line, chunk.lines.len()), + mark_color: theme.warning, + anchor: self + .anchors_selection_end(key, base_line, chunk.lines.len()) + .then(|| self.selection_anchor.clone()), }; + let selection = row_selection(key.clone(), base_line, marks, self.focus_handle.clone(), cx); div() .w_full() @@ -1268,6 +1211,16 @@ impl ReviewView { } } +impl EventEmitter for ReviewView {} + +impl SelectsLines for ReviewView { + type Key = FileKey; + + fn line_selection(&mut self) -> &mut LineSelection { + &mut self.selection + } +} + impl Focusable for ReviewView { fn focus_handle(&self, _cx: &gpui_kit::App) -> FocusHandle { self.focus_handle.clone() @@ -1280,6 +1233,7 @@ impl Render for ReviewView { // generation compare when nothing changed. self.sync_listing(window, cx); self.sync_diff(cx); + self.apply_pending_reveal(window, cx); let muted = cx.theme().muted_foreground; let border = cx.theme().border; @@ -1312,31 +1266,8 @@ impl Render for ReviewView { .into_any_element(); } - // A "Copy" button appears while there is a selection; it (and Cmd/Ctrl-C - // on the focused panel) copies the selected lines. - let copy_button = self.selection.is_some().then(|| { - let theme = cx.theme(); - div() - .id("review-copy-selection") - .flex() - .flex_row() - .items_center() - .gap_1() - .px_2() - .py_0p5() - .rounded(px(4.)) - .cursor_pointer() - .bg(theme.muted) - .hover(|s| s.bg(theme.border)) - .text_xs() - .text_color(theme.foreground) - .child("Copy") - .on_click(cx.listener(|this, _ev, _window, cx| this.copy_selection(cx))) - .into_any_element() - }); - - // Header: compare-mode selector (base selectors live per repo), plus - // the copy button when a selection exists. + // Header: compare-mode selector (base selectors live per repo). Copy + // and comment float next to the selection. let header = div() .flex() .flex_row() @@ -1356,8 +1287,7 @@ impl Render for ReviewView { .text_color(muted), ) .min_w(px(130.)), - ) - .children(copy_button); + ); // The render callback only runs for rows in (or near) the viewport. self.sync_rows(); @@ -1370,11 +1300,14 @@ impl Render for ReviewView { v_flex() .size_full() .track_focus(&self.focus_handle) - .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { + .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { let ks = &event.keystroke; if ks.modifiers.secondary() && ks.key == "c" { this.copy_selection(cx); cx.stop_propagation(); + } else if ks.modifiers.secondary() && ks.modifiers.shift && ks.key == "m" { + this.start_comment(window, cx); + cx.stop_propagation(); } })) .child(header) @@ -1385,6 +1318,7 @@ impl Render for ReviewView { .child(body) .vertical_scrollbar(&self.list_state), ) + .children(self.render_floating(window, cx)) .into_any_element() } } @@ -1589,8 +1523,64 @@ mod tests { // to copy, so copying is a safe no-op. view.update(cx, |view, cx| { view.begin_selection(key.clone(), 1, cx); - assert!(view.selection.is_some()); + assert!(view.selection.get().is_some()); assert!(view.selected_text().is_none()); }); } + + #[gpui_kit::test] + fn a_selection_shows_the_pill_and_its_comment_button_opens_the_card(cx: &mut TestAppContext) { + let key: FileKey = (PathBuf::from("/repo"), "a.rs".into()); + let (view, cx) = view_with_files(vec![added_file("a.rs")], cx); + view.update(cx, |view, cx| { + view.file_diffs.insert( + key.clone(), + LoadedDiff { + file: added_file("a.rs"), + prepared: prepared(10), + stamp: 1, + }, + ); + view.begin_selection(key.clone(), 2, cx); + view.extend_selection(&key, 4, cx); + view.end_selection(); + cx.notify(); + }); + // The first frame paints the rows and records the selection's end; + // the next one floats the pill there. + cx.run_until_parked(); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + + let bounds = cx + .debug_bounds("review-selection-comment") + .expect("the pill shows"); + cx.simulate_click(bounds.center(), gpui_kit::Modifiers::none()); + cx.run_until_parked(); + + view.read_with(cx, |view, cx| { + let editor = view.comment_editor.as_ref().expect("the card opened"); + let comment = editor.read(cx).comment(); + assert_eq!((comment.start_line, comment.end_line), (3, 5)); + assert!(comment.in_diff); + assert!(comment.excerpt.starts_with("+line 2")); + }); + assert!(cx.debug_bounds("review-selection-comment").is_none()); + + // A press elsewhere closes the card… + let outside = gpui_kit::point(gpui_kit::px(4.), gpui_kit::px(4.)); + cx.simulate_click(outside, gpui_kit::Modifiers::none()); + cx.run_until_parked(); + view.read_with(cx, |view, _| assert!(view.comment_editor.is_none())); + + // …and hides the pill until the selection changes. + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + assert!(cx.debug_bounds("review-selection-comment").is_some()); + cx.simulate_click(outside, gpui_kit::Modifiers::none()); + cx.run_until_parked(); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + assert!(cx.debug_bounds("review-selection-comment").is_none()); + } } diff --git a/crates/ui_gpui/src/main_screen/right_panel/review_view/comments.rs b/crates/ui_gpui/src/main_screen/right_panel/review_view/comments.rs new file mode 100644 index 00000000..d50cdc30 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/review_view/comments.rs @@ -0,0 +1,364 @@ +//! Line comments on the Review view's diffs. +//! +//! A selection of diff rows becomes a comment on the file's new-side lines +//! (or old-side lines when only deleted rows are selected), with the rows as +//! excerpt, `+`/`-` prefixed. Rows covered by any comment on the file are +//! marked, whether the comment was made here or in the Files view. + +use super::*; +use crate::comments::CommentChange; +use crate::comments::editor::{CommentEditor, CommentEditorEvent}; +use code_assistant_core::line_comments::LineComment; +use similar::ChangeTag; +use std::ops::Range; + +/// One diff row's change tag and its 1-based line numbers in the old and +/// new file, for every row of the file's hunks in render order. +pub(super) fn row_numbers(prepared: &PreparedReviewDiff) -> Vec<(ChangeTag, usize, usize)> { + let mut rows = Vec::new(); + for hunk in &prepared.hunks { + let (mut old, mut new) = (hunk.old_start, hunk.new_start); + for line in &hunk.lines { + rows.push((line.tag, old, new)); + if line.tag != ChangeTag::Insert { + old += 1; + } + if line.tag != ChangeTag::Delete { + new += 1; + } + } + } + rows +} + +/// Whether `comment` covers a row with these numbers. +fn covers(comment: &LineComment, (tag, old, new): (ChangeTag, usize, usize)) -> bool { + let lines = comment.start_line..=comment.end_line; + if comment.old_side { + tag == ChangeTag::Delete && lines.contains(&old) + } else { + tag != ChangeTag::Delete && lines.contains(&new) + } +} + +impl ReviewView { + fn file_of(key: &FileKey) -> PathBuf { + key.0.join(&key.1) + } + + pub fn set_comments(&mut self, comments: Vec, cx: &mut Context) { + if self.comments != comments { + self.comments = comments; + cx.notify(); + } + } + + /// Rows of a chunk (relative to it) covered by a comment. + pub(super) fn chunk_marks( + &self, + key: &FileKey, + prepared: &PreparedReviewDiff, + base_line: usize, + row_count: usize, + ) -> Vec> { + let file = Self::file_of(key); + let comments: Vec<&LineComment> = self.comments.iter().filter(|c| c.file == file).collect(); + if comments.is_empty() { + return Vec::new(); + } + let numbers = row_numbers(prepared); + (0..row_count) + .filter(|&row| { + numbers + .get(base_line + row) + .is_some_and(|&n| comments.iter().any(|c| covers(c, n))) + }) + .map(|row| row..row + 1) + .collect() + } + + /// The comment for the current selection: an existing one on those rows, + /// else a new one. + fn selection_comment(&self) -> Option { + let sel = self.selection.get()?; + let prepared = &self.file_diffs.get(&sel.key)?.prepared; + let numbers = row_numbers(prepared); + let lines: Vec<_> = prepared.hunks.iter().flat_map(|h| h.lines.iter()).collect(); + let (lo, hi) = sel.range(); + let hi = hi.min(numbers.len().checked_sub(1)?); + if lo > hi { + return None; + } + let file = Self::file_of(&sel.key); + let rows = &numbers[lo..=hi]; + if let Some(existing) = self + .comments + .iter() + .find(|c| c.file == file && rows.iter().any(|&n| covers(c, n))) + { + return Some(existing.clone()); + } + let new_side: Vec = rows + .iter() + .filter(|(tag, ..)| *tag != ChangeTag::Delete) + .map(|&(_, _, new)| new) + .collect(); + let (old_side, numbers_used) = if new_side.is_empty() { + (true, rows.iter().map(|&(_, old, _)| old).collect()) + } else { + (false, new_side) + }; + let excerpt = lines[lo..=hi] + .iter() + .map(|l| { + let prefix = match l.tag { + ChangeTag::Insert => '+', + ChangeTag::Delete => '-', + ChangeTag::Equal => ' ', + }; + format!("{prefix}{}", l.text) + }) + .collect::>() + .join("\n"); + Some(LineComment { + id: 0, + file, + start_line: *numbers_used.iter().min()?, + end_line: *numbers_used.iter().max()?, + old_side, + in_diff: true, + on_message: false, + excerpt, + text: String::new(), + }) + } + + /// Whether the chunk of `key` starting at flat line `base_line` holds the + /// selection's last row. + pub(super) fn anchors_selection_end( + &self, + key: &FileKey, + base_line: usize, + rows: usize, + ) -> bool { + self.selection.get().is_some_and(|sel| { + let (_, hi) = sel.range(); + &sel.key == key && (base_line..base_line + rows).contains(&hi) + }) + } + + /// The selection pill under the selection's last row, or the comment + /// card while one is open. + pub(super) fn render_floating( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + use crate::comments::{floating, selection_pill}; + use gpui_kit::point; + // Written by the last paint; cleared so a scrolled-away row hides it. + let row = self.selection_anchor.take(); + if let Some(editor) = &self.comment_editor { + let position = row + .map(|b| point(b.left() + px(28.), b.bottom() + px(2.))) + .or(self.anchor.last()); + self.anchor.track(position, window); + return position.map(|p| floating(p, editor.clone())); + } + let dismissed = self.selection.get() == self.pill_dismissed.as_ref(); + let position = row + .filter(|_| { + self.selection.get().is_some() && !self.selection.is_dragging() && !dismissed + }) + .map(|b| point(b.right() - px(72.), b.bottom() + px(2.))); + self.anchor.track(position, window); + let view = cx.entity().downgrade(); + let view_for_comment = view.clone(); + // A press elsewhere hides the pill until the selection changes. + let dismiss = cx.listener(|this, _: &gpui_kit::MouseDownEvent, _, cx| { + this.pill_dismissed = this.selection.get().cloned(); + cx.notify(); + }); + Some(floating( + position?, + div().on_mouse_down_out(dismiss).child(selection_pill( + "review-selection", + move |_, cx| { + view.update(cx, |view, cx| view.copy_selection(cx)).ok(); + }, + move |window, cx| { + view_for_comment + .update(cx, |view, cx| view.start_comment(window, cx)) + .ok(); + }, + cx, + )), + )) + } + + pub(super) fn start_comment(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(comment) = self.selection_comment() { + self.open_comment_editor(comment, window, cx); + } + } + + fn open_comment_editor( + &mut self, + comment: LineComment, + window: &mut Window, + cx: &mut Context, + ) { + let editor = cx.new(|cx| CommentEditor::new(comment, window, cx)); + self._comment_editor_subscription = Some(cx.subscribe_in( + &editor, + window, + |this, _, event: &CommentEditorEvent, window, cx| { + match event { + CommentEditorEvent::Save(comment) => { + cx.emit(CommentChange::Upsert(comment.clone())) + } + CommentEditorEvent::Delete(id) => cx.emit(CommentChange::Remove(*id)), + CommentEditorEvent::Cancel => {} + } + this.comment_editor = None; + this._comment_editor_subscription = None; + window.focus(&this.focus_handle, cx); + cx.notify(); + }, + )); + self.comment_editor = Some(editor); + cx.notify(); + } + + /// Show `comment`'s rows: expand its repo and file, select the rows, + /// scroll to them and open the comment. Waits for the listing and the + /// file's diff. + pub fn reveal_comment(&mut self, comment: LineComment, cx: &mut Context) { + self.pending_reveal = Some(comment); + cx.notify(); + } + + /// Advance a pending reveal as far as the loaded data allows; called + /// from `render`. + pub(super) fn apply_pending_reveal(&mut self, window: &mut Window, cx: &mut Context) { + let Some(comment) = self.pending_reveal.clone() else { + return; + }; + let found = self.repos.iter().enumerate().find_map(|(repo, section)| { + section + .files + .iter() + .position(|f| section.repo_root.join(&f.path) == comment.file) + .map(|file| (repo, file)) + }); + let Some((repo, file)) = found else { + if self.has_listing { + // Not among the changed files (any more). + self.pending_reveal = None; + } + return; + }; + let key: FileKey = ( + self.repos[repo].repo_root.clone(), + self.repos[repo].files[file].path.clone(), + ); + if self.repos[repo].collapsed || self.collapsed_files.contains(&key) { + self.repos[repo].collapsed = false; + self.collapsed_files.remove(&key); + self.ensure_diff_request(cx); + cx.notify(); + } + let Some(loaded) = self.file_diffs.get(&key) else { + return; + }; + self.pending_reveal = None; + let numbers = row_numbers(&loaded.prepared); + let covered: Vec = numbers + .iter() + .enumerate() + .filter(|&(_, &n)| covers(&comment, n)) + .map(|(ix, _)| ix) + .collect(); + if let (Some(&first), Some(&last)) = (covered.first(), covered.last()) { + self.selection.begin(key.clone(), first, false); + self.selection.extend(&key, last); + self.selection.end(); + // Scroll to the chunk holding the first row. + let chunks = &loaded.prepared.chunked.chunks; + let mut base = 0; + let mut chunk_ix = None; + for (ix, chunk) in chunks.iter().enumerate() { + let start: usize = loaded.prepared.hunks[..chunk.hunk] + .iter() + .map(|h| h.lines.len()) + .sum::() + + chunk.lines.start; + if start <= first { + chunk_ix = Some(ix); + base = start; + } + } + let _ = base; + self.sync_rows(); + if let Some(chunk) = chunk_ix + && let Some(row) = self.rows.iter().position(|r| { + matches!(r, ReviewRow::Chunk { repo: r_repo, file: r_file, chunk: c, .. } + if *r_repo == repo && *r_file == file && *c == chunk) + }) + { + self.list_state.scroll_to_reveal_item(row); + } + } + self.open_comment_editor(comment, window, cx); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn comment(lines: (usize, usize), old_side: bool) -> LineComment { + LineComment { + id: 1, + file: PathBuf::from("/r/a.rs"), + start_line: lines.0, + end_line: lines.1, + old_side, + in_diff: true, + on_message: false, + excerpt: String::new(), + text: String::new(), + } + } + + #[test] + fn row_numbers_count_each_side() { + let prepared = PreparedReviewDiff::from_content( + "a.rs", + &git::FileDiffContent { + old_text: Some("a\nb\nc\n".into()), + new_text: Some("a\nB\nc\nd\n".into()), + is_binary: false, + too_large: false, + }, + ); + let numbers = row_numbers(&prepared); + let tags: Vec<_> = numbers.iter().map(|n| n.0).collect(); + assert_eq!( + tags, + vec![ + ChangeTag::Equal, + ChangeTag::Delete, + ChangeTag::Insert, + ChangeTag::Equal, + ChangeTag::Insert + ] + ); + assert_eq!(numbers[1], (ChangeTag::Delete, 2, 2)); + assert_eq!(numbers[2], (ChangeTag::Insert, 3, 2)); + assert_eq!(numbers[4], (ChangeTag::Insert, 4, 4)); + assert!(covers(&comment((2, 2), false), numbers[2])); + assert!(!covers(&comment((2, 2), false), numbers[1])); + assert!(covers(&comment((2, 2), true), numbers[1])); + } +} diff --git a/crates/ui_gpui/src/main_screen/tests/comments.rs b/crates/ui_gpui/src/main_screen/tests/comments.rs new file mode 100644 index 00000000..84cd6daf --- /dev/null +++ b/crates/ui_gpui/src/main_screen/tests/comments.rs @@ -0,0 +1,180 @@ +//! Line comments in the composer, and the right panel: file requests and +//! the title bar switch. + +use crate::main_screen::right_panel::{CommentChange, RightPanelView}; +use crate::shared::open_file::{OpenFileRequest, request}; +use crate::test_support::{MainScreenTest, TestCore}; +use code_assistant_core::line_comments::LineComment; +use code_assistant_core::mocks::{MockLLMProvider, create_test_response_text}; +use code_assistant_core::persistence::DraftAttachment; +use gpui_kit::TestAppContext; +use std::path::PathBuf; + +fn comment(file: &str, lines: (usize, usize), text: &str) -> LineComment { + LineComment { + id: 0, + file: PathBuf::from(file), + start_line: lines.0, + end_line: lines.1, + old_side: false, + in_diff: false, + on_message: false, + excerpt: "let x = y.unwrap();".into(), + text: text.into(), + } +} + +impl MainScreenTest { + fn add_comment(&mut self, comment: LineComment) { + let panel = self + .main_screen + .read_with(&self.cx, |s, _| s.right_panel.clone()); + panel.update(&mut self.cx, |_, cx| { + cx.emit(CommentChange::Upsert(comment)) + }); + self.settle(); + } + + fn comments(&mut self) -> Vec { + let input = self + .main_screen + .read_with(&self.cx, |s, _| s.input_area.clone()); + input.read_with(&self.cx, |input, _| input.comments().to_vec()) + } +} + +#[gpui_kit::test] +fn comments_from_the_panel_land_in_the_draft(cx: &mut TestAppContext) { + let mut test = MainScreenTest::new(cx); + test.view_session("a"); + test.add_comment(comment("/p/src/a.rs", (3, 4), "no unwrap")); + test.add_comment(comment("/p/src/b.rs", (9, 9), "rename")); + + let comments = test.comments(); + assert_eq!(comments.len(), 2); + assert_eq!((comments[0].id, comments[1].id), (1, 2)); + let draft = test.stores.drafts.stored("a").expect("draft saved"); + assert!(matches!( + draft.attachments.as_slice(), + [DraftAttachment::LineComments { comments }] if comments.len() == 2 + )); + + // Editing keeps the id; removing the last one drops the attachment. + let mut edited = comments[0].clone(); + edited.text = "use ?".into(); + test.add_comment(edited); + assert_eq!(test.comments()[0].text, "use ?"); + let panel = test + .main_screen + .read_with(&test.cx, |s, _| s.right_panel.clone()); + for id in [1, 2] { + panel.update(&mut test.cx, |_, cx| cx.emit(CommentChange::Remove(id))); + } + test.settle(); + assert!(test.comments().is_empty()); +} + +#[gpui_kit::test] +fn comments_survive_switching_sessions(cx: &mut TestAppContext) { + let mut test = MainScreenTest::new(cx); + test.view_session("a"); + test.add_comment(comment("/p/src/a.rs", (3, 4), "no unwrap")); + test.view_session("b"); + assert!(test.comments().is_empty()); + test.view_session("a"); + assert_eq!(test.comments().len(), 1); +} + +#[gpui_kit::test] +fn comments_go_with_the_next_message(cx: &mut TestAppContext) { + let core = TestCore::new( + MockLLMProvider::new(vec![Ok(create_test_response_text("ok"))]) + .streaming() + .into_factory(), + ); + let mut test = MainScreenTest::with_core(cx, core); + test.open_new_session(); + test.add_comment(comment("/p/src/a.rs", (3, 4), "no unwrap")); + + test.send("please fix"); + test.wait_until("the agent replied", |test| { + !test.agent_is_running() && test.transcript().len() == 2 + }); + + let transcript = test.transcript(); + assert!(transcript[0].starts_with("user: please fix")); + assert!( + transcript[0].contains(""), + "{transcript:?}" + ); + assert!(test.comments().is_empty()); +} + +#[gpui_kit::test] +fn an_open_file_request_shows_the_file_in_the_panel(cx: &mut TestAppContext) { + let mut test = MainScreenTest::new(cx); + test.view_session("a"); + test.cx.update(|_, cx| { + request( + OpenFileRequest { + path: "./src/lib.rs".into(), + line: Some(3), + project: None, + }, + cx, + ) + }); + test.settle(); + + let (collapsed, panel) = test.main_screen.read_with(&test.cx, |s, _| { + (s.right_sidebar_collapsed, s.right_panel.clone()) + }); + assert!(!collapsed, "the panel opened"); + panel.read_with(&test.cx, |panel, cx| { + assert_eq!(panel.active_view(), RightPanelView::Files); + assert_eq!(panel.files_open_path(cx).as_deref(), Some("src/lib.rs")); + }); +} + +fn panel_state(test: &mut MainScreenTest) -> Option { + let (collapsed, panel) = test.main_screen.read_with(&test.cx, |s, _| { + (s.right_sidebar_collapsed, s.right_panel.clone()) + }); + (!collapsed).then(|| panel.read_with(&test.cx, |panel, _| panel.active_view())) +} + +#[gpui_kit::test] +fn the_title_bar_switch_opens_switches_and_closes_the_panel(cx: &mut TestAppContext) { + let mut test = MainScreenTest::new(cx); + test.view_session("a"); + assert_eq!(panel_state(&mut test), None); + + test.click("right-panel-view-Files"); + assert_eq!(panel_state(&mut test), Some(RightPanelView::Files)); + test.click("right-panel-view-Review"); + assert_eq!(panel_state(&mut test), Some(RightPanelView::Review)); + test.click("right-panel-view-Review"); + assert_eq!(panel_state(&mut test), None); +} + +#[gpui_kit::test] +fn a_comment_on_a_message_reaches_the_draft_and_the_transcript(cx: &mut TestAppContext) { + let mut test = MainScreenTest::new(cx); + test.view_session("a"); + let mut on_message = LineComment::on_message("Use a ledger first."); + on_message.text = "why not both?".into(); + test.cx.update(|_, cx| { + crate::comments::report(crate::comments::CommentChange::Upsert(on_message), cx) + }); + test.settle(); + + let comments = test.comments(); + assert_eq!(comments.len(), 1); + assert!(comments[0].on_message); + // Message blocks see it through the global, to mark the quoted message. + test.cx.update(|_, cx| { + let marked = crate::comments::message_comments(cx); + assert_eq!(marked.len(), 1); + assert_eq!(marked[0].excerpt, "Use a ledger first."); + }); +} diff --git a/crates/ui_gpui/src/main_screen/tests/mod.rs b/crates/ui_gpui/src/main_screen/tests/mod.rs index 122ad1a3..5f49e9d5 100644 --- a/crates/ui_gpui/src/main_screen/tests/mod.rs +++ b/crates/ui_gpui/src/main_screen/tests/mod.rs @@ -1,6 +1,7 @@ //! Behaviour of the main screen, driven through //! [`MainScreenTest`](crate::test_support::MainScreenTest). +mod comments; mod composer; mod end_to_end; mod stored_outputs; diff --git a/crates/ui_gpui/src/messages/mod.rs b/crates/ui_gpui/src/messages/mod.rs index 679de595..0c880556 100644 --- a/crates/ui_gpui/src/messages/mod.rs +++ b/crates/ui_gpui/src/messages/mod.rs @@ -391,6 +391,13 @@ impl MessagesView { /// blocks, etc.) doesn't jerk the viewport. The animation loop /// recalculates the target each frame from `max_offset_for_scrollbar()`, /// so it naturally chases a continuously moving bottom. + /// Scroll so message `ix` is in view. + pub fn reveal_message(&self, ix: usize) { + if ix < self.list_state.item_count() { + self.list_state.scroll_to_reveal_item(ix); + } + } + pub fn scroll_to_bottom(&mut self) { if self.list_state.item_count() == 0 { return; diff --git a/crates/ui_gpui/src/shared/mod.rs b/crates/ui_gpui/src/shared/mod.rs index 746a80ea..54c3350f 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 frame_profile; pub mod image; +pub mod open_file; pub mod plan_banner; pub mod review_cache; pub mod segmented_switch; diff --git a/crates/ui_gpui/src/shared/open_file.rs b/crates/ui_gpui/src/shared/open_file.rs new file mode 100644 index 00000000..76ea8bd2 --- /dev/null +++ b/crates/ui_gpui/src/shared/open_file.rs @@ -0,0 +1,47 @@ +//! Requests to show a project file in the right panel's Files view, from +//! anywhere in the UI (e.g. a file path in a tool card). +//! +//! Elements deep in the transcript cannot reach the right panel directly: +//! they emit an [`OpenFileRequest`] on the app-wide [`OpenFileBus`], which +//! the main screen subscribes to. + +use gpui_kit::{ + App, Div, Entity, EventEmitter, Global, InteractiveElement, Stateful, + StatefulInteractiveElement, +}; + +/// Show `path` (relative to the project root, or absolute inside it) and +/// select `line` (1-based) when given. `project` names the project the +/// path belongs to, when the source knows it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OpenFileRequest { + pub path: String, + pub line: Option, + pub project: Option, +} + +pub struct OpenFileBus; + +impl EventEmitter for OpenFileBus {} + +#[derive(Clone)] +pub struct OpenFileGlobal(pub Entity); + +impl Global for OpenFileGlobal {} + +/// Ask for `request` to be shown; a no-op before the main screen exists. +pub fn request(request: OpenFileRequest, cx: &mut App) { + if let Some(bus) = cx.try_global::().map(|g| g.0.clone()) { + bus.update(cx, |_, cx| cx.emit(request)); + } +} + +/// Make `element` open `request` on click, with a pointer cursor and an +/// underline on hover. +pub fn clickable(element: Stateful
, request: OpenFileRequest) -> Stateful
{ + use gpui_kit::Styled; + element + .cursor_pointer() + .hover(|s| s.underline()) + .on_click(move |_, _, cx| self::request(request.clone(), cx)) +} diff --git a/crates/ui_gpui/src/shared/segmented_switch.rs b/crates/ui_gpui/src/shared/segmented_switch.rs index f129d456..c160db54 100644 --- a/crates/ui_gpui/src/shared/segmented_switch.rs +++ b/crates/ui_gpui/src/shared/segmented_switch.rs @@ -1,5 +1,6 @@ //! A switch between a few views, as in the left sidebar's header (Active | -//! Projects) and the right panel's (Review | Browser). +//! Projects) and the title bar's right panel switch (Review | Files | +//! Browser). //! //! A segmented control like gpui-component's, drawn here because that one //! paints its active segment from the theme's global background token and @@ -30,6 +31,32 @@ pub fn segmented_switch( on_select: impl Fn(T, &mut Window, &mut App) + 'static, window: &mut Window, cx: &mut App, +) -> impl IntoElement { + switch(id, segments, Some(selected), false, on_select, window, cx) +} + +/// Like [`segmented_switch`], but nothing may be selected (no pill), and +/// clicking the selected segment calls `on_click` too, so a click on it can +/// mean "close". +pub fn segmented_toggle( + id: &'static str, + segments: &[(T, &'static str)], + selected: Option, + on_click: impl Fn(T, &mut Window, &mut App) + 'static, + window: &mut Window, + cx: &mut App, +) -> impl IntoElement { + switch(id, segments, selected, true, on_click, window, cx) +} + +fn switch( + id: &'static str, + segments: &[(T, &'static str)], + selected: Option, + selected_clickable: bool, + on_select: impl Fn(T, &mut Window, &mut App) + 'static, + window: &mut Window, + cx: &mut App, ) -> impl IntoElement { let (track, active) = if cx.theme().is_dark() { (hsla(0., 0., 0., 0.25), hsla(0., 0., 1., 0.12)) @@ -43,11 +70,9 @@ pub fn segmented_switch( .blur_radius(px(1.)) .spread_radius(px(-1.)), ]; - let index = segments - .iter() - .position(|(value, _)| *value == selected) - .unwrap_or_default(); - let target = rems(index as f32 * (SEGMENT_WIDTH.0 + SEGMENT_GAP.0)); + let index = + selected.and_then(|selected| segments.iter().position(|(value, _)| *value == selected)); + let target = rems(index.unwrap_or_default() as f32 * (SEGMENT_WIDTH.0 + SEGMENT_GAP.0)); let pill_left = spring( (id, "left"), target, @@ -68,21 +93,24 @@ pub fn segmented_switch( .p(TRACK_PADDING) .rounded_lg() .bg(track) - .child( - div() - .absolute() - .top(TRACK_PADDING) - .left(rems(TRACK_PADDING.0 + pill_left.0)) - .w(SEGMENT_WIDTH) - .h(SEGMENT_HEIGHT) - .rounded_md() - .bg(active) - .shadow(shadow), - ) + .when(index.is_some(), |track| { + track.child( + div() + .absolute() + .top(TRACK_PADDING) + .left(rems(TRACK_PADDING.0 + pill_left.0)) + .w(SEGMENT_WIDTH) + .h(SEGMENT_HEIGHT) + .rounded_md() + .bg(active) + .shadow(shadow), + ) + }) .children(segments.iter().enumerate().map(|(i, &(value, label))| { let on_select = on_select.clone(); div() .id(SharedString::from(format!("{id}-{label}"))) + .debug_selector(move || format!("{id}-{label}")) .w(SEGMENT_WIDTH) .h(SEGMENT_HEIGHT) .flex() @@ -90,12 +118,17 @@ pub fn segmented_switch( .justify_center() .text_size(rems(0.8125)) .map(|el| { - if i == index { + let is_selected = index == Some(i); + if is_selected && !selected_clickable { el.text_color(foreground) } else { + let el = if is_selected { + el.text_color(foreground) + } else { + el.text_color(muted) + .hover(move |s| s.text_color(foreground)) + }; el.cursor_pointer() - .text_color(muted) - .hover(move |s| s.text_color(foreground)) .on_click(move |_: &ClickEvent, window, cx| { on_select(value, window, cx) }) diff --git a/crates/ui_gpui/src/shared/ui_state.rs b/crates/ui_gpui/src/shared/ui_state.rs index dafd5259..366ddde7 100644 --- a/crates/ui_gpui/src/shared/ui_state.rs +++ b/crates/ui_gpui/src/shared/ui_state.rs @@ -87,6 +87,29 @@ pub struct UiSessionState { /// Last review compare mode ("working_tree" or "branch_vs_base"). #[serde(default, skip_serializing_if = "Option::is_none")] pub review_compare_mode: Option, + + /// What the Files view showed: open file, expanded directories, whether + /// gitignored entries were shown. + #[serde(default, skip_serializing_if = "FilesViewState::is_default")] + pub files_view: FilesViewState, +} + +/// The Files view's state for one session. Paths are `/`-separated and +/// relative to the project root. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct FilesViewState { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub open_file: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub expanded: Vec, + #[serde(default)] + pub show_ignored: bool, +} + +impl FilesViewState { + fn is_default(&self) -> bool { + *self == Self::default() + } } // --------------------------------------------------------------------------- @@ -290,13 +313,11 @@ impl UiStateStore { } /// Which view the right panel last showed for a session. - #[cfg(feature = "browser-panel")] pub fn get_right_panel_view(&mut self, session_id: &str) -> Option { self.get(session_id).right_panel_view } /// Persist which view the right panel shows for a session. - #[cfg(feature = "browser-panel")] pub fn set_right_panel_view(&mut self, session_id: &str, view: &str) { let state = self.states.entry(session_id.to_owned()).or_default(); if state.right_panel_view.as_deref() == Some(view) { @@ -306,6 +327,21 @@ impl UiStateStore { self.dirty.insert(session_id.to_owned()); } + /// The Files view's state for a session. + pub fn get_files_view(&mut self, session_id: &str) -> FilesViewState { + self.get(session_id).files_view + } + + /// Persist the Files view's state for a session. + pub fn set_files_view(&mut self, session_id: &str, files_view: FilesViewState) { + let state = self.states.entry(session_id.to_owned()).or_default(); + if state.files_view == files_view { + return; + } + state.files_view = files_view; + 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 diff --git a/crates/ui_gpui/src/tool_cards/code_card.rs b/crates/ui_gpui/src/tool_cards/code_card.rs index cdc9b9c2..d9663659 100644 --- a/crates/ui_gpui/src/tool_cards/code_card.rs +++ b/crates/ui_gpui/src/tool_cards/code_card.rs @@ -78,15 +78,16 @@ impl ToolBlockRenderer for CodeCardRenderer { } let rem_size = window.rem_size(); + let project = get_param(tool, "project"); // Try to parse structured JSON output from render_for_ui() if let Ok(json) = serde_json::from_str::(output) { match json.get("kind").and_then(|k| k.as_str()) { Some("read_files") => { - return render_read_files_output(&json, theme, rem_size); + return render_read_files_output(&json, project, theme, rem_size); } Some("search_files") => { - return render_search_files_output(&json, theme, rem_size); + return render_search_files_output(&json, project, theme, rem_size); } _ => {} } @@ -101,8 +102,31 @@ impl ToolBlockRenderer for CodeCardRenderer { // read_files renderer // --------------------------------------------------------------------------- +/// A file header that opens `path` at `line` in the Files view. +fn open_file_label( + text: String, + path: &str, + line: usize, + project: Option<&str>, +) -> gpui_kit::AnyElement { + use gpui_kit::InteractiveElement; + crate::shared::open_file::clickable( + div().id(SharedString::from(format!( + "open-file-{path}-{line}-{text}" + ))), + crate::shared::open_file::OpenFileRequest { + path: path.to_owned(), + line: Some(line), + project: project.map(str::to_owned), + }, + ) + .child(text) + .into_any() +} + fn render_read_files_output( json: &Value, + project: Option<&str>, theme: &gpui_kit::component::theme::Theme, rem_size: gpui_kit::Pixels, ) -> Option { @@ -154,14 +178,19 @@ fn render_read_files_output( continue; } - // File header + // File header; opens the file in the Files view. children.push( div() .w_full() .px_3() .py_0p5() .text_color(theme.muted_foreground.opacity(0.7)) - .child(format!("── {} ──", path)) + .child(open_file_label( + format!("── {} ──", path), + path, + start_line, + project, + )) .into_any(), ); @@ -200,6 +229,7 @@ fn render_read_files_output( fn render_search_files_output( json: &Value, + project: Option<&str>, theme: &gpui_kit::component::theme::Theme, rem_size: gpui_kit::Pixels, ) -> Option { @@ -326,7 +356,12 @@ fn render_search_files_output( .pt_1() .pb_0p5() .text_color(theme.muted_foreground.opacity(0.7)) - .child(format!("── {}:{}-{} ──", file, start_line, end_line)) + .child(open_file_label( + format!("── {}:{}-{} ──", file, start_line, end_line), + file, + start_line, + project, + )) .into_any(), ); diff --git a/crates/ui_gpui/src/tool_cards/diff_card.rs b/crates/ui_gpui/src/tool_cards/diff_card.rs index 003ffbaf..29999cb5 100644 --- a/crates/ui_gpui/src/tool_cards/diff_card.rs +++ b/crates/ui_gpui/src/tool_cards/diff_card.rs @@ -129,13 +129,26 @@ impl ToolBlockRenderer for DiffCardRenderer { } else { tool.name.replace('_', " ") }; - header_left = header_left.child( - div() - .text_size(rems(0.75)) - .text_color(header_text_color) - .overflow_hidden() - .child(header_label), - ); + let label = div() + .id(SharedString::from(format!("tool-path-{}", tool.id))) + .text_size(rems(0.75)) + .text_color(header_text_color) + .overflow_hidden() + .child(header_label); + // A single, still existing file opens in the Files view. + let single_path = get_param(tool, "path").filter(|_| tool.name != "delete_files"); + header_left = header_left.child(match single_path { + Some(path) => crate::shared::open_file::clickable( + label, + crate::shared::open_file::OpenFileRequest { + path: path.to_owned(), + line: None, + project: get_param(tool, "project").map(str::to_owned), + }, + ) + .into_any_element(), + None => label.into_any_element(), + }); let mut header_right = div().flex().flex_row().items_center().gap_1(); if has_error { @@ -406,6 +419,9 @@ impl DiffCardSelection { base_line, highlight, color: self.color, + marked: Vec::new(), + mark_color: self.color, + anchor: None, on_start, on_drag, on_end, diff --git a/crates/ui_gpui/src/tool_cards/diff_rows.rs b/crates/ui_gpui/src/tool_cards/diff_rows.rs index c518a18f..65512093 100644 --- a/crates/ui_gpui/src/tool_cards/diff_rows.rs +++ b/crates/ui_gpui/src/tool_cards/diff_rows.rs @@ -64,6 +64,12 @@ pub(crate) struct RowSelection { pub highlight: Range, /// Highlight color (usually the theme's selection background). pub color: Hsla, + /// Local rows carrying a comment, marked by a bar at the left edge. + pub marked: Vec>, + pub mark_color: Hsla, + /// Receives the window bounds of the last highlighted row when painted, + /// so the owner can float things next to the selection's end. + pub anchor: Option>>>>, /// Pointer pressed on a row: `base_line + row`. pub on_start: LineCallback, /// Pointer dragged over a row while the button is held. @@ -260,7 +266,7 @@ fn paint_rows( layout: &RowsLayout, geometry: RowGeometry, bounds: Bounds, - selection: Option<(&Range, Hsla)>, + selection: Option<&RowSelection>, window: &mut Window, cx: &mut App, ) { @@ -278,15 +284,23 @@ fn paint_rows( } // Selection sits above the add/delete tint but below the glyphs, so // the selected text stays readable. - if let Some((range, color)) = selection - && range.contains(&ix) + if let Some(sel) = selection + && sel.highlight.contains(&ix) { window.paint_quad(fill( Bounds::new( point(bounds.origin.x, y), size(bounds.size.width, row_layout.height), ), - color, + sel.color, + )); + } + if let Some(sel) = selection + && sel.marked.iter().any(|range| range.contains(&ix)) + { + window.paint_quad(fill( + Bounds::new(point(bounds.origin.x, y), size(px(3.), row_layout.height)), + sel.mark_color, )); } if let Some(gutter) = &row_layout.gutter { @@ -322,7 +336,9 @@ impl IntoElement for DiffRows { impl Element for DiffRows { type RequestLayoutState = LayoutCell; - type PrepaintState = (); + /// Selectable rows take presses through a hitbox, so something floating + /// above them (a selection pill) keeps its clicks. + type PrepaintState = Option; fn id(&self) -> Option { None @@ -372,11 +388,14 @@ impl Element for DiffRows { &mut self, _: Option<&GlobalElementId>, _: Option<&InspectorElementId>, - _: Bounds, + bounds: Bounds, _: &mut LayoutCell, - _: &mut Window, + window: &mut Window, _: &mut App, - ) { + ) -> Option { + self.selection + .is_some() + .then(|| window.insert_hitbox(bounds, gpui_kit::HitboxBehavior::Normal)) } fn paint( @@ -385,7 +404,7 @@ impl Element for DiffRows { _: Option<&InspectorElementId>, bounds: Bounds, cell: &mut LayoutCell, - _: &mut (), + hitbox: &mut Option, window: &mut Window, cx: &mut App, ) { @@ -394,16 +413,12 @@ impl Element for DiffRows { return; }; - let selection = self - .selection - .as_ref() - .map(|s| (s.highlight.clone(), s.color)); paint_rows( &self.rows, layout, self.geometry, bounds, - selection.as_ref().map(|(range, color)| (range, *color)), + self.selection.as_ref(), window, cx, ); @@ -419,6 +434,15 @@ impl Element for DiffRows { y += row.height; } tops.push(y); + if let Some(anchor) = &sel.anchor + && let Some(last) = sel.highlight.end.checked_sub(1) + && last < layout.rows.len() + { + anchor.set(Some(Bounds::new( + point(bounds.origin.x, tops[last]), + size(bounds.size.width, tops[last + 1] - tops[last]), + ))); + } let tops = Rc::new(tops); let base = sel.base_line; let row_count = layout.rows.len(); @@ -430,10 +454,13 @@ impl Element for DiffRows { drop(borrow); let tops_down = tops.clone(); + let hitbox = hitbox.clone(); window.on_mouse_event(move |e: &MouseDownEvent, phase, window, cx| { if phase == DispatchPhase::Bubble && e.button == MouseButton::Left - && bounds.contains(&e.position) + && hitbox + .as_ref() + .map_or(bounds.contains(&e.position), |h| h.is_hovered(window)) { on_start( local_line(&tops_down, base, row_count, e.position.y), @@ -560,7 +587,7 @@ mod tests { let (_, cx) = cx.add_window_view(|_, _| Root); let cx: &mut VisualTestContext = cx; let long = "x".repeat(200); - let (cell, ()) = cx.draw(point(px(0.), px(0.)), size(px(600.), px(400.)), |_, _| { + let (cell, _) = cx.draw(point(px(0.), px(0.)), size(px(600.), px(400.)), |_, _| { DiffRows::new(rows(&["short", "another"]), GEOMETRY) }); { @@ -572,7 +599,7 @@ mod tests { assert_eq!(layout.width, px(600.)); } - let (cell, ()) = cx.draw(point(px(0.), px(0.)), size(px(120.), px(400.)), |_, _| { + let (cell, _) = cx.draw(point(px(0.), px(0.)), size(px(120.), px(400.)), |_, _| { DiffRows::new(rows(&[long.as_str()]), GEOMETRY) }); let layout = cell.borrow(); diff --git a/crates/ui_terminal/src/ui.rs b/crates/ui_terminal/src/ui.rs index 1abbc448..618542e9 100644 --- a/crates/ui_terminal/src/ui.rs +++ b/crates/ui_terminal/src/ui.rs @@ -412,6 +412,9 @@ impl TerminalUI { } => { format!("[file ({filename})]") } + code_assistant_core::persistence::DraftAttachment::LineComments { + comments, + } => format!("[{} line comments]", comments.len()), }) .collect(); if !attachment_lines.is_empty() { diff --git a/docs/files-panel-plan.md b/docs/files-panel-plan.md new file mode 100644 index 00000000..3aaf86f8 --- /dev/null +++ b/docs/files-panel-plan.md @@ -0,0 +1,377 @@ +# Files Panel and Line Comments Plan + +This document plans two connected features for the GPUI frontend: + +1. **Files view** in the right panel: a tree of the project with the + selected file shown next to it. +2. **Line comments**: in the file viewer and in the Review diffs the user + selects lines and adds a comment. Comments collect in the composer as a + compact reference and go to the agent with the next message. + +The idea comes from commenting on a plan in other coding agents, extended to +any file in the project. + +A list of the files the agent wrote in the session (like "changed files" in +git) is out of scope here; see "Later: files written by the agent". + +## Summary + +- A third right-panel view, **Files**, next to Review and Browser: the + project tree on the left, the file on the right. Directories and files are + read through new `SessionService` methods. +- Line selection already exists in the Review view (`DiffSelection`, the + "Copy" button). It grows a **Comment** action, and the same selection model + is reused by the new file viewer. +- Comments are composer state, stored with the draft + (`DraftAttachment::LineComments`), shown as one chip above the input + ("3 comments" or a short preview for one), and sent as one structured text + block the model can read. + +## Status + +All steps of the order of work are built. File paths in tool cards (the +header of file-editing cards, the file headers of `read_files` and +`search_files` results) open the file in the Files view, at the shown line +where there is one; requests travel over the app-wide +`shared::open_file::OpenFileBus`. A file of another project than the +session's is not opened. + +Decisions taken while building: + +- **Editor, not a viewer.** The Files view shows the file in + gpui-component's code editor (`EditorState`: tree-sitter highlighting, line + numbers, search), so the user can edit it too. Edits save on their own + about a second after typing stops, when another file is opened, and at + once on Cmd/Ctrl-S. While the file changed on disk under unsaved edits, + nothing is saved; a banner offers Reload or Overwrite. Saving goes through + `SessionService::write_project_file` and keeps the file's encoding. +- **The Review view keeps its diff rows.** Its selection moved into the + shared `LineSelection` helper; commented rows get a bar at the left edge. +- **Comment card** opens from the selection pill or Cmd/Ctrl-Shift-M (Files + and Review); Cmd/Ctrl-Enter saves, Escape cancels. +- **Markers.** In the editor, commented lines are tinted with editor + decorations, which follow edits; in the Review view, rows covered by a + comment carry a bar. A selection over a commented range edits that + comment instead of adding one. +- **Paths.** A comment stores the file's absolute path while in the draft; + the core makes it project-relative when sending. `in_diff` tells where to + reveal it again (Review or Files). +- **Re-anchoring** happens after a load or save in the editor + (`line_comments::locate`); diff comments are not re-anchored. +- **Comments on chat messages.** Releasing the mouse over selected text in + a message shows the selection pill there; its comment button opens the + card. The comment carries the selected passage (Markdown source) as a + quote and goes to the model as `` with a ``. + A message containing a pending comment's quote gets a border and a badge + that opens the comment; the composer's list scrolls to it. +- **Selection pill and floating card everywhere.** In the editor, the + Review diffs and the chat, a selection shows a small pill (copy, comment) + at its end, and the comment card floats next to the lines instead of + docking at the bottom. Positions come from the last frame's layout; when + they move, one more frame is drawn (`comments::AnchorTracker`). The + Review rows take presses through a hitbox so the pill above them keeps + its clicks. +- **Switching views** happens in the title bar (Review | Files | Browser), + which replaced the panel toggle button: a click opens the panel on that + view or switches to it; clicking the shown view closes the panel. +- **Watching** uses a new `fs_explorer::watch::TreeWatcher` (the git + `ChangeWatcher` only covers repositories and does not say which paths + changed). + +Where the code lives: `fs_explorer::browse` and `fs_explorer::watch`; +`code_assistant_core::line_comments` and `session/service/files.rs`; in +`ui_gpui`, `main_screen/right_panel/{files_view, file_viewer, file_filter, +line_selection, comment_editor}.rs`, `review_view/comments.rs`, +`input/comments.rs` and the transcript chip in `blocks/render.rs`. + +## Where we are today + +- **Right panel.** `RightPanelView` (`ui_gpui/src/main_screen/right_panel/mod.rs`) + switches between `Review` and, with `browser-panel`, `Browser`, via + `segmented_switch` in the header. The view is remembered per session + through `ui_state` (`as_str`/`from_str`). Without `browser-panel` there is no + header at all. +- **Review view** (`review_view.rs`) stacks changed files per repo with their + hunks in a virtualized `list()`. Diff bodies are `DiffRows` elements + (`tool_cards/diff_rows.rs`), which support whole-line selection through + `RowSelection` (`base_line`, `highlight`, `on_start`/`on_drag`/`on_end`). + The view keeps one `DiffSelection { file, anchor, head }` and a Copy button. + Selection indices are flat indices into the file's hunk lines, not file + line numbers. +- **Diff data.** `PreparedReviewDiff` holds hunks of lines with + syntax highlights (`tool_cards/diff_syntax.rs`, `diff_prepare.rs`); each + diff line knows its old/new line numbers (the gutter shows them). +- **Composer.** `InputArea` (`ui_gpui/src/input/mod.rs`) holds text plus + `Vec`; attachments render as `AttachmentView` tiles and + persist with the draft (`SessionDraft`, `DraftStore`). On send, + `utils::content::content_blocks_from` turns each attachment into a content + block (`Text` → text block, `File` → `"File: name\ncontent"`). +- **`fs_explorer`** reads files with encoding detection + (`read_file_with_encoding`, `is_text_file`) and knows gitignore + (`is_path_gitignored`, the explorer's listing). +- **Project root.** `session_effective_path` resolves the session's + directory, worktree-aware; the Review panel uses it already. +- **No file tree and no plain file viewer** exist in the GPUI frontend. + +## UX + +### Files view + +``` +┌ Review | Files | Browser ────────────────────────────────┐ +│ 🔍 filter │ src/session/service.rs ⟳ │ +│ ▾ crates │ 1 use anyhow::… │ +│ ▾ code_assistant_core │ 2 │ +│ ▾ src │ ... │ +│ ▸ session │ 41 pub fn sweep(…) { 💬2 │ +│ lib.rs │ 42 let now = … │ +│ ▸ docs │ │ +│ AGENTS.md │ │ +└──────────────────────────────────────────────────────────┘ +``` + +- **Layout.** Tree on the left (resizable, default ~35% of the panel), viewer + on the right. The panel is narrow (440 px), so the tree can be collapsed to + a file-name dropdown above the viewer; the panel width itself stays + resizable as today. +- **Tree.** A lazy tree of the session's effective project path + (worktree-aware): directories first, loaded on expand; gitignored entries + and `.git` hidden by default (a toggle shows them). Expanded directories + and the open file are remembered per session in `ui_state`. +- **Filter.** A field above the tree matches file names (fuzzy, over a + background walk of the project that respects gitignore); results replace + the tree while the field is not empty. +- **Editor.** The file in a code editor (syntax highlighting, line numbers, + search), editable with auto-save (see Status). Binary files and files over + the size cap show a notice instead of content. +- **Freshness.** The viewer reloads when the open file changes on disk (a + watcher on that file, like the Review view's `git::ChangeWatcher`); the + tree refreshes expanded directories when entries appear or vanish. The + scroll position is kept across reloads. +- **Open from the transcript.** Clicking a file path in a tool card + (`read_files`, `write_file`, `edit`, ...) opens the Files view on that + file and scrolls to the line when the card names one. + +### Commenting + +- **Select.** Click-drag over lines (gutter or text), shift-click extends, + exactly as in the Review view today. A selection never spans files. +- **Act.** A small floating bar at the end of the selection offers + **Comment** and **Copy** (the Review header button moves there). + Shortcut: `Cmd-Shift-M` (or similar) opens the comment editor for the + current selection. +- **Edit.** An inline editor opens below the last selected line: multi-line + input, `Cmd-Enter` saves, `Esc` cancels. Saving adds the comment to the + composer; the editor closes. +- **Markers.** Commented lines show a marker in the gutter (a speech bubble + with a count when several comments touch the line). Hovering shows the + comment; clicking opens it for edit/delete. Markers show in every view + of that file (file viewer and Review diff). +- **In the composer.** All pending comments show as one chip in the + attachment row: + - one comment: `💬 service.rs:41–44 · "this should not unwrap"` (truncated), + - several: `💬 3 comments in 2 files`. + Clicking the chip opens a popover listing each comment (file:lines, the + start of the first selected line, the comment text); + each entry jumps to the place in the Files/Review view, can be edited or + removed. The chip's ✕ removes all comments. +- **Send.** Comments go with the next message, even with empty message text + (the send button is enabled when only comments are pending). After sending + they are cleared from the composer and the gutter markers disappear (the + sent message keeps them, see below). +- **In the transcript.** The user message shows the same chip, collapsed; + expanding it lists the comments with their code excerpts. + +## Data model + +### Comment (core, `code_assistant_core::line_comments`) + +```rust +pub struct LineComment { + pub id: u64, // unique within the draft, for edit/delete + pub file: PathBuf, // absolute in the draft; project-relative once sent + pub start_line: usize, // 1-based, inclusive + pub end_line: usize, + pub old_side: bool, // the lines count in the old file (deleted rows) + pub in_diff: bool, // made in the Review view + pub excerpt: String, // the selected lines, as shown + pub text: String, // the user's comment +} + +pub enum DraftAttachment { + // ...existing variants... + #[serde(rename = "line_comments")] + LineComments { comments: Vec }, +} +``` + +- One `LineComments` attachment per draft holds every comment; the composer + keeps it last in the attachment list. It persists with the draft like the + other attachments, so comments survive switching sessions and restarts. +- `excerpt` is captured when the comment is made. Line numbers alone go stale + as soon as the agent edits the file again; the excerpt lets the model find + the place anyway and lets the transcript render the comment without the + file. +- **Diff lines map to file lines.** The Review selection is in flat hunk + indices; on Comment the view maps them to new-side line numbers (each + prepared diff line knows its numbers). A selection made only of deleted + lines uses the old side (`old_side`). A mixed selection is stored with the + new-side range plus the full excerpt including `-` lines. + +### Sending + +`content_blocks_from` renders `LineComments` as one text block: + +``` + + + + let x = y.unwrap(); + ... + +this should not unwrap; return the error + +... + +``` + +- Each comment carries its line range **and** the selected lines + themselves, so the agent sees the code it is about without a file read and + can find the place again after line numbers shifted. +- A fixed tag lets the GPUI transcript recognize the block and render it as + the collapsed chip instead of raw text (the same block is what the model + sees, so stored sessions need no extra field). +- Paths are relative to the session's project root when the file lies inside + it, so the model can pass them to its file tools; otherwise absolute. +- Excerpts are capped (e.g. 40 lines per comment: the first and last lines + kept, the middle elided with a `… N lines …` marker) to keep a large + selection from flooding the context. +- The ACP and terminal frontends render the block as text; they get no + commenting UI in this plan. + +## File access (core) + +The viewer reads through the core, not `std::fs` in the UI, so sandbox and +worktree rules stay in one place: + +- `SessionService::list_dir(session_id, rel_dir) -> Vec` + (name, kind, gitignored), backed by `fs_explorer` (which already knows + gitignore). The root is the session's `session_effective_path`, so a + worktree switch moves the tree with it. +- `SessionService::read_file_for_view(session_id, rel_path) -> FileView` + with the text (via `fs_explorer::read_file_with_encoding`), a size cap with + a "file too large" state, and a binary flag (images could reuse + `shared::image` later). +- `SessionService::find_files(session_id, query) -> Vec` for the + filter, capped in result count. +- Paths are checked to stay inside the root (no `..` escapes, no symlinks + out of it). +- Run on the io side (`call_io`, `spawn_blocking`) like the Review scans, so + a big directory never blocks the session's command worker. + +## UI (ui_gpui) + +- **`RightPanelView::Files`**, persisted as `"files"`. The header with the + segmented switch is shown whenever there is more than one view, also + without `browser-panel`. +- **`files_view.rs`** in `main_screen/right_panel/`: a `FilesView` entity + with the tree (a `list()` over flat rows of the expanded entries, like + `review_rows.rs`) and a `FileViewer` entity. Data comes in the way the + Review view gets it: commands in `app/commands.rs` call the service and + hand results to the view. +- **`FileViewer`**: content split into chunks of rows, rendered with + `DiffRows` without diff colors (so selection, wrapping and painting come for + free and stay fast; see `docs/frame-profiling.md`), highlighted with the + existing syntax machinery. Highlighting runs off the main thread on load, + as for diffs. +- **Shared selection and comments.** Pull the selection logic out of + `ReviewView` into a small `LineSelection` helper (anchor/head, drag state, + mapping to file lines through a per-view callback) used by both views. The + floating action bar, inline comment editor and gutter markers are shared + components too. +- **Comment store in the UI.** The pending comments of the shown session live + in the composer (`InputArea`, as the `LineComments` attachment). Views read + them through a `PendingComments` global (or an entity owned by the main + screen) for markers, and add/edit/remove through it; the input area + re-renders its chip from the same data and the draft is saved as usual. +- **Composer chip.** A new `AttachmentView` variant for `LineComments` with + the popover. `InputArea::is_empty` and the send-enabled check count + comments as content. +- **Transcript.** The user-message renderer detects a text block starting + with `` and renders the collapsed chip. +- **Jump to a comment.** From the chip popover: switch the panel to Files (or + Review for diff comments), open the file and scroll to the lines. If the + lines no longer match the excerpt, search the file for the excerpt and use + the first match; otherwise show the stored range with a "file changed" + hint. + +## Edge cases + +- **File changed after commenting.** Markers are placed by line number; when + the file reloads, re-anchor each comment by its excerpt (exact match first, + then nearest occurrence to the old line). Unplaceable comments stay in the + composer, marked "outdated", and are still sent with their excerpt. +- **Same lines commented twice.** Allowed; the gutter count shows it. +- **Session switch.** Comments belong to the session's draft; switching + sessions shows that session's comments and markers. +- **Message edit mode.** Comments attach to the edited message like other + attachments. +- **Queued messages** (agent busy): the comments go with the queued message; + `queue_user_message` takes only text today, so either extend it to + attachments or keep the Send button for comments disabled while the agent + runs. Recommended: extend it. +- **Session without a project path**, or a path that no longer exists: the + Files view shows an empty state. +- **Large files**: viewer caps (e.g. 2 MB / 50k lines) with a notice; + commenting still works within what is shown. +- **Large directories** (`node_modules`, `target`): gitignored, so hidden by + default; when shown, listings are capped with a "N more" row. + +## Order of work + +1. **Files view, read-only.** `list_dir` / `read_file_for_view` service + methods with tests; `RightPanelView::Files`; the right-panel header + without `browser-panel`; lazy tree and the file viewer (highlighted, + virtualized), remembered per session. +2. **Freshness and filter.** Watcher on the open file and expanded + directories; `find_files` and the filter field. +3. **Selection and comments in the file viewer.** Shared `LineSelection` + helper (pulled out of `ReviewView`), `LineComment`, + `DraftAttachment::LineComments`, rendering in `content_blocks_from`, + composer chip and popover, floating Comment/Copy bar, inline editor. +4. **Comments in the Review diffs**, with the diff-to-file line mapping. +5. **Gutter markers and re-anchoring**; transcript chip; jump to a comment. +6. Clicking file paths in tool cards opens the Files view. + +Tests: core tests for `list_dir` / `read_file_for_view` (gitignore, size cap, +binary, path escapes), the comment block rendering and re-anchoring; GPUI +tests with `MainScreenTest` for opening a file from the tree, selecting +lines, adding a comment, the chip text for one/several comments, and that +sending clears them. + +## Later: files written by the agent + +A second section above the tree, *Written by agent*, listing the files the +agent wrote in the session (newest first, `A`/`M`/`D` like the Review view), +with a *Changes* toggle in the viewer that diffs against the content before +the session's first write. Sketch for when it is picked up: + +- The core derives the list from the active path of the transcript + (successful calls of tools tagged e.g. `writes_files`: `write_file`, + `edit`, `replace_in_file`, `delete_files`), so branches and message edits + are handled for free; sub-agent writes included. +- `SessionService::written_files`, a `written_files` field in + `SessionSnapshot`, and a `UiEvent::WrittenFilesChanged` on each completed + write. +- Baselines for the *Changes* diff captured by the tools before the first + write, stored beside the session, capped by size. + +## Open questions + +- Should a sent comment stay visible as a marker on the file (a review + history per session), or disappear once sent? The plan says disappear. +- Should the agent be able to reply to a comment (threaded, like PR reviews), + e.g. through a tool that resolves comments? Out of scope for now. +- Should the tree also offer opening a file in the system editor + (`Cmd-click`)? Cheap, but outside the review flow. +- Separate shortcut for "comment" vs. reusing `Cmd-Enter` in the panel.