From 893a00ae68715b829bf35f5eedb29be8cf284d23 Mon Sep 17 00:00:00 2001 From: Daniel Kurzynski Date: Thu, 8 Oct 2026 14:53:07 +0200 Subject: [PATCH 1/6] docs: plan a Files view and line comments for the right panel --- docs/files-panel-plan.md | 359 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100644 docs/files-panel-plan.md diff --git a/docs/files-panel-plan.md b/docs/files-panel-plan.md new file mode 100644 index 00000000..4f3403c5 --- /dev/null +++ b/docs/files-panel-plan.md @@ -0,0 +1,359 @@ +# 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 + +Steps 1–5 of the order of work are built; step 6 (opening files from tool +cards) is open. + +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 box at the bottom** of the view instead of inline below the + lines (the Review list is virtualized in chunks; the editor owns its + layout). A header button ("Comment" / "Edit comment") or Cmd/Ctrl-Shift-M + opens it for the selection; 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. +- **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. From 0e532091a7dcf4f828f7255f5bbaabfa4660d169 Mon Sep 17 00:00:00 2001 From: Daniel Kurzynski Date: Thu, 8 Oct 2026 14:53:07 +0200 Subject: [PATCH 2/6] feat(gpui): Files view with editor and line comments The right panel gets a Files view: the session's project as a lazy tree (gitignore-aware, fuzzy filter, live updates) next to the selected file in the code editor, which saves on its own shortly after typing stops. Lines selected in the editor or in the Review diffs can be commented. The comments collect in the composer as one chip, persist with the draft and go to the model as a block with path, lines, excerpt and text; the transcript shows them folded. --- Cargo.lock | 1 + crates/code_assistant_core/src/lib.rs | 1 + .../code_assistant_core/src/line_comments.rs | 257 +++++ .../src/persistence/mod.rs | 5 + crates/code_assistant_core/src/session/mod.rs | 5 +- .../src/session/service.rs | 18 +- .../src/session/service/files.rs | 173 +++ .../src/session/service/new_context.rs | 4 +- .../code_assistant_core/src/utils/content.rs | 19 +- crates/fs_explorer/Cargo.toml | 1 + crates/fs_explorer/src/browse.rs | 293 +++++ crates/fs_explorer/src/lib.rs | 2 + crates/fs_explorer/src/watch.rs | 133 +++ crates/ui_gpui/src/app/event_loop.rs | 9 + crates/ui_gpui/src/blocks/mod.rs | 3 + crates/ui_gpui/src/blocks/render.rs | 95 ++ crates/ui_gpui/src/input/attachment.rs | 2 + crates/ui_gpui/src/input/comments.rs | 346 ++++++ crates/ui_gpui/src/input/mod.rs | 33 +- crates/ui_gpui/src/main_screen/mod.rs | 31 + .../main_screen/right_panel/comment_editor.rs | 156 +++ .../main_screen/right_panel/file_filter.rs | 135 +++ .../main_screen/right_panel/file_viewer.rs | 643 +++++++++++ .../src/main_screen/right_panel/files_view.rs | 998 ++++++++++++++++++ .../main_screen/right_panel/line_selection.rs | 225 ++++ .../src/main_screen/right_panel/mod.rs | 130 ++- .../main_screen/right_panel/review_view.rs | 183 ++-- .../right_panel/review_view/comments.rs | 310 ++++++ crates/ui_gpui/src/shared/ui_state.rs | 40 +- crates/ui_gpui/src/tool_cards/diff_card.rs | 2 + crates/ui_gpui/src/tool_cards/diff_rows.rs | 25 +- crates/ui_terminal/src/ui.rs | 3 + 32 files changed, 4112 insertions(+), 169 deletions(-) create mode 100644 crates/code_assistant_core/src/line_comments.rs create mode 100644 crates/code_assistant_core/src/session/service/files.rs create mode 100644 crates/fs_explorer/src/browse.rs create mode 100644 crates/fs_explorer/src/watch.rs create mode 100644 crates/ui_gpui/src/input/comments.rs create mode 100644 crates/ui_gpui/src/main_screen/right_panel/comment_editor.rs create mode 100644 crates/ui_gpui/src/main_screen/right_panel/file_filter.rs create mode 100644 crates/ui_gpui/src/main_screen/right_panel/file_viewer.rs create mode 100644 crates/ui_gpui/src/main_screen/right_panel/files_view.rs create mode 100644 crates/ui_gpui/src/main_screen/right_panel/line_selection.rs create mode 100644 crates/ui_gpui/src/main_screen/right_panel/review_view/comments.rs 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..f100c25c --- /dev/null +++ b/crates/code_assistant_core/src/line_comments.rs @@ -0,0 +1,257 @@ +//! Comments the user attaches to lines of a file 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, + /// 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) + } + } + + /// 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 { + 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 { + pub path: String, + pub lines: String, + pub code: String, + pub text: String, +} + +/// 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 code_start = inner.find("\n")? + "\n".len(); + let code_end = inner.find("\n")?; + comments.push(ParsedComment { + path: attribute(head, "path")?, + lines: attribute(head, "lines")?, + code: inner[code_start..code_end].to_owned(), + text: inner[code_end + "\n".len()..].trim().to_owned(), + }); + 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, + 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 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/mod.rs b/crates/ui_gpui/src/blocks/mod.rs index ad072823..3ecce62b 100644 --- a/crates/ui_gpui/src/blocks/mod.rs +++ b/crates/ui_gpui/src/blocks/mod.rs @@ -229,6 +229,8 @@ 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, } impl BlockView { @@ -283,6 +285,7 @@ impl BlockView { diff_dragging: false, focus_handle: cx.focus_handle(), deferred_output_requested: false, + comments_expanded: false, } } diff --git a/crates/ui_gpui/src/blocks/render.rs b/crates/ui_gpui/src/blocks/render.rs index 668d0cb3..74d889d5 100644 --- a/crates/ui_gpui/src/blocks/render.rs +++ b/crates/ui_gpui/src/blocks/render.rs @@ -432,6 +432,9 @@ 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) @@ -848,3 +851,95 @@ 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] => 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(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/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..362ee213 --- /dev/null +++ b/crates/ui_gpui/src/input/comments.rs @@ -0,0 +1,346 @@ +//! 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.file_name(), c.lines_label()) + } + _ => { + 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(format!("{}:{}", c.file_name(), c.lines_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, + 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" + ); + 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/main_screen/mod.rs b/crates/ui_gpui/src/main_screen/mod.rs index d95453aa..ec07de3b 100644 --- a/crates/ui_gpui/src/main_screen/mod.rs +++ b/crates/ui_gpui/src/main_screen/mod.rs @@ -228,6 +228,7 @@ pub struct MainScreen { context_limit_cache: Option<(String, u32)>, // Subscription to input area events _input_area_subscription: Subscription, + _right_panel_subscription: Subscription, _plan_banner_subscription: Subscription, _project_sidebar_subscription: Subscription, _new_project_dialog_subscription: Option, @@ -281,6 +282,18 @@ impl MainScreen { // Create the right (review) sidebar panel. let right_panel = cx.new(|cx| right_panel::RightPanel::new(window, 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.input_area.update(cx, |input, cx| match change { + right_panel::CommentChange::Upsert(comment) => { + input.upsert_comment(comment.clone(), cx) + } + right_panel::CommentChange::Remove(id) => input.remove_comment(*id, cx), + }); + }, + ); let mut root_view = Self { input_area, @@ -319,6 +332,7 @@ impl MainScreen { resize_start_width: 0.0, context_limit_cache: None, _input_area_subscription: input_area_subscription, + _right_panel_subscription: right_panel_subscription, _plan_banner_subscription: plan_banner_subscription, _project_sidebar_subscription: project_sidebar_subscription, _new_project_dialog_subscription: None, @@ -352,6 +366,10 @@ impl MainScreen { _window: &mut gpui_kit::Window, cx: &mut Context, ) { + self.toggle_right_sidebar(cx); + } + + 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 +688,19 @@ impl MainScreen { self.report_user_activity(session_id, cx); } } + InputAreaEvent::CommentsChanged { comments } => { + let comments = comments.clone(); + self.right_panel + .update(cx, |panel, cx| panel.set_comments(comments, 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 } diff --git a/crates/ui_gpui/src/main_screen/right_panel/comment_editor.rs b/crates/ui_gpui/src/main_screen/right_panel/comment_editor.rs new file mode 100644 index 00000000..0c358867 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/comment_editor.rs @@ -0,0 +1,156 @@ +//! The box in which the user writes or edits one line comment, shown at the +//! bottom of the view the lines were selected in. Cmd/Ctrl-Enter saves, +//! Escape 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::*, +}; + +/// A view's change to the draft's comments. +#[derive(Clone, Debug)] +pub enum CommentChange { + /// Add (`id` 0) or replace a comment. + Upsert(LineComment), + Remove(u64), +} + +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("Comment on these lines…") + .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.file_name(), + self.comment.lines_label() + ); + div() + .flex_none() + .flex() + .flex_col() + .gap_1p5() + .p_2() + .border_t_1() + .border_color(theme.border) + .bg(theme.title_bar) + .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/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..6610c559 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/file_viewer.rs @@ -0,0 +1,643 @@ +//! 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 super::comment_editor::{CommentChange, CommentEditor, CommentEditorEvent}; +use crate::Gpui; +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, Render, + Subscription, Task, Window, div, prelude::*, +}; +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, + load_task: Option>, + save_task: Option>, + auto_save_task: Option>, + focus_handle: FocusHandle, + _editor_subscriptions: Vec, + _comment_editor_subscription: 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, + load_task: None, + save_task: None, + auto_save_task: None, + focus_handle: cx.focus_handle(), + _editor_subscriptions: Vec::new(), + _comment_editor_subscription: 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(); + } + + 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() + } + + /// Whether the header offers to comment (a file is in the editor). + pub fn can_comment(&self) -> bool { + self.editor.is_some() && self.root.is_some() + } + + /// Label of the header's comment button. + pub fn comment_label(&self, cx: &gpui_kit::App) -> &'static str { + if self.comment_at_cursor(cx).is_some() { + "Edit comment" + } else { + "Comment" + } + } + + /// 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, + 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(); + } + + /// 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 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); + } + + 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.comment_editor.clone()) + } +} 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..a3ffbb84 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/files_view.rs @@ -0,0 +1,998 @@ +//! 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::comment_editor::CommentChange; +use super::file_filter::{FileMatch, filter_paths}; +use super::file_viewer::FileViewer; +use crate::Gpui; +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, + + 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, + watcher: None, + watch_task: None, + load_tasks: HashMap::new(), + focus_handle: cx.focus_handle(), + _subscriptions: subscriptions, + } + } + + pub fn set_comments(&mut self, comments: Vec, cx: &mut Context) { + self.viewer + .update(cx, |viewer, cx| viewer.set_comments(comments, 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); + } + } + + 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(); + let comment_label = viewer.can_comment().then(|| viewer.comment_label(cx)); + let viewer_entity = self.viewer.clone(); + 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) + }), + ) + }) + .when_some(comment_label, |d, label| { + d.child( + Button::new("files-comment") + .label(label) + .xsmall() + .ghost() + .tooltip("Comment on the selected lines (⌘⇧M)") + .on_click(move |_, window, cx| { + viewer_entity + .update(cx, |viewer, cx| viewer.start_comment(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..859ae956 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/line_selection.rs @@ -0,0 +1,225 @@ +//! 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 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, +} + +/// 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, + 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..f018a0cc 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 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`]), switched in a header above +//! them. The shown view is remembered per session. #[cfg(feature = "browser-panel")] mod browser; +pub mod comment_editor; +mod file_filter; +mod file_viewer; +pub mod files_view; +mod line_selection; mod review_rows; pub mod review_view; +use code_assistant_core::line_comments::LineComment; +pub use comment_editor::CommentChange; +use files_view::FilesView; use gpui_kit::{Context, Entity, FocusHandle, Focusable, Render, Window, div, prelude::*}; use review_view::ReviewView; @@ -16,6 +25,7 @@ use review_view::ReviewView; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RightPanelView { Review, + Files, #[cfg(feature = "browser-panel")] Browser, } @@ -25,6 +35,7 @@ impl RightPanelView { pub fn as_str(self) -> &'static str { match self { RightPanelView::Review => "review", + RightPanelView::Files => "files", #[cfg(feature = "browser-panel")] RightPanelView::Browser => "browser", } @@ -34,6 +45,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 +56,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,12 +97,33 @@ impl RightPanel { }), #[cfg(feature = "browser-panel")] browser, - #[cfg(feature = "browser-panel")] session_id: None, + _comment_subscriptions: comment_subscriptions, focus_handle: cx.focus_handle(), } } + /// 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)); + } + + /// 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)); + } + } + #[allow(dead_code)] pub fn active_view(&self) -> RightPanelView { self.active_view @@ -86,47 +134,63 @@ 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)); + if self.active_view == RightPanelView::Files { + self.files_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(); + let views: &[(RightPanelView, &str)] = &[ + (RightPanelView::Review, "Review"), + (RightPanelView::Files, "Files"), + #[cfg(feature = "browser-panel")] + (RightPanelView::Browser, "Browser"), + ]; div() .flex_none() .flex() @@ -138,10 +202,7 @@ impl RightPanel { .border_color(cx.theme().border) .child(crate::shared::segmented_switch::segmented_switch( "right-panel-view", - &[ - (RightPanelView::Review, "Review"), - (RightPanelView::Browser, "Browser"), - ], + views, self.active_view, move |view, _, cx| { panel @@ -161,21 +222,18 @@ impl Focusable for RightPanel { } impl Render for RightPanel { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + 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() + 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 + .child(self.render_header(window, cx)) + .child(div().flex_1().min_h_0().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..f1914aa9 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,15 +26,21 @@ //! listing entry changed is stale and re-requested, but keeps rendering until //! its replacement arrives, so nothing flickers. +mod comments; + +use super::comment_editor::{CommentChange, CommentEditor}; +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::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::button::{Button, ButtonVariants}; use gpui_kit::component::{ ActiveTheme, Icon, Sizable, Size, scroll::ScrollableElement, @@ -48,7 +54,6 @@ use gpui_kit::{ }; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; -use std::rc::Rc; // --------------------------------------------------------------------------- // Compare-mode dropdown @@ -168,24 +173,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 +207,16 @@ 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, /// Filesystem watcher on the listed repos (keyed by their roots so a /// changed set restarts it). Dropping it stops watching. @@ -273,8 +265,11 @@ 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, watcher: None, watch_task: None, listing_generation: GENERATION_UNSEEN, @@ -299,8 +294,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 +539,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 +1079,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 +1116,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 +1136,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 +1166,13 @@ 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, }; + let selection = row_selection(key.clone(), base_line, marks, self.focus_handle.clone(), cx); div() .w_full() @@ -1268,6 +1198,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 +1220,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; @@ -1314,7 +1255,7 @@ impl Render for ReviewView { // 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 copy_button = self.selection.get().is_some().then(|| { let theme = cx.theme(); div() .id("review-copy-selection") @@ -1357,7 +1298,19 @@ impl Render for ReviewView { ) .min_w(px(130.)), ) - .children(copy_button); + .children(copy_button) + .when_some(self.comment_label(), |d, label| { + d.child( + Button::new("review-comment") + .label(label) + .xsmall() + .ghost() + .tooltip("Comment on the selected lines (⌘⇧M)") + .on_click( + cx.listener(|this, _, window, cx| this.start_comment(window, cx)), + ), + ) + }); // The render callback only runs for rows in (or near) the viewport. self.sync_rows(); @@ -1370,11 +1323,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 +1341,7 @@ impl Render for ReviewView { .child(body) .vertical_scrollbar(&self.list_state), ) + .children(self.comment_editor.clone()) .into_any_element() } } @@ -1589,7 +1546,7 @@ 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()); }); } 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..e6c86518 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/right_panel/review_view/comments.rs @@ -0,0 +1,310 @@ +//! 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::main_screen::right_panel::comment_editor::{ + CommentChange, 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, + excerpt, + text: String::new(), + }) + } + + /// Label of the header's comment button, if one is offered. + pub(super) fn comment_label(&self) -> Option<&'static str> { + let comment = self.selection_comment()?; + Some(if comment.id != 0 { + "Edit comment" + } else { + "Comment" + }) + } + + 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, + 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/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/diff_card.rs b/crates/ui_gpui/src/tool_cards/diff_card.rs index 003ffbaf..548b6ed9 100644 --- a/crates/ui_gpui/src/tool_cards/diff_card.rs +++ b/crates/ui_gpui/src/tool_cards/diff_card.rs @@ -406,6 +406,8 @@ impl DiffCardSelection { base_line, highlight, color: self.color, + marked: Vec::new(), + mark_color: self.color, 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..5a67fc33 100644 --- a/crates/ui_gpui/src/tool_cards/diff_rows.rs +++ b/crates/ui_gpui/src/tool_cards/diff_rows.rs @@ -64,6 +64,9 @@ 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, /// Pointer pressed on a row: `base_line + row`. pub on_start: LineCallback, /// Pointer dragged over a row while the button is held. @@ -260,7 +263,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 +281,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 { @@ -394,16 +405,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, ); 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() { From 186c65e2c595eadda21500ebeae96cd0246a907f Mon Sep 17 00:00:00 2001 From: Daniel Kurzynski Date: Thu, 8 Oct 2026 14:56:15 +0200 Subject: [PATCH 3/6] feat(gpui): open files from tool cards in the Files view File paths in tool cards open the file in the right panel's Files view, at the shown line for read and search results. Requests reach the panel over an app-wide bus the main screen listens to. Adds main screen tests for line comments in the draft and for opening files. --- crates/ui_gpui/src/main_screen/mod.rs | 48 +++++++ .../main_screen/right_panel/file_viewer.rs | 20 +++ .../src/main_screen/right_panel/files_view.rs | 37 +++++ .../src/main_screen/right_panel/mod.rs | 19 +++ .../ui_gpui/src/main_screen/tests/comments.rs | 135 ++++++++++++++++++ crates/ui_gpui/src/main_screen/tests/mod.rs | 1 + crates/ui_gpui/src/shared/mod.rs | 1 + crates/ui_gpui/src/shared/open_file.rs | 47 ++++++ crates/ui_gpui/src/tool_cards/code_card.rs | 45 +++++- crates/ui_gpui/src/tool_cards/diff_card.rs | 27 +++- docs/files-panel-plan.md | 8 +- 11 files changed, 374 insertions(+), 14 deletions(-) create mode 100644 crates/ui_gpui/src/main_screen/tests/comments.rs create mode 100644 crates/ui_gpui/src/shared/open_file.rs diff --git a/crates/ui_gpui/src/main_screen/mod.rs b/crates/ui_gpui/src/main_screen/mod.rs index ec07de3b..5166ff83 100644 --- a/crates/ui_gpui/src/main_screen/mod.rs +++ b/crates/ui_gpui/src/main_screen/mod.rs @@ -229,6 +229,7 @@ pub struct MainScreen { // Subscription to input area events _input_area_subscription: Subscription, _right_panel_subscription: Subscription, + _open_file_subscription: Subscription, _plan_banner_subscription: Subscription, _project_sidebar_subscription: Subscription, _new_project_dialog_subscription: Option, @@ -282,6 +283,17 @@ 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); + }, + ); // Comment edits in the panel change the composer's draft. let right_panel_subscription = cx.subscribe( &right_panel, @@ -333,6 +345,7 @@ impl MainScreen { context_limit_cache: None, _input_area_subscription: input_area_subscription, _right_panel_subscription: right_panel_subscription, + _open_file_subscription: open_file_subscription, _plan_banner_subscription: plan_banner_subscription, _project_sidebar_subscription: project_sidebar_subscription, _new_project_dialog_subscription: None, @@ -369,6 +382,41 @@ impl MainScreen { self.toggle_right_sidebar(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) + }); + } + fn toggle_right_sidebar(&mut self, cx: &mut Context) { let should_expand = self.right_sidebar_collapsed; self.right_sidebar_collapsed = !self.right_sidebar_collapsed; diff --git a/crates/ui_gpui/src/main_screen/right_panel/file_viewer.rs b/crates/ui_gpui/src/main_screen/right_panel/file_viewer.rs index 6610c559..3de46817 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/file_viewer.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/file_viewer.rs @@ -67,6 +67,8 @@ pub struct FileViewer { 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>, @@ -93,6 +95,7 @@ impl FileViewer { comments: Vec::new(), comment_editor: None, pending_reveal: None, + pending_line: None, load_task: None, save_task: None, auto_save_task: None, @@ -166,6 +169,12 @@ impl FileViewer { 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; @@ -604,6 +613,17 @@ impl Render for FileViewer { { 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), 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 index a3ffbb84..e0b0c8cd 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/files_view.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/files_view.rs @@ -101,6 +101,8 @@ pub struct FilesView { 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>, @@ -140,6 +142,7 @@ impl FilesView { matches: Vec::new(), clear_filter: false, pending_reveal: None, + pending_open: None, watcher: None, watch_task: None, load_tasks: HashMap::new(), @@ -148,11 +151,42 @@ impl FilesView { } } + #[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) { @@ -328,6 +362,9 @@ impl FilesView { 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) { 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 f018a0cc..2ade3ef6 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/mod.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/mod.rs @@ -111,6 +111,25 @@ impl RightPanel { .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 { 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..b0c9951b --- /dev/null +++ b/crates/ui_gpui/src/main_screen/tests/comments.rs @@ -0,0 +1,135 @@ +//! Line comments in the composer and file requests to the right panel. + +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, + 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")); + }); +} 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/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/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 548b6ed9..818d7130 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 { diff --git a/docs/files-panel-plan.md b/docs/files-panel-plan.md index 4f3403c5..f748c5c1 100644 --- a/docs/files-panel-plan.md +++ b/docs/files-panel-plan.md @@ -29,8 +29,12 @@ git) is out of scope here; see "Later: files written by the agent". ## Status -Steps 1–5 of the order of work are built; step 6 (opening files from tool -cards) is open. +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: From a87cae4a99ab922faae29b8dbaf0d7c35b72b7d8 Mon Sep 17 00:00:00 2001 From: Daniel Kurzynski Date: Thu, 8 Oct 2026 14:59:07 +0200 Subject: [PATCH 4/6] feat(gpui): switch right panel views in the title bar The Review | Files | Browser switch moves from the panel's header into the title bar and replaces the panel toggle button. A click opens the panel on that view or switches to it; clicking the shown view closes the panel. --- crates/ui_gpui/src/main_screen/mod.rs | 66 ++++++++++------- .../src/main_screen/right_panel/mod.rs | 57 ++++----------- .../ui_gpui/src/main_screen/tests/comments.rs | 24 +++++- crates/ui_gpui/src/shared/segmented_switch.rs | 73 ++++++++++++++----- docs/files-panel-plan.md | 3 + 5 files changed, 134 insertions(+), 89 deletions(-) diff --git a/crates/ui_gpui/src/main_screen/mod.rs b/crates/ui_gpui/src/main_screen/mod.rs index 5166ff83..9373e0ef 100644 --- a/crates/ui_gpui/src/main_screen/mod.rs +++ b/crates/ui_gpui/src/main_screen/mod.rs @@ -417,6 +417,23 @@ impl MainScreen { }); } + /// 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; @@ -1991,31 +2008,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/mod.rs b/crates/ui_gpui/src/main_screen/right_panel/mod.rs index 2ade3ef6..b0c12805 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/mod.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/mod.rs @@ -1,9 +1,10 @@ -//! The right sidebar's view switcher. +//! The right sidebar's views. //! //! The sidebar hosts the [`review_view::ReviewView`], the project's files //! ([`files_view::FilesView`]) and, with the `browser-panel` feature, the -//! agent's browser ([`browser::BrowserPanel`]), switched in a header above -//! them. The shown view is remembered per session. +//! 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; @@ -31,6 +32,14 @@ pub enum RightPanelView { } 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 { @@ -143,7 +152,6 @@ impl RightPanel { } } - #[allow(dead_code)] pub fn active_view(&self) -> RightPanelView { self.active_view } @@ -200,38 +208,6 @@ impl RightPanel { self.files_view.update(cx, |v, cx| v.reload(cx)); } } - - fn render_header(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - use gpui_kit::component::ActiveTheme; - let panel = cx.entity().downgrade(); - let views: &[(RightPanelView, &str)] = &[ - (RightPanelView::Review, "Review"), - (RightPanelView::Files, "Files"), - #[cfg(feature = "browser-panel")] - (RightPanelView::Browser, "Browser"), - ]; - 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", - views, - self.active_view, - move |view, _, cx| { - panel - .update(cx, |panel, cx| panel.set_active_view(view, cx)) - .ok(); - }, - window, - cx, - )) - } } impl Focusable for RightPanel { @@ -241,18 +217,13 @@ impl Focusable for RightPanel { } impl Render for RightPanel { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + 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(), }; - div() - .flex() - .flex_col() - .size_full() - .child(self.render_header(window, cx)) - .child(div().flex_1().min_h_0().child(body)) + div().size_full().child(body) } } diff --git a/crates/ui_gpui/src/main_screen/tests/comments.rs b/crates/ui_gpui/src/main_screen/tests/comments.rs index b0c9951b..7b3a75a9 100644 --- a/crates/ui_gpui/src/main_screen/tests/comments.rs +++ b/crates/ui_gpui/src/main_screen/tests/comments.rs @@ -1,4 +1,5 @@ -//! Line comments in the composer and file requests to the right panel. +//! 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}; @@ -133,3 +134,24 @@ fn an_open_file_request_shows_the_file_in_the_panel(cx: &mut TestAppContext) { 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); +} 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/docs/files-panel-plan.md b/docs/files-panel-plan.md index f748c5c1..eb5df7df 100644 --- a/docs/files-panel-plan.md +++ b/docs/files-panel-plan.md @@ -60,6 +60,9 @@ Decisions taken while building: 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. +- **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). From 5262ea72d8c5b6dac2c18e35c759e20e5c8fcaf0 Mon Sep 17 00:00:00 2001 From: Daniel Kurzynski Date: Thu, 8 Oct 2026 16:00:48 +0200 Subject: [PATCH 5/6] feat(gpui): comment on chat messages; selection pill and floating card Selected text in a chat message can be commented: releasing the mouse shows a small pill (copy, comment) at the selection's end, and the comment card floats there. The comment quotes the passage and reaches the model as with a . Messages holding a pending comment's quote get a border and a badge that reopens it. The Files editor and the Review diffs use the same pill and floating card in place of the header buttons and the docked comment box. Diff rows take presses through a hitbox, so the pill above them keeps its clicks. --- .../code_assistant_core/src/line_comments.rs | 83 ++++++- crates/ui_gpui/src/blocks/comments.rs | 203 ++++++++++++++++++ crates/ui_gpui/src/blocks/mod.rs | 23 ++ crates/ui_gpui/src/blocks/render.rs | 37 +++- .../comment_editor.rs => comments/editor.rs} | 33 ++- crates/ui_gpui/src/comments/mod.rs | 149 +++++++++++++ crates/ui_gpui/src/input/comments.rs | 18 +- crates/ui_gpui/src/lib.rs | 1 + crates/ui_gpui/src/main_screen/mod.rs | 71 +++++- .../main_screen/right_panel/file_viewer.rs | 106 +++++++-- .../src/main_screen/right_panel/files_view.rs | 17 +- .../main_screen/right_panel/line_selection.rs | 8 + .../src/main_screen/right_panel/mod.rs | 3 +- .../main_screen/right_panel/review_view.rs | 98 +++++---- .../right_panel/review_view/comments.rs | 66 +++++- .../ui_gpui/src/main_screen/tests/comments.rs | 23 ++ crates/ui_gpui/src/messages/mod.rs | 7 + crates/ui_gpui/src/tool_cards/diff_card.rs | 1 + crates/ui_gpui/src/tool_cards/diff_rows.rs | 36 +++- docs/files-panel-plan.md | 19 +- 20 files changed, 863 insertions(+), 139 deletions(-) create mode 100644 crates/ui_gpui/src/blocks/comments.rs rename crates/ui_gpui/src/{main_screen/right_panel/comment_editor.rs => comments/editor.rs} (87%) create mode 100644 crates/ui_gpui/src/comments/mod.rs diff --git a/crates/code_assistant_core/src/line_comments.rs b/crates/code_assistant_core/src/line_comments.rs index f100c25c..0f62dd80 100644 --- a/crates/code_assistant_core/src/line_comments.rs +++ b/crates/code_assistant_core/src/line_comments.rs @@ -1,4 +1,5 @@ -//! Comments the user attaches to lines of a file before sending a message. +//! 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 @@ -36,6 +37,10 @@ pub struct LineComment { /// 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, @@ -51,6 +56,31 @@ impl LineComment { } } + /// 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 @@ -80,6 +110,14 @@ 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", @@ -136,10 +174,14 @@ pub fn locate(lines: &[&str], comment: &LineComment) -> Option<(usize, usize)> { /// 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. @@ -157,13 +199,25 @@ pub fn parse(block: &str) -> Option> { let inner_and_rest = &after[head_end + 1..]; let end = inner_and_rest.find("")?; let inner = &inner_and_rest[..end]; - let code_start = inner.find("\n")? + "\n".len(); - let code_end = inner.find("\n")?; + 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: attribute(head, "path")?, - lines: attribute(head, "lines")?, + 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 + "\n".len()..].trim().to_owned(), + text: inner[code_end + close.len()..].trim().to_owned(), + on_message, }); rest = &inner_and_rest[end + "".len()..]; } @@ -188,6 +242,7 @@ mod tests { end_line: lines.1, old_side: false, in_diff: false, + on_message: false, excerpt: excerpt.to_owned(), text: text.to_owned(), } @@ -248,6 +303,22 @@ mod tests { 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?"); 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 3ecce62b..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; @@ -231,6 +232,22 @@ pub struct BlockView { 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 { @@ -286,6 +303,12 @@ impl BlockView { 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 74d889d5..1f7ec122 100644 --- a/crates/ui_gpui/src/blocks/render.rs +++ b/crates/ui_gpui/src/blocks/render.rs @@ -440,9 +440,32 @@ impl BlockView { .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) => { @@ -863,6 +886,7 @@ impl BlockView { 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()), }; @@ -908,12 +932,13 @@ impl BlockView { .pl_2() .border_l_2() .border_color(theme.warning.opacity(0.6)) - .child( - div() - .text_xs() - .text_color(theme.muted_foreground) - .child(format!("{}:{}", c.path, c.lines)), - ) + .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() diff --git a/crates/ui_gpui/src/main_screen/right_panel/comment_editor.rs b/crates/ui_gpui/src/comments/editor.rs similarity index 87% rename from crates/ui_gpui/src/main_screen/right_panel/comment_editor.rs rename to crates/ui_gpui/src/comments/editor.rs index 0c358867..37970125 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/comment_editor.rs +++ b/crates/ui_gpui/src/comments/editor.rs @@ -1,6 +1,6 @@ -//! The box in which the user writes or edits one line comment, shown at the -//! bottom of the view the lines were selected in. Cmd/Ctrl-Enter saves, -//! Escape cancels. +//! 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 cancels. use code_assistant_core::line_comments::LineComment; use gpui_kit::component::button::{Button, ButtonVariants}; @@ -8,17 +8,9 @@ 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::*, + prelude::*, px, }; -/// A view's change to the draft's comments. -#[derive(Clone, Debug)] -pub enum CommentChange { - /// Add (`id` 0) or replace a comment. - Upsert(LineComment), - Remove(u64), -} - pub enum CommentEditorEvent { /// The comment with the entered text. Save(LineComment), @@ -42,7 +34,7 @@ impl CommentEditor { let input = cx.new(|cx| { TextareaState::new(window, cx) .auto_grow(2, 8) - .placeholder("Comment on these lines…") + .placeholder("Add a comment…") .default_value(text) }); input.update(cx, |input, cx| input.focus(window, cx)); @@ -88,24 +80,27 @@ impl Render for CommentEditor { let existing = self.comment.id != 0; let id = self.comment.id; let title = format!( - "{} {}:{}", + "{} {}", if existing { "Edit comment on" } else { "Comment on" }, - self.comment.file_name(), - self.comment.lines_label() + self.comment.location_label() ); div() - .flex_none() + .id("comment-editor") + .occlude() + .w(px(340.)) .flex() .flex_col() .gap_1p5() .p_2() - .border_t_1() + .rounded_lg() + .border_1() .border_color(theme.border) - .bg(theme.title_bar) + .bg(theme.popover) + .shadow_lg() .capture_action(cx.listener(|_, _: &Escape, _, cx| { cx.emit(CommentEditorEvent::Cancel); cx.stop_propagation(); 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/comments.rs b/crates/ui_gpui/src/input/comments.rs index 362ee213..1bd97b79 100644 --- a/crates/ui_gpui/src/input/comments.rs +++ b/crates/ui_gpui/src/input/comments.rs @@ -100,7 +100,10 @@ impl InputArea { } else { "" }; - format!("{}:{} · {text}{ellipsis}", c.file_name(), c.lines_label()) + 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(); @@ -225,7 +228,7 @@ impl InputArea { div() .text_xs() .text_color(theme.muted_foreground) - .child(format!("{}:{}", c.file_name(), c.lines_label())), + .child(c.location_label()), ) .child( div() @@ -317,6 +320,7 @@ mod tests { end_line: 5, old_side: false, in_diff: false, + on_message: false, excerpt: "x".into(), text: text.into(), } @@ -334,6 +338,16 @@ mod tests { 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"), 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 9373e0ef..cc090a5d 100644 --- a/crates/ui_gpui/src/main_screen/mod.rs +++ b/crates/ui_gpui/src/main_screen/mod.rs @@ -230,6 +230,7 @@ pub struct MainScreen { _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, @@ -294,16 +295,20 @@ impl MainScreen { 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.input_area.update(cx, |input, cx| match change { - right_panel::CommentChange::Upsert(comment) => { - input.upsert_comment(comment.clone(), cx) - } - right_panel::CommentChange::Remove(id) => input.remove_comment(*id, cx), - }); + this.apply_comment_change(change, cx); }, ); @@ -346,6 +351,7 @@ impl MainScreen { _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, @@ -382,6 +388,53 @@ impl MainScreen { 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( @@ -754,10 +807,16 @@ impl MainScreen { } } 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); diff --git a/crates/ui_gpui/src/main_screen/right_panel/file_viewer.rs b/crates/ui_gpui/src/main_screen/right_panel/file_viewer.rs index 3de46817..f7a7300e 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/file_viewer.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/file_viewer.rs @@ -15,8 +15,9 @@ //! follow edits). After a load or save each comment is found again by its //! excerpt ([`line_comments::locate`]); one that moved is updated. -use super::comment_editor::{CommentChange, CommentEditor, CommentEditorEvent}; 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; @@ -26,8 +27,8 @@ use gpui_kit::component::input::{ }; use gpui_kit::component::{ActiveTheme, Sizable, v_flex}; use gpui_kit::{ - Context, Entity, EventEmitter, FocusHandle, Focusable, HighlightStyle, KeyDownEvent, Render, - Subscription, Task, Window, div, prelude::*, + 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; @@ -75,6 +76,8 @@ pub struct FileViewer { focus_handle: FocusHandle, _editor_subscriptions: Vec, _comment_editor_subscription: Option, + /// Keeps the selection pill and the comment card next to their lines. + anchor: AnchorTracker, } impl EventEmitter for FileViewer {} @@ -102,6 +105,7 @@ impl FileViewer { focus_handle: cx.focus_handle(), _editor_subscriptions: Vec::new(), _comment_editor_subscription: None, + anchor: AnchorTracker::default(), } } @@ -349,20 +353,6 @@ impl FileViewer { .cloned() } - /// Whether the header offers to comment (a file is in the editor). - pub fn can_comment(&self) -> bool { - self.editor.is_some() && self.root.is_some() - } - - /// Label of the header's comment button. - pub fn comment_label(&self, cx: &gpui_kit::App) -> &'static str { - if self.comment_at_cursor(cx).is_some() { - "Edit comment" - } else { - "Comment" - } - } - /// 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) { @@ -380,6 +370,7 @@ impl FileViewer { end_line: end, old_side: false, in_diff: false, + on_message: false, excerpt, text: String::new(), } @@ -422,6 +413,50 @@ impl FileViewer { 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() { + 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 { @@ -592,6 +627,41 @@ impl FileViewer { } } +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(); + Some(floating( + position, + 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() @@ -658,6 +728,6 @@ impl Render for FileViewer { })) .children(self.render_banner(cx)) .child(div().flex_1().min_h_0().child(body)) - .children(self.comment_editor.clone()) + .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 index e0b0c8cd..e114cd41 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/files_view.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/files_view.rs @@ -8,10 +8,10 @@ //! they change on disk. The open file, the expanded directories and whether //! ignored entries show are remembered per session. -use super::comment_editor::CommentChange; 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}; @@ -851,8 +851,6 @@ impl FilesView { let viewer = self.viewer.read(cx); let path = viewer.path()?.to_owned(); let dirty = viewer.is_dirty(); - let comment_label = viewer.can_comment().then(|| viewer.comment_label(cx)); - let viewer_entity = self.viewer.clone(); Some( div() .flex_none() @@ -886,19 +884,6 @@ impl FilesView { .build(window, cx) }), ) - }) - .when_some(comment_label, |d, label| { - d.child( - Button::new("files-comment") - .label(label) - .xsmall() - .ghost() - .tooltip("Comment on the selected lines (⌘⇧M)") - .on_click(move |_, window, cx| { - viewer_entity - .update(cx, |viewer, cx| viewer.start_comment(window, cx)); - }), - ) }), ) } diff --git a/crates/ui_gpui/src/main_screen/right_panel/line_selection.rs b/crates/ui_gpui/src/main_screen/right_panel/line_selection.rs index 859ae956..cfbb6892 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/line_selection.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/line_selection.rs @@ -48,6 +48,10 @@ impl LineSelection { self.selected.as_ref() } + pub fn is_dragging(&self) -> bool { + self.dragging + } + pub fn clear(&mut self) { self.selected = None; self.dragging = false; @@ -128,6 +132,9 @@ pub(crate) struct ChunkMarks { 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 @@ -184,6 +191,7 @@ pub(crate) fn row_selection( color: marks.color, marked: marks.marked, mark_color: marks.mark_color, + anchor: marks.anchor, on_start, on_drag, on_end, 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 b0c12805..4cfc77c8 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/mod.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/mod.rs @@ -8,7 +8,6 @@ #[cfg(feature = "browser-panel")] mod browser; -pub mod comment_editor; mod file_filter; mod file_viewer; pub mod files_view; @@ -16,8 +15,8 @@ mod line_selection; mod review_rows; pub mod review_view; +pub use crate::comments::CommentChange; use code_assistant_core::line_comments::LineComment; -pub use comment_editor::CommentChange; use files_view::FilesView; use gpui_kit::{Context, Entity, FocusHandle, Focusable, Render, Window, div, prelude::*}; use review_view::ReviewView; 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 f1914aa9..7291817b 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 @@ -28,19 +28,19 @@ mod comments; -use super::comment_editor::{CommentChange, CommentEditor}; 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::{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::button::{Button, ButtonVariants}; use gpui_kit::component::{ ActiveTheme, Icon, Sizable, Size, scroll::ScrollableElement, @@ -54,6 +54,7 @@ use gpui_kit::{ }; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; +use std::rc::Rc; // --------------------------------------------------------------------------- // Compare-mode dropdown @@ -217,6 +218,10 @@ pub struct ReviewView { _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, /// Filesystem watcher on the listed repos (keyed by their roots so a /// changed set restarts it). Dropping it stops watching. @@ -270,6 +275,8 @@ impl ReviewView { comment_editor: None, _comment_editor_subscription: None, pending_reveal: None, + selection_anchor: Rc::default(), + anchor: Default::default(), watcher: None, watch_task: None, listing_generation: GENERATION_UNSEEN, @@ -1171,6 +1178,9 @@ impl ReviewView { color: theme.selection, 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); @@ -1253,31 +1263,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.get().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() @@ -1297,20 +1284,7 @@ impl Render for ReviewView { .text_color(muted), ) .min_w(px(130.)), - ) - .children(copy_button) - .when_some(self.comment_label(), |d, label| { - d.child( - Button::new("review-comment") - .label(label) - .xsmall() - .ghost() - .tooltip("Comment on the selected lines (⌘⇧M)") - .on_click( - cx.listener(|this, _, window, cx| this.start_comment(window, cx)), - ), - ) - }); + ); // The render callback only runs for rows in (or near) the viewport. self.sync_rows(); @@ -1341,7 +1315,7 @@ impl Render for ReviewView { .child(body) .vertical_scrollbar(&self.list_state), ) - .children(self.comment_editor.clone()) + .children(self.render_floating(window, cx)) .into_any_element() } } @@ -1550,4 +1524,44 @@ mod tests { 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()); + } } diff --git a/crates/ui_gpui/src/main_screen/right_panel/review_view/comments.rs b/crates/ui_gpui/src/main_screen/right_panel/review_view/comments.rs index e6c86518..51a155b7 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/review_view/comments.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/review_view/comments.rs @@ -6,9 +6,8 @@ //! marked, whether the comment was made here or in the Files view. use super::*; -use crate::main_screen::right_panel::comment_editor::{ - CommentChange, CommentEditor, CommentEditorEvent, -}; +use crate::comments::CommentChange; +use crate::comments::editor::{CommentEditor, CommentEditorEvent}; use code_assistant_core::line_comments::LineComment; use similar::ChangeTag; use std::ops::Range; @@ -128,21 +127,67 @@ impl ReviewView { end_line: *numbers_used.iter().max()?, old_side, in_diff: true, + on_message: false, excerpt, text: String::new(), }) } - /// Label of the header's comment button, if one is offered. - pub(super) fn comment_label(&self) -> Option<&'static str> { - let comment = self.selection_comment()?; - Some(if comment.id != 0 { - "Edit comment" - } else { - "Comment" + /// 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 position = row + .filter(|_| self.selection.get().is_some() && !self.selection.is_dragging()) + .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(); + Some(floating( + position?, + 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); @@ -272,6 +317,7 @@ mod tests { end_line: lines.1, old_side, in_diff: true, + on_message: false, excerpt: String::new(), text: String::new(), } diff --git a/crates/ui_gpui/src/main_screen/tests/comments.rs b/crates/ui_gpui/src/main_screen/tests/comments.rs index 7b3a75a9..84cd6daf 100644 --- a/crates/ui_gpui/src/main_screen/tests/comments.rs +++ b/crates/ui_gpui/src/main_screen/tests/comments.rs @@ -18,6 +18,7 @@ fn comment(file: &str, lines: (usize, usize), text: &str) -> LineComment { end_line: lines.1, old_side: false, in_diff: false, + on_message: false, excerpt: "let x = y.unwrap();".into(), text: text.into(), } @@ -155,3 +156,25 @@ fn the_title_bar_switch_opens_switches_and_closes_the_panel(cx: &mut TestAppCont 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/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/tool_cards/diff_card.rs b/crates/ui_gpui/src/tool_cards/diff_card.rs index 818d7130..29999cb5 100644 --- a/crates/ui_gpui/src/tool_cards/diff_card.rs +++ b/crates/ui_gpui/src/tool_cards/diff_card.rs @@ -421,6 +421,7 @@ impl DiffCardSelection { 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 5a67fc33..65512093 100644 --- a/crates/ui_gpui/src/tool_cards/diff_rows.rs +++ b/crates/ui_gpui/src/tool_cards/diff_rows.rs @@ -67,6 +67,9 @@ pub(crate) struct RowSelection { /// 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. @@ -333,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 @@ -383,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( @@ -396,7 +404,7 @@ impl Element for DiffRows { _: Option<&InspectorElementId>, bounds: Bounds, cell: &mut LayoutCell, - _: &mut (), + hitbox: &mut Option, window: &mut Window, cx: &mut App, ) { @@ -426,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(); @@ -437,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), @@ -567,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) }); { @@ -579,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/docs/files-panel-plan.md b/docs/files-panel-plan.md index eb5df7df..3aaf86f8 100644 --- a/docs/files-panel-plan.md +++ b/docs/files-panel-plan.md @@ -47,10 +47,8 @@ Decisions taken while building: `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 box at the bottom** of the view instead of inline below the - lines (the Review list is virtualized in chunks; the editor owns its - layout). A header button ("Comment" / "Edit comment") or Cmd/Ctrl-Shift-M - opens it for the selection; Cmd/Ctrl-Enter saves, Escape cancels. +- **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 @@ -60,6 +58,19 @@ Decisions taken while building: 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. From 83444a39afdb9ebfb6480d9f5ca93957c5dfff87 Mon Sep 17 00:00:00 2001 From: Daniel Kurzynski Date: Thu, 8 Oct 2026 16:14:21 +0200 Subject: [PATCH 6/6] fix(gpui): close the comment card and selection pill on a press elsewhere --- crates/ui_gpui/src/comments/editor.rs | 6 +++++- .../main_screen/right_panel/file_viewer.rs | 14 +++++++++++--- .../main_screen/right_panel/review_view.rs | 19 +++++++++++++++++++ .../right_panel/review_view/comments.rs | 14 +++++++++++--- 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/crates/ui_gpui/src/comments/editor.rs b/crates/ui_gpui/src/comments/editor.rs index 37970125..2fa3616f 100644 --- a/crates/ui_gpui/src/comments/editor.rs +++ b/crates/ui_gpui/src/comments/editor.rs @@ -1,6 +1,6 @@ //! 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 cancels. +//! 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}; @@ -101,6 +101,10 @@ impl Render for CommentEditor { .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(); diff --git a/crates/ui_gpui/src/main_screen/right_panel/file_viewer.rs b/crates/ui_gpui/src/main_screen/right_panel/file_viewer.rs index f7a7300e..079181bb 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/file_viewer.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/file_viewer.rs @@ -78,6 +78,8 @@ pub struct FileViewer { _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 {} @@ -106,6 +108,7 @@ impl FileViewer { _editor_subscriptions: Vec::new(), _comment_editor_subscription: None, anchor: AnchorTracker::default(), + pill_dismissed: None, } } @@ -438,7 +441,7 @@ impl FileViewer { return (None, position); } let range = editor.selected_range(); - if range.is_empty() { + if range.is_empty() || self.pill_dismissed.as_ref() == Some(&range) { return (None, None); } let pill = editor @@ -642,9 +645,14 @@ impl FileViewer { 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, - selection_pill( + div().on_mouse_down_out(dismiss).child(selection_pill( "file-selection", move |_, cx| { viewer @@ -657,7 +665,7 @@ impl FileViewer { .ok(); }, cx, - ), + )), )) } } 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 7291817b..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 @@ -222,6 +222,8 @@ pub struct ReviewView { 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. @@ -277,6 +279,7 @@ impl ReviewView { pending_reveal: None, selection_anchor: Rc::default(), anchor: Default::default(), + pill_dismissed: None, watcher: None, watch_task: None, listing_generation: GENERATION_UNSEEN, @@ -1563,5 +1566,21 @@ mod tests { 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 index 51a155b7..d50cdc30 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/review_view/comments.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/review_view/comments.rs @@ -165,15 +165,23 @@ impl ReviewView { 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()) + .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?, - selection_pill( + div().on_mouse_down_out(dismiss).child(selection_pill( "review-selection", move |_, cx| { view.update(cx, |view, cx| view.copy_selection(cx)).ok(); @@ -184,7 +192,7 @@ impl ReviewView { .ok(); }, cx, - ), + )), )) }