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": "构建模块",