From 94490e2b3706ce94776553d8f558540d5f75ee25 Mon Sep 17 00:00:00 2001 From: Lee Dong Geon <100822592+ldg030201@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:07:44 +0900 Subject: [PATCH] feat(vcs): show the history of the selected lines (#1266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Git › Show History for Selection, as in IDEA: from Git › Current File and the editor's Git menu it opens a tab with only the commits that changed the selected lines (or the caret line), newest first, and the block before and after each commit in the diff viewer. Only Show History for the whole file existed before. git log -L tracks the lines across renames; the selection is first mapped through unsaved and uncommitted edits to HEAD's lines, and those edits come first as Local Changes. The query is its own ide-vcs module and cancellable command (vcs_block_history), read-only and allowed in Restricted Mode (--no-ext-diff --no-textconv). Co-authored-by: ldg030201 <100822592+ldg030201@users.noreply.github.com> --- crates/ide-host/src/ops/vcs/commands.rs | 27 +++ crates/ide-host/src/trust.rs | 1 + crates/ide-vcs/src/block_history.rs | 227 ++++++++++++++++++ crates/ide-vcs/src/lib.rs | 44 ++++ crates/ide-vcs/tests/block_history.rs | 124 ++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/vcs.rs | 15 ++ src-tauri/src/menu.rs | 1 + src/bindings.ts | 30 +++ src/editor/core/controller.ts | 1 + src/features/vcs/SelectionHistory.module.css | 18 ++ src/features/vcs/SelectionHistory.tsx | 154 ++++++++++++ .../vcs/__tests__/selectionHistory.test.tsx | 136 +++++++++++ src/features/vcs/actions.tsx | 10 + src/features/vcs/index.ts | 10 + src/features/vcs/selectionHistoryKind.ts | 47 ++++ src/platform/locales/en.json | 1 + src/platform/locales/ko.json | 1 + src/platform/locales/text/ko.json | 6 + src/platform/locales/text/zh.json | 6 + src/platform/locales/zh.json | 1 + 21 files changed, 861 insertions(+) create mode 100644 crates/ide-vcs/src/block_history.rs create mode 100644 crates/ide-vcs/tests/block_history.rs create mode 100644 src/features/vcs/SelectionHistory.module.css create mode 100644 src/features/vcs/SelectionHistory.tsx create mode 100644 src/features/vcs/__tests__/selectionHistory.test.tsx create mode 100644 src/features/vcs/selectionHistoryKind.ts diff --git a/crates/ide-host/src/ops/vcs/commands.rs b/crates/ide-host/src/ops/vcs/commands.rs index 1ed60e503..42c2b2943 100644 --- a/crates/ide-host/src/ops/vcs/commands.rs +++ b/crates/ide-host/src/ops/vcs/commands.rs @@ -222,6 +222,29 @@ pub async fn vcs_blame( .await } +/// History for Selection: the commits that changed `lines` (0-based, inclusive) of the +/// editor's text of `path`. Cancellable. +pub async fn vcs_block_history( + host: &Host, + window: &str, + request: RequestId, + path: PathBuf, + text: Option, + lines: (u32, u32), +) -> IdeResult { + let project = host.project(window)?; + let cancel = host.begin_request(request); + let result = blocking(move || { + let text = text.or_else(|| project.documents.text_of(&path)); + project.with_repo_of(&path, |r| { + r.block_history(&path, text.as_deref(), lines, project.vcs_encoding(&path, None)?, &cancel) + }) + }) + .await; + host.end_request(request); + result +} + pub async fn vcs_log(host: &Host, window: &str, repo: Option, query: LogQuery) -> IdeResult> { let project = host.project(window)?; blocking(move || project.with_repo_at(repo.as_deref(), |r| r.log(&query))).await @@ -987,6 +1010,10 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("vcs_blame", |c| async move { vcs_blame(&c.host, &c.window, c.arg("path")?, c.arg("text")?, c.arg("revision")?).await }); + d.add("vcs_block_history", |c| async move { + let (request, path, text, lines) = (c.arg("request")?, c.arg("path")?, c.arg("text")?, c.arg("lines")?); + vcs_block_history(&c.host, &c.window, request, path, text, lines).await + }); d.add("vcs_log", |c| async move { vcs_log(&c.host, &c.window, c.arg("repo")?, c.arg("query")?).await }); d.add("vcs_commit_files", |c| async move { vcs_commit_files(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?).await diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index aa0b04973..e365ada59 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -305,6 +305,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { | "vcs_line_hunks" | "vcs_file_at" | "vcs_blame" + | "vcs_block_history" | "vcs_log" | "vcs_commit_files" | "vcs_commits_changes" diff --git a/crates/ide-vcs/src/block_history.rs b/crates/ide-vcs/src/block_history.rs new file mode 100644 index 000000000..c47d1cb86 --- /dev/null +++ b/crates/ide-vcs/src/block_history.rs @@ -0,0 +1,227 @@ +//! History for Selection: the commits that changed a block of lines +//! (`git log -L`), each with the block as it was before and after. + +use crate::text_encoding::{TextEncoding, decode}; +use crate::{BlockHistory, BlockRevision, LineHunk, Repo, cli}; +use ide_core::{CancellationToken, IdeResult}; +use std::path::Path; + +/// Separates commits in the log output; fields of the header are separated by `\x1f`. +const RECORD: u8 = 0x1e; +const FORMAT: &str = "--format=%x1e%H%x1f%h%x1f%an%x1f%ae%x1f%at%x1f%s"; + +pub(crate) fn block_history( + r: &Repo, + path: &Path, + current_text: Option<&str>, + lines: (u32, u32), + encoding: TextEncoding, + cancel: &CancellationToken, +) -> IdeResult { + let rel = r.rel_path(path)?; + let head = { + let repo = r.gix()?; + crate::repo::blob_at(&repo, &rel, "HEAD")? + }; + let Some(head) = head else { + return Ok(BlockHistory { head_lines: None, revisions: Vec::new() }); + }; + let head = decode(&head, encoding); + let current = match current_text { + Some(text) => text.to_owned(), + None => decode(&std::fs::read(r.abs_path(&rel))?, encoding).into_owned(), + }; + let Some((start, end)) = head_range(&crate::diff::line_hunks(&head, ¤t), lines) else { + return Ok(BlockHistory { head_lines: None, revisions: Vec::new() }); + }; + let range = format!("-L{},{}:{rel}", start + 1, end + 1); + let args = ["log", "--no-color", "--no-ext-diff", "--no-textconv", FORMAT, &range, "HEAD"]; + let out = cli::run_cancellable(r.root(), &args, cancel)?; + Ok(BlockHistory { head_lines: Some((start, end)), revisions: parse_log(&out.stdout, encoding) }) +} + +/// The HEAD lines (0-based, inclusive) of `lines` in the current text, given the +/// hunks between HEAD and it. A selection end inside a changed region takes the +/// whole region; None when every selected line is new. +pub(crate) fn head_range(hunks: &[LineHunk], (start, end): (u32, u32)) -> Option<(u32, u32)> { + let (start, end) = (start.min(end), start.max(end)); + let to_head = |line: u32, at_end: bool| -> i64 { + let mut shift = 0i64; + for h in hunks { + if line < h.start { + break; + } + if line < h.start + h.lines { + return if at_end { + i64::from(h.old_start) + i64::from(h.old_lines) - 1 + } else { + i64::from(h.old_start) + }; + } + shift += i64::from(h.lines) - i64::from(h.old_lines); + } + i64::from(line) - shift + }; + let (from, to) = (to_head(start, false), to_head(end, true)); + (from >= 0 && from <= to).then_some((from as u32, to as u32)) +} + +/// Commits of `git log -L` output, newest first. +pub(crate) fn parse_log(stdout: &[u8], encoding: TextEncoding) -> Vec { + stdout + .split(|b| *b == RECORD) + .filter(|r| !r.is_empty()) + .filter_map(|record| parse_record(record, encoding)) + .collect() +} + +fn parse_record(record: &[u8], encoding: TextEncoding) -> Option { + let newline = record.iter().position(|b| *b == b'\n').unwrap_or(record.len()); + let header = String::from_utf8_lossy(&record[..newline]); + let mut fields = header.split('\x1f'); + let id = fields.next()?.to_owned(); + let short_id = fields.next()?.to_owned(); + let author = fields.next()?.to_owned(); + let email = fields.next()?.to_owned(); + let time_ms = fields.next()?.trim().parse::().ok()? * 1000.0; + let summary = fields.next().unwrap_or_default().to_owned(); + let patch = decode(record.get(newline + 1..).unwrap_or_default(), encoding); + let mut revision = BlockRevision { + id, + short_id, + author, + email, + time_ms, + summary, + path: String::new(), + before: String::new(), + after: String::new(), + before_line: 0, + after_line: 0, + }; + let mut in_hunk = false; + for line in patch.lines() { + if let Some(path) = line.strip_prefix("+++ ") { + revision.path = unquote(path).trim_start_matches("b/").to_owned(); + } else if let Some(ranges) = line.strip_prefix("@@ ") { + in_hunk = true; + let mut parts = ranges.split_whitespace(); + revision.before_line = hunk_start(parts.next().unwrap_or_default()); + revision.after_line = hunk_start(parts.next().unwrap_or_default()); + } else if in_hunk { + match line.as_bytes().first() { + Some(b' ') => { + push_line(&mut revision.before, &line[1..]); + push_line(&mut revision.after, &line[1..]); + } + Some(b'-') => push_line(&mut revision.before, &line[1..]), + Some(b'+') => push_line(&mut revision.after, &line[1..]), + _ => {} + } + } + } + Some(revision) +} + +/// 0-based first line of a hunk side (`-12,3` → 11; `+0,0` for a file that did not exist → 0). +fn hunk_start(side: &str) -> u32 { + let n = side.trim_start_matches(['-', '+']).split(',').next().unwrap_or_default(); + n.parse::().unwrap_or(0).saturating_sub(1) +} + +fn push_line(block: &mut String, line: &str) { + block.push_str(line); + block.push('\n'); +} + +/// A C-quoted path (`"b/a \"b\".txt"`), as git writes unusual names. +fn unquote(path: &str) -> String { + let Some(inner) = path.strip_prefix('"').and_then(|p| p.strip_suffix('"')) else { + return path.to_owned(); + }; + let mut out = String::new(); + let mut chars = inner.chars(); + while let Some(c) = chars.next() { + if c != '\\' { + out.push(c); + continue; + } + match chars.next() { + Some('t') => out.push('\t'), + Some('n') => out.push('\n'), + Some(other) => out.push(other), + None => {} + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::HunkKind; + + fn hunk(start: u32, lines: u32, old_start: u32, old_lines: u32) -> LineHunk { + LineHunk { kind: HunkKind::Modified, start, lines, old_start, old_lines, old_text: String::new() } + } + + #[test] + fn maps_the_selection_to_head_lines() { + // Unchanged file. + assert_eq!(head_range(&[], (3, 5)), Some((3, 5))); + // Two lines inserted at 1: current 4..=6 were HEAD 2..=4; a reversed selection too. + let inserted = [hunk(1, 2, 1, 0)]; + assert_eq!(head_range(&inserted, (4, 6)), Some((2, 4))); + assert_eq!(head_range(&inserted, (6, 4)), Some((2, 4))); + // Selecting only the inserted lines: nothing committed. + assert_eq!(head_range(&inserted, (1, 2)), None); + // From an inserted line on: the HEAD lines after the insertion. + assert_eq!(head_range(&inserted, (2, 4)), Some((1, 2))); + // Three HEAD lines (5..=7) replaced by one: a selection ending in it takes all three. + let replaced = [hunk(5, 1, 5, 3)]; + assert_eq!(head_range(&replaced, (4, 5)), Some((4, 7))); + assert_eq!(head_range(&replaced, (6, 6)), Some((8, 8))); + // Two HEAD lines deleted at 2: later lines move down by two. + let deleted = [hunk(2, 0, 2, 2)]; + assert_eq!(head_range(&deleted, (2, 3)), Some((4, 5))); + } + + #[test] + fn parses_git_log_line_ranges() { + let out = [ + "\x1eaaaa1111\x1faaaa\x1fAda\x1fada@example.com\x1f1700000000\x1fRename greet", + "", + "diff --git a/src/a.txt b/src/b.txt", + "--- a/src/a.txt", + "+++ b/src/b.txt", + "@@ -2,2 +2,2 @@", + " keep", + "-hello", + "+hi", + "\x1ebbbb2222\x1fbbbb\x1fBo\x1fbo@example.com\x1f1600000000\x1fAdd", + "", + "diff --git a/src/a.txt b/src/a.txt", + "--- /dev/null", + "+++ b/src/a.txt", + "@@ -0,0 +2,2 @@", + "+keep", + "+hello", + ] + .join("\n"); + let revisions = parse_log(out.as_bytes(), TextEncoding::from(encoding_rs::UTF_8)); + assert_eq!(revisions.len(), 2); + let [newer, older] = &revisions[..] else { unreachable!() }; + assert_eq!((newer.id.as_str(), newer.short_id.as_str(), newer.author.as_str()), ("aaaa1111", "aaaa", "Ada")); + assert_eq!((newer.time_ms, newer.summary.as_str(), newer.path.as_str()), (1.7e12, "Rename greet", "src/b.txt")); + assert_eq!((newer.before.as_str(), newer.after.as_str()), ("keep\nhello\n", "keep\nhi\n")); + assert_eq!((newer.before_line, newer.after_line), (1, 1)); + assert_eq!((older.before.as_str(), older.after.as_str()), ("", "keep\nhello\n")); + assert_eq!((older.before_line, older.after_line), (0, 1)); + } + + #[test] + fn unquotes_c_quoted_paths() { + assert_eq!(unquote(r#""b/a \"q\"\tb.txt""#), "b/a \"q\"\tb.txt"); + assert_eq!(unquote("b/plain.txt"), "b/plain.txt"); + } +} diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index a14c23448..0db7caedb 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -77,6 +77,35 @@ pub struct BlameLine { pub summary: String, } +/// History for Selection: the commits that changed a block of lines, newest first. +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct BlockHistory { + /// The selected lines in HEAD (0-based, inclusive); None when none of them is committed. + pub head_lines: Option<(u32, u32)>, + pub revisions: Vec, +} + +/// A commit that changed the block, with the block in its first parent and in it. +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct BlockRevision { + pub id: String, + pub short_id: String, + pub author: String, + pub email: String, + pub time_ms: f64, + pub summary: String, + /// Repository-relative path of the file in this commit (before a later rename). + pub path: String, + /// The block before the commit; empty when the commit added it. + pub before: String, + pub after: String, + /// 0-based first line of the block before and after the commit. + pub before_line: u32, + pub after_line: u32, +} + #[derive(Clone, Debug, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct Commit { @@ -459,6 +488,7 @@ pub struct ChangelistState { } mod blame; +mod block_history; pub mod changelists; mod cli; mod compare; @@ -685,6 +715,20 @@ impl Repo { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::blame(self, path, current_text, revision, encoding) } + /// History for Selection: commits that changed `lines` (0-based, inclusive, of the + /// current text: `current_text`, or the file on disk), mapped to HEAD's lines first. + pub fn block_history( + &self, + path: &Path, + current_text: Option<&str>, + lines: (u32, u32), + encoding: impl Into, + cancel: &CancellationToken, + ) -> IdeResult { + let encoding = encoding.into(); + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); + block_history::block_history(self, path, current_text, lines, encoding, cancel) + } /// Commits newest-first in topological order (children before parents). /// /// `branch` is any revision; when absent, HEAD plus all local and remote branches are diff --git a/crates/ide-vcs/tests/block_history.rs b/crates/ide-vcs/tests/block_history.rs new file mode 100644 index 000000000..961282736 --- /dev/null +++ b/crates/ide-vcs/tests/block_history.rs @@ -0,0 +1,124 @@ +//! History for Selection (`git log -L`) against throwaway repositories built with the git CLI. + +use ide_core::CancellationToken; +use ide_core::error::ErrorKind; +use ide_vcs::*; +use std::path::{Path, PathBuf}; +use std::process::Command; + +struct Fixture { + _tmp: tempfile::TempDir, + dir: PathBuf, +} + +impl Fixture { + fn new() -> Self { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("work"); + std::fs::create_dir(&dir).unwrap(); + let fx = Fixture { _tmp: tmp, dir }; + fx.git(&["init", "-q"]); + for (k, v) in [("user.name", "Test User"), ("user.email", "test@example.com"), ("commit.gpgsign", "false")] { + fx.git(&["config", k, v]); + } + fx + } + fn git(&self, args: &[&str]) -> String { + let out = Command::new("git") + .current_dir(&self.dir) + .args(["-c", "init.defaultBranch=main", "-c", "core.autocrlf=false"]) + .args(args) + .output() + .expect("git runs"); + assert!(out.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr)); + String::from_utf8_lossy(&out.stdout).trim().to_string() + } + fn write(&self, rel: &str, content: &str) { + std::fs::write(self.dir.join(rel), content).unwrap(); + } + fn commit(&self, msg: &str) { + self.git(&["add", "-A"]); + self.git(&["commit", "-q", "-m", msg]); + } + fn repo(&self) -> Repo { + Repo::discover(&self.dir).unwrap().expect("repository found") + } + fn path(&self, rel: &str) -> PathBuf { + self.dir.join(rel) + } +} + +fn history(repo: &Repo, path: &Path, text: Option<&str>, lines: (u32, u32)) -> BlockHistory { + repo.block_history(path, text, lines, encoding_rs::UTF_8, &CancellationToken::new()).unwrap() +} + +/// a.txt: added, line 2 edited, line 5 edited, renamed to b.txt, line 3 edited. +fn renamed_history() -> Fixture { + let fx = Fixture::new(); + fx.write("a.txt", "one\ntwo\nthree\nfour\nfive\n"); + fx.commit("Add a"); + fx.write("a.txt", "one\nTWO\nthree\nfour\nfive\n"); + fx.commit("Shout two"); + fx.write("a.txt", "one\nTWO\nthree\nfour\nFIVE\n"); + fx.commit("Shout five"); + fx.git(&["mv", "a.txt", "b.txt"]); + fx.commit("Rename a to b"); + fx.write("b.txt", "one\nTWO\nTHREE\nfour\nFIVE\n"); + fx.commit("Shout three"); + fx +} + +#[test] +fn follows_the_block_through_edits_and_a_rename() { + let fx = renamed_history(); + let h = history(&fx.repo(), &fx.path("b.txt"), None, (1, 2)); + assert_eq!(h.head_lines, Some((1, 2))); + let shown: Vec<_> = h.revisions.iter().map(|r| (r.summary.as_str(), r.path.as_str())).collect(); + // Edits outside the block and the pure rename are left out. + assert_eq!(shown, [("Shout three", "b.txt"), ("Shout two", "a.txt"), ("Add a", "a.txt")]); + let blocks: Vec<_> = h.revisions.iter().map(|r| (r.before.as_str(), r.after.as_str())).collect(); + assert_eq!(blocks, [("TWO\nthree\n", "TWO\nTHREE\n"), ("two\nthree\n", "TWO\nthree\n"), ("", "two\nthree\n")]); + let newest = &h.revisions[0]; + assert_eq!((newest.before_line, newest.after_line, newest.author.as_str()), (1, 1, "Test User")); + assert!(newest.id.starts_with(&newest.short_id) && newest.time_ms > 0.0); +} + +#[test] +fn maps_an_edited_selection_to_head_lines() { + let fx = renamed_history(); + let repo = fx.repo(); + // Two unsaved lines inserted at the top: lines 3..=4 are HEAD's 1..=2. + let edited = "new\nnew\none\nTWO\nTHREE\nfour\nFIVE\n"; + let h = history(&repo, &fx.path("b.txt"), Some(edited), (3, 4)); + assert_eq!(h.head_lines, Some((1, 2))); + assert_eq!(h.revisions.len(), 3); + // Only new lines selected, or a file Git does not know: nothing committed to show. + let h = history(&repo, &fx.path("b.txt"), Some(edited), (0, 1)); + assert_eq!((h.head_lines, h.revisions.len()), (None, 0)); + fx.write("untracked.txt", "x\n"); + let h = history(&repo, &fx.path("untracked.txt"), None, (0, 0)); + assert_eq!((h.head_lines, h.revisions.len()), (None, 0)); +} + +#[test] +fn restricted_mode_runs_no_diff_driver_of_the_repository() { + let fx = renamed_history(); + let marker = fx.path("textconv-ran"); + fx.write(".gitattributes", "*.txt diff=probe\n"); + fx.commit("Attributes"); + fx.git(&["config", "diff.probe.textconv", &format!("sh -c 'touch {}; cat \"$0\"'", marker.display())]); + let repo = fx.repo(); + repo.set_execution_allowed(false); + let h = history(&repo, &fx.path("b.txt"), None, (1, 2)); + assert_eq!(h.revisions.len(), 3); + assert!(!marker.exists(), "a textconv driver ran in restricted mode"); +} + +#[test] +fn cancelled_requests_stop() { + let fx = renamed_history(); + let cancel = CancellationToken::new(); + cancel.cancel(); + let err = fx.repo().block_history(&fx.path("b.txt"), None, (1, 2), encoding_rs::UTF_8, &cancel).unwrap_err(); + assert_eq!(err.kind, ErrorKind::Cancelled); +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index bf45cdca9..dfb51cd21 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -247,6 +247,7 @@ pub fn builder() -> tauri_specta::Builder { vcs::vcs_line_hunks, vcs::vcs_file_at, vcs::vcs_blame, + vcs::vcs_block_history, vcs::vcs_log, vcs::vcs_commit_files, vcs::vcs_commits_changes, diff --git a/src-tauri/src/commands/vcs.rs b/src-tauri/src/commands/vcs.rs index 9f23a5a5a..ba3dee826 100644 --- a/src-tauri/src/commands/vcs.rs +++ b/src-tauri/src/commands/vcs.rs @@ -50,6 +50,21 @@ pub async fn vcs_file_at( ide_host::ops::vcs::vcs_file_at(&state.host, window.label(), path, rev).await } +/// History for Selection: commits that changed `lines` (0-based, inclusive) of `path`'s +/// editor text. Cancellable with `cancel_request(request)`. +#[tauri::command] +#[specta::specta] +pub async fn vcs_block_history( + window: Window, + state: State<'_, AppState>, + request: RequestId, + path: PathBuf, + text: Option, + lines: (u32, u32), +) -> IdeResult { + ide_host::ops::vcs::vcs_block_history(&state.host, window.label(), request, path, text, lines).await +} + #[tauri::command] #[specta::specta] pub async fn vcs_blame( diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index f85412eff..8b9fc95ae 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -670,6 +670,7 @@ pub fn spec(os: Os) -> Vec { act("vcs.showDiff", "Show Diff"), act("vcs.compareWithBranch", "Compare with Branch or Revision…"), act("vcs.showHistory", "Show History"), + act("vcs.showHistoryForSelection", "Show History for Selection"), SEP, act("vcs.localHistory", "Local History"), act("localHistory.putLabel", "Put Label…"), diff --git a/src/bindings.ts b/src/bindings.ts index 042adce4f..25da23951 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -469,6 +469,11 @@ export const commands = { vcsLineHunks: (path: string) => typedError(__TAURI_INVOKE("vcs_line_hunks", { path })), vcsFileAt: (path: string, rev: string | null) => typedError(__TAURI_INVOKE("vcs_file_at", { path, rev })), vcsBlame: (path: string, text: string | null, revision: string | null) => typedError(__TAURI_INVOKE("vcs_blame", { path, text, revision })), + /** + * History for Selection: commits that changed `lines` (0-based, inclusive) of `path`'s + * editor text. Cancellable with `cancel_request(request)`. + */ + vcsBlockHistory: (request: RequestId, path: string, text: string | null, lines: [number, number]) => typedError(__TAURI_INVOKE("vcs_block_history", { request, path, text, lines })), vcsLog: (repo: string | null, query: LogQuery) => typedError(__TAURI_INVOKE("vcs_log", { repo, query })), vcsCommitFiles: (repo: string | null, commit: string) => typedError(__TAURI_INVOKE("vcs_commit_files", { repo, commit })), /** Combined changes of several commits, `commits` oldest first. */ @@ -1710,6 +1715,31 @@ export type BlameLine = { summary: string, }; +/** History for Selection: the commits that changed a block of lines, newest first. */ +export type BlockHistory = { + /** The selected lines in HEAD (0-based, inclusive); None when none of them is committed. */ + headLines: [number, number] | null, + revisions: BlockRevision[], +}; + +/** A commit that changed the block, with the block in its first parent and in it. */ +export type BlockRevision = { + id: string, + shortId: string, + author: string, + email: string, + timeMs: number | null, + summary: string, + /** Repository-relative path of the file in this commit (before a later rename). */ + path: string, + /** The block before the commit; empty when the commit added it. */ + before: string, + after: string, + /** 0-based first line of the block before and after the commit. */ + beforeLine: number, + afterLine: number, +}; + export type Branch = { name: string, remote: boolean, diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index 7e9cd19b7..fc43c9b1c 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -1848,6 +1848,7 @@ function gitMenu(path: string): MenuItem { { kind: "action", action: "vcs.showDiff", arg: [path], label: "Show Diff" }, { kind: "action", action: "vcs.compareWithBranch", arg: { path }, label: "Compare with Branch…" }, { kind: "action", action: "vcs.showHistory", arg: [path], label: "Show History" }, + { kind: "action", action: "vcs.showHistoryForSelection", label: "Show History for Selection" }, { kind: "action", action: "vcs.rollback", arg: [path], label: "Rollback…" }, { kind: "separator" }, { kind: "action", action: "vcs.push", label: "Push…" }, diff --git a/src/features/vcs/SelectionHistory.module.css b/src/features/vcs/SelectionHistory.module.css new file mode 100644 index 000000000..04bc54f19 --- /dev/null +++ b/src/features/vcs/SelectionHistory.module.css @@ -0,0 +1,18 @@ +.range { + flex: none; + color: var(--fg-subtle); + font-size: var(--fs-small); +} +.summary, +.meta { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.summary { + color: var(--fg-emphasis); +} +.meta { + color: var(--fg-subtle); + font-size: var(--fs-small); +} diff --git a/src/features/vcs/SelectionHistory.tsx b/src/features/vcs/SelectionHistory.tsx new file mode 100644 index 000000000..f92a5eab9 --- /dev/null +++ b/src/features/vcs/SelectionHistory.tsx @@ -0,0 +1,154 @@ +/** + * History for Selection editor tab (IDEA's Show History for Selection): the + * commits that changed the selected lines (`git log -L`, renames followed), + * newest first, each shown as the block before and after it. Unsaved or + * uncommitted edits of the block come first as Local Changes. + */ +import { useEffect, useState } from "react"; +import type { BlockHistory, BlockRevision } from "@/bindings"; +import { call, commands, isCancelled, nextRequestId } from "@/platform/ipc"; +import { List, Spinner, T } from "@/ui"; +import type { EditorInput } from "@/workbench/contributions"; +import { DiffEditor } from "./DiffEditor"; +import { diffInput, type DiffSide } from "./diffInput"; +import { baseName, formatTime } from "./model"; +import { errorMessage } from "./ops"; +import type { SelectionHistoryData } from "./selectionHistoryKind"; +import layout from "./LocalHistory.module.css"; +import styles from "./SelectionHistory.module.css"; +import { tx } from "@/platform/i18n"; + +export type HistoryRow = + | { kind: "local"; head: string; current: string } + | { kind: "revision"; revision: BlockRevision }; + +const HEAD = "HEAD"; + +/** Lines `from..=to` of `text`, each ending with a line break (as Git prints a block). */ +export function blockOf(text: string, [from, to]: readonly [number, number]): string { + return text + .split("\n") + .slice(from, to + 1) + .map((line) => `${line.replace(/\r$/, "")}\n`) + .join(""); +} + +/** The rows of the list: Local Changes when the block differs from HEAD, then the commits. */ +export function historyRows(history: BlockHistory, current: string): HistoryRow[] { + const rows: HistoryRow[] = history.revisions.map((revision) => ({ kind: "revision", revision })); + const head = history.revisions[0]?.after; + if (history.headLines && head !== undefined && head !== current) rows.unshift({ kind: "local", head, current }); + return rows; +} + +function sides(row: HistoryRow): [DiffSide, DiffSide] { + if (row.kind === "local") + return [ + { type: "text", text: row.head, label: HEAD }, + { type: "text", text: row.current, label: tx("Local Changes") }, + ]; + const r = row.revision; + return [ + { type: "text", text: r.before, label: `${r.shortId}^` }, + { type: "text", text: r.after, label: r.shortId }, + ]; +} + +export function SelectionHistoryEditor({ input }: { input: EditorInput }) { + const data = input.data as SelectionHistoryData; + const [history, setHistory] = useState(null); + const [error, setError] = useState(null); + const [active, setActive] = useState(0); + + useEffect(() => { + const request = nextRequestId(); + let done = false; + call(commands.vcsBlockHistory(request, data.path, data.text, data.lines)) + .then(setHistory, (e: unknown) => { + if (!isCancelled(e)) setError(errorMessage(e)); + }) + .finally(() => { + done = true; + }); + return () => { + if (!done) void commands.cancelRequest(request); + }; + }, [data]); + + const rows = history ? historyRows(history, blockOf(data.text, data.lines)) : []; + const current = rows[Math.min(active, rows.length - 1)]; + const [from, to] = data.lines; + + let detail; + if (!current) + detail = ( +
+ Select a revision +
+ ); + else { + const [left, right] = sides(current); + detail = ( + + ); + } + + return ( +
+
+
+ + History for Selection + + {`${baseName(data.path)}:${from + 1}-${to + 1}`} +
+ {error ? ( +
{error}
+ ) : !history ? ( +
+ +
+ ) : ( + (row.kind === "local" ? "local" : row.revision.id)} + activeIndex={Math.min(active, rows.length - 1)} + onActiveChange={setActive} + onActivate={(_row, i) => setActive(i)} + rowHeight={38} + aria-label="Revisions" + className={layout.list} + empty={ + + {history.headLines ? tx`No commits changed these lines` : tx`These lines are not committed yet`} + + } + renderItem={(row) => + row.kind === "local" ? ( + + + Local Changes + + + Not Committed Yet + + + ) : ( + + {row.revision.summary} + + {`${row.revision.shortId} · ${row.revision.author} · ${formatTime(row.revision.timeMs)}`} + + + ) + } + /> + )} +
+
{detail}
+
+ ); +} diff --git a/src/features/vcs/__tests__/selectionHistory.test.tsx b/src/features/vcs/__tests__/selectionHistory.test.tsx new file mode 100644 index 000000000..50f81fbd8 --- /dev/null +++ b/src/features/vcs/__tests__/selectionHistory.test.tsx @@ -0,0 +1,136 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { EditorState } from "@codemirror/state"; +import type { BlockHistory, BlockRevision } from "@/bindings"; + +const vcsBlockHistory = vi.fn(); +const cancelRequest = vi.fn(); + +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + isTauri: () => true, + commands: { + ...real.commands, + vcsBlockHistory: (...args: unknown[]) => vcsBlockHistory(...args), + cancelRequest: (id: number) => cancelRequest(id), + }, + }; +}); +vi.mock("../DiffEditor", () => ({ + DiffEditor: ({ + input, + }: { + input: { data: { left: { label: string; text: string }; right: { label: string; text: string } } }; + }) => ( +
+ {`${input.data.left.label}: ${input.data.left.text}| ${input.data.right.label}: ${input.data.right.text}`} +
+ ), +})); + +import { SelectionHistoryEditor, blockOf, historyRows } from "../SelectionHistory"; +import { selectedLines, selectionHistoryInput, type SelectionHistoryData } from "../selectionHistoryKind"; + +const revision = (id: string, summary: string, before: string, after: string): BlockRevision => ({ + id: id.repeat(40), + shortId: id.repeat(7), + author: "Ada", + email: "ada@example.com", + timeMs: 1_700_000_000_000, + summary, + path: "src/a.txt", + before, + after, + beforeLine: 1, + afterLine: 1, +}); + +const TEXT = "one\nTWO\nTHREE\nfour\n"; +const history: BlockHistory = { + headLines: [1, 2], + revisions: [ + revision("a", "Shout three", "TWO\nthree\n", "TWO\nTHREE\n"), + revision("b", "Shout two", "two\nthree\n", "TWO\nthree\n"), + ], +}; +const ok = (data: unknown) => Promise.resolve({ status: "ok", data }); + +function open(data: SelectionHistoryData) { + return render(); +} + +beforeEach(() => { + vcsBlockHistory.mockReset(); + cancelRequest.mockReset().mockReturnValue(ok(null)); +}); + +describe("selected lines", () => { + const doc = EditorState.create({ doc: "zero\none\ntwo\nthree\n" }).doc; + it.each([ + ["the caret line without a selection", 6, 6, [1, 1]], + ["every line the selection touches", 7, 11, [1, 2]], + ["a selection backwards", 11, 7, [1, 2]], + ["not the line a selection ends at the start of", 5, 13, [1, 2]], + ["the line itself when selecting just its start", 9, 9, [2, 2]], + ])("%s", (_, from, to, expected) => { + expect(selectedLines(doc, Math.min(from, to), Math.max(from, to))).toEqual(expected); + }); +}); + +describe("rows", () => { + it("cuts the block out of the text as Git prints it", () => { + expect(blockOf(TEXT, [1, 2])).toBe("TWO\nTHREE\n"); + expect(blockOf("a\r\nb\r\n", [0, 1])).toBe("a\nb\n"); + }); + + it("lists Local Changes only when the block differs from HEAD", () => { + expect(historyRows(history, "TWO\nTHREE\n").map((r) => r.kind)).toEqual(["revision", "revision"]); + const edited = historyRows(history, "TWO\nTHREE!\n"); + expect(edited[0]).toEqual({ kind: "local", head: "TWO\nTHREE\n", current: "TWO\nTHREE!\n" }); + expect(edited).toHaveLength(3); + expect(historyRows({ headLines: null, revisions: [] }, "new\n")).toEqual([]); + }); +}); + +describe("History for Selection tab", () => { + it("asks for the selected lines of the editor's text and diffs each commit's block", async () => { + vcsBlockHistory.mockReturnValue(ok(history)); + open({ path: "/p/src/a.txt", lines: [1, 2], text: "one\nTWO\nTHREE!\nfour\n" }); + await screen.findByText("Shout three"); + const [request, path, text, lines] = vcsBlockHistory.mock.calls[0]!; + expect([typeof request, path, text, lines]).toEqual(["number", "/p/src/a.txt", "one\nTWO\nTHREE!\nfour\n", [1, 2]]); + expect(screen.getByText("a.txt:2-3")).toBeTruthy(); + // The unsaved edit comes first, against HEAD's block. + expect(screen.getByTestId("diff").textContent).toBe("HEAD: TWO\nTHREE\n| Local Changes: TWO\nTHREE!\n"); + fireEvent.click(screen.getByText("Shout two")); + await waitFor(() => + expect(screen.getByTestId("diff").textContent).toBe("bbbbbbb^: two\nthree\n| bbbbbbb: TWO\nthree\n"), + ); + expect(screen.getByText(/^bbbbbbb · Ada · /)).toBeTruthy(); + }); + + it("says why there is nothing to show", async () => { + vcsBlockHistory.mockReturnValue(ok({ headLines: null, revisions: [] })); + const { unmount } = open({ path: "/p/a.txt", lines: [0, 0], text: "new\n" }); + await screen.findByText("These lines are not committed yet"); + unmount(); + vcsBlockHistory.mockReturnValue(ok({ headLines: [0, 0], revisions: [] })); + open({ path: "/p/a.txt", lines: [0, 0], text: "old\n" }); + await screen.findByText("No commits changed these lines"); + }); + + it("shows Git's error and cancels a request still running when closed", async () => { + vcsBlockHistory.mockReturnValueOnce( + Promise.resolve({ status: "error", error: { kind: "internal", message: "fatal: bad range" } }), + ); + const { unmount } = open({ path: "/p/a.txt", lines: [0, 0], text: "x\n" }); + await screen.findByText(/fatal: bad range/); + unmount(); + expect(cancelRequest).not.toHaveBeenCalled(); + vcsBlockHistory.mockReturnValue(new Promise(() => {})); + open({ path: "/p/a.txt", lines: [0, 0], text: "x\n" }).unmount(); + expect(cancelRequest).toHaveBeenCalledWith(vcsBlockHistory.mock.calls.at(-1)![0]); + }); +}); diff --git a/src/features/vcs/actions.tsx b/src/features/vcs/actions.tsx index 2edcfb400..032119654 100644 --- a/src/features/vcs/actions.tsx +++ b/src/features/vcs/actions.tsx @@ -33,6 +33,7 @@ import { useChangelists } from "./changelists"; import { openDialog } from "./dialogs"; import { gotoChange, rollbackSelectedLines } from "./gutter"; import { openLocalHistory } from "./localHistoryKind"; +import { showSelectionHistory } from "./selectionHistoryKind"; import { openLogTab, revealCommit, selectGitTab, useGitLog } from "./logStore"; import { isModalCommit, openCommit, pushAfterCommit } from "./commitUi"; import { allRepos, runRemote } from "./ops"; @@ -472,6 +473,15 @@ export function registerVcsActions(): () => void { showGitWindow(); }, }, + { + id: "vcs.showHistoryForSelection", + title: "Show History for Selection", + category: "Git", + icon: "history", + when: `${VCS} && textEditorActive`, + keywords: ["line history", "block history", "git log -L"], + run: () => showSelectionHistory(), + }, { id: "vcs.showCommit", title: "Show Commit in Git Log", diff --git a/src/features/vcs/index.ts b/src/features/vcs/index.ts index 521828c10..64f1bd3fd 100644 --- a/src/features/vcs/index.ts +++ b/src/features/vcs/index.ts @@ -32,6 +32,7 @@ import { VcsDialogHost } from "./dialogs"; import { changeMarkers } from "./gutter"; import { installHunkPopup } from "./HunkPopup"; import { LOCAL_HISTORY_KIND } from "./localHistoryKind"; +import { SELECTION_HISTORY_KIND, type SelectionHistoryData } from "./selectionHistoryKind"; import { registerLocalHistoryActions } from "./localHistoryActions"; import { MERGE_KIND, type MergeData } from "./mergeKind"; import { registerMergeActions } from "./mergeActions"; @@ -253,6 +254,15 @@ export function activate(): () => void { title: (input) => `Local History: ${baseName((input.data as { path: string }).path)}`, icon: () => "clock", }), + registerEditorKind({ + kind: SELECTION_HISTORY_KIND, + component: lazyComponent(() => import("./SelectionHistory").then((m) => m.SelectionHistoryEditor)), + title: (input) => { + const { path, lines } = input.data as SelectionHistoryData; + return `History: ${baseName(path)}:${lines[0] + 1}-${lines[1] + 1}`; + }, + icon: () => "history", + }), registerEditorContribution({ id: "vcs.changeMarkers", order: 60, diff --git a/src/features/vcs/selectionHistoryKind.ts b/src/features/vcs/selectionHistoryKind.ts new file mode 100644 index 000000000..56b56972b --- /dev/null +++ b/src/features/vcs/selectionHistoryKind.ts @@ -0,0 +1,47 @@ +/** History for Selection tab input; kept apart from `SelectionHistory` so opening one does not load it. */ +import { activeEditor } from "@/editor/api"; +import { isVirtualUri } from "@/platform/virtualDocs"; +import { editorService, notifications, type EditorInput } from "@/workbench/contributions"; + +export const SELECTION_HISTORY_KIND = "selectionHistory"; + +export interface SelectionHistoryData { + path: string; + /** 0-based first and last selected line of `text`. */ + lines: [number, number]; + /** The editor's text when the history was asked for (unsaved edits included). */ + text: string; +} + +export function selectionHistoryInput(data: SelectionHistoryData): EditorInput { + const [from, to] = data.lines; + return { kind: SELECTION_HISTORY_KIND, key: `selectionHistory:${data.path}:${from}-${to}`, path: data.path, data }; +} + +/** + * The selected lines of a text (IDEA): the caret line without a selection, and a + * selection ending at the start of a line leaves that line out. + */ +export function selectedLines( + doc: { lineAt(pos: number): { number: number; from: number } }, + from: number, + to: number, +): [number, number] { + const first = doc.lineAt(from); + const last = doc.lineAt(to); + const end = to > from && last.from === to && last.number > first.number ? last.number - 1 : last.number; + return [first.number - 1, end - 1]; +} + +/** Git › Show History for Selection: the active editor's selected lines. */ +export function showSelectionHistory() { + const editor = activeEditor(); + if (!editor || isVirtualUri(editor.ctx.path)) { + notifications().notify({ group: "vcs", level: "info", title: "Open a file to show the history of its selection" }); + return; + } + const { state } = editor.view; + const { from, to } = state.selection.main; + const data = { path: editor.ctx.path, lines: selectedLines(state.doc, from, to), text: state.doc.toString() }; + void editorService().openInput(selectionHistoryInput(data), { focus: true }); +} diff --git a/src/platform/locales/en.json b/src/platform/locales/en.json index 96fbf6a5d..d56c4edfc 100644 --- a/src/platform/locales/en.json +++ b/src/platform/locales/en.json @@ -340,6 +340,7 @@ "menu.vcs.addToVcs": "Add to VCS", "menu.vcs.compareWithBranch": "Compare with Branch or Revision…", "menu.vcs.showHistory": "Show History", + "menu.vcs.showHistoryForSelection": "Show History for Selection", "menu.build": "Build", "menu.build.buildProject": "Build Project", "menu.build.buildModule": "Build Module", diff --git a/src/platform/locales/ko.json b/src/platform/locales/ko.json index 761284fbd..e6e1951cf 100644 --- a/src/platform/locales/ko.json +++ b/src/platform/locales/ko.json @@ -340,6 +340,7 @@ "menu.vcs.addToVcs": "VCS에 추가", "menu.vcs.compareWithBranch": "브랜치 또는 리비전과 비교…", "menu.vcs.showHistory": "기록 표시", + "menu.vcs.showHistoryForSelection": "선택 영역 기록 표시", "menu.build": "빌드", "menu.build.buildProject": "프로젝트 빌드", "menu.build.buildModule": "모듈 빌드", diff --git a/src/platform/locales/text/ko.json b/src/platform/locales/text/ko.json index 3a0d1445f..cb982bc84 100644 --- a/src/platform/locales/text/ko.json +++ b/src/platform/locales/text/ko.json @@ -2026,6 +2026,12 @@ "Show Hidden Tabs": "숨겨진 탭 표시", "Show History": "기록 표시", "Show History for File": "파일 기록 표시", + "Show History for Selection": "선택 영역 기록 표시", + "History for Selection": "선택 영역 기록", + "Open a file to show the history of its selection": "선택 영역의 기록을 보려면 파일을 여세요", + "No commits changed these lines": "이 줄을 변경한 커밋이 없습니다", + "These lines are not committed yet": "이 줄은 아직 커밋되지 않았습니다", + "Not Committed Yet": "아직 커밋되지 않음", "Show Ignored": "무시된 항목 표시", "Show Infos and Hints": "정보 및 힌트 표시", "Show Local History": "로컬 기록 표시", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 37e46c865..8dbfff8c8 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -2030,6 +2030,12 @@ "Show Hidden Tabs": "显示隐藏的标签页", "Show History": "显示历史记录", "Show History for File": "显示文件的历史记录", + "Show History for Selection": "显示所选内容的历史记录", + "History for Selection": "所选内容的历史记录", + "Open a file to show the history of its selection": "打开文件以显示所选内容的历史记录", + "No commits changed these lines": "没有提交更改过这些行", + "These lines are not committed yet": "这些行尚未提交", + "Not Committed Yet": "尚未提交", "Show Ignored": "显示已忽略", "Show Infos and Hints": "显示信息和提示", "Show Local History": "显示本地历史记录", diff --git a/src/platform/locales/zh.json b/src/platform/locales/zh.json index 5f06697ce..d0d9cd10d 100644 --- a/src/platform/locales/zh.json +++ b/src/platform/locales/zh.json @@ -340,6 +340,7 @@ "menu.vcs.addToVcs": "添加到 VCS", "menu.vcs.compareWithBranch": "与分支或修订比较…", "menu.vcs.showHistory": "显示历史记录", + "menu.vcs.showHistoryForSelection": "显示所选内容的历史记录", "menu.build": "构建", "menu.build.buildProject": "构建项目", "menu.build.buildModule": "构建模块",