feat(vcs): show the history of the selected lines (#1266)

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 <[email protected]>
Цей коміт міститься в:
Lee Dong Geon authored and GitHub committed 2026-11-05 00:07:44 +08:00
1 джерело f3afe69ca2
коміт 94490e2b37
21 files changed
+861

No files matched your search

+27
Переглянути файл
@@ -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<String>,
lines: (u32, u32),
) -> IdeResult<BlockHistory> {
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<PathBuf>, query: LogQuery) -> IdeResult<Vec<Commit>> {
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
+1
Переглянути файл
@@ -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"
+227
Переглянути файл
@@ -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<BlockHistory> {
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, &current), 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<BlockRevision> {
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<BlockRevision> {
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::<f64>().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::<u32>().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\x1f[email protected]\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\x1f[email protected]\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");
}
}
+44
Переглянути файл
@@ -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<BlockRevision>,
}
/// 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<TextEncoding>,
cancel: &CancellationToken,
) -> IdeResult<BlockHistory> {
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
+124
Переглянути файл
@@ -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", "[email protected]"), ("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);
}
+1
Переглянути файл
@@ -247,6 +247,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+15
Переглянути файл
@@ -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<String>,
lines: (u32, u32),
) -> IdeResult<BlockHistory> {
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(
+1
Переглянути файл
@@ -670,6 +670,7 @@ pub fn spec(os: Os) -> Vec<Entry> {
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…"),
+30
Переглянути файл
@@ -469,6 +469,11 @@ export const commands = {
vcsLineHunks: (path: string) => typedError<LineHunk[], IdeError>(__TAURI_INVOKE("vcs_line_hunks", { path })),
vcsFileAt: (path: string, rev: string | null) => typedError<string | null, IdeError>(__TAURI_INVOKE("vcs_file_at", { path, rev })),
vcsBlame: (path: string, text: string | null, revision: string | null) => typedError<BlameLine[], IdeError>(__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<BlockHistory, IdeError>(__TAURI_INVOKE("vcs_block_history", { request, path, text, lines })),
vcsLog: (repo: string | null, query: LogQuery) => typedError<Commit[], IdeError>(__TAURI_INVOKE("vcs_log", { repo, query })),
vcsCommitFiles: (repo: string | null, commit: string) => typedError<ChangedFile[], IdeError>(__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,
+1
Переглянути файл
@@ -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…" },
+18
Переглянути файл
@@ -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);
}
+154
Переглянути файл
@@ -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<BlockHistory | null>(null);
const [error, setError] = useState<string | null>(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 = (
<div className={layout.message}>
<T>Select a revision</T>
</div>
);
else {
const [left, right] = sides(current);
detail = (
<DiffEditor
key={current.kind === "local" ? "local" : current.revision.id}
input={diffInput({ path: data.path, left, right })}
/>
);
}
return (
<div className={layout.root}>
<div className={layout.sidebar}>
<div className={layout.header}>
<span className={layout.title}>
<T>History for Selection</T>
</span>
<span className={styles.range}>{`${baseName(data.path)}:${from + 1}-${to + 1}`}</span>
</div>
{error ? (
<div className={layout.message}>{error}</div>
) : !history ? (
<div className={layout.message}>
<Spinner size={14} />
</div>
) : (
<List
items={rows}
getKey={(row) => (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={
<span className={layout.message}>
{history.headLines ? tx`No commits changed these lines` : tx`These lines are not committed yet`}
</span>
}
renderItem={(row) =>
row.kind === "local" ? (
<span className={layout.entry}>
<span className={styles.summary}>
<T>Local Changes</T>
</span>
<span className={styles.meta}>
<T>Not Committed Yet</T>
</span>
</span>
) : (
<span className={layout.entry}>
<span className={styles.summary}>{row.revision.summary}</span>
<span className={styles.meta}>
{`${row.revision.shortId} · ${row.revision.author} · ${formatTime(row.revision.timeMs)}`}
</span>
</span>
)
}
/>
)}
</div>
<div className={layout.diff}>{detail}</div>
</div>
);
}
+136
Переглянути файл
@@ -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<typeof import("@/platform/ipc")>();
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 } } };
}) => (
<div data-testid="diff">
{`${input.data.left.label}: ${input.data.left.text}| ${input.data.right.label}: ${input.data.right.text}`}
</div>
),
}));
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: "[email protected]",
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(<SelectionHistoryEditor input={selectionHistoryInput(data)} />);
}
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]);
});
});
+10
Переглянути файл
@@ -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",
+10
Переглянути файл
@@ -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,
+47
Переглянути файл
@@ -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 });
}
+1
Переглянути файл
@@ -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",
+1
Переглянути файл
@@ -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": "모듈 빌드",
+6
Переглянути файл
@@ -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": "로컬 기록 표시",
+6
Переглянути файл
@@ -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": "显示本地历史记录",
+1
Переглянути файл
@@ -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": "构建模块",