From 381142fb2167b94847e1211044c5c03bc56799f0 Mon Sep 17 00:00:00 2001 From: Emily M Klassen Date: Mon, 8 Sep 2025 20:55:38 -0700 Subject: [PATCH 1/7] feat: allow specifying diff commit with env var --- helix-vcs/src/git.rs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/helix-vcs/src/git.rs b/helix-vcs/src/git.rs index 133b77dab0bf..92b5294a56ec 100644 --- a/helix-vcs/src/git.rs +++ b/helix-vcs/src/git.rs @@ -38,7 +38,7 @@ pub fn get_diff_base(file: &Path, trust_full: bool) -> Result> { let repo = open_repo(repo_dir, trust_full) .context("failed to open git repo")? .to_thread_local(); - let head = repo.head_commit()?; + let head = get_head_or_override(&repo)?; let file_oid = find_file_in_commit(&repo, &head, &file)?; let file_object = repo.find_object(file_oid)?; @@ -65,6 +65,14 @@ pub fn get_diff_base(file: &Path, trust_full: bool) -> Result> { } } +fn get_head_or_override(repo: &Repository) -> Result, anyhow::Error> { + let head_commit = match std::env::var("HELIX_GIT_HEAD") { + Ok(id) => repo.find_commit(id.parse::()?)?, + Err(_) => repo.head_commit()?, + }; + Ok(head_commit) +} + pub fn get_current_head_name(file: &Path, trust_full: bool) -> Result>>> { debug_assert!(!file.exists() || file.is_file()); debug_assert!(file.is_absolute()); @@ -75,7 +83,7 @@ pub fn get_current_head_name(file: &Path, trust_full: bool) -> Result reference.name().shorten().to_string(), @@ -151,8 +159,15 @@ fn status(repo: &Repository, f: impl Fn(Result) -> bool) -> Result<( .ok_or_else(|| anyhow::anyhow!("working tree not found"))? .to_path_buf(); - let status_platform = repo - .status(gix::progress::Discard)? + let mut status_platform = repo.status(gix::progress::Discard)?; + if let Ok(diff_id) = std::env::var("HELIX_GIT_HEAD") { + let diff_commit = repo.find_commit(diff_id.parse::()?)?; + let diff_tree = diff_commit.tree_id()?; + status_platform = status_platform.index(gix::worktree::IndexPersistedOrInMemory::InMemory( + repo.index_from_tree(gix::hash::oid::from_bytes_unchecked(diff_tree.as_bytes()))?, + )); + }; + status_platform = status_platform // Here we discard the `status.showUntrackedFiles` config, as it makes little sense in // our case to not list new (untracked) files. We could have respected this config // if the default value weren't `Collapsed` though, as this default value would render From 3b9c5882e4c33f1e5f25ba2799ec8232faf718c5 Mon Sep 17 00:00:00 2001 From: Emily M Klassen Date: Tue, 9 Sep 2025 09:17:41 -0700 Subject: [PATCH 2/7] feat: differentiate added vs untracked files --- helix-term/src/commands.rs | 2 ++ helix-vcs/src/git.rs | 36 ++++++++++++++++++++++++++++-------- helix-vcs/src/status.rs | 3 +++ 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index c9560f33f830..39ab8a56832f 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -3505,6 +3505,7 @@ fn changed_file_picker(cx: &mut Context) { let columns = [ PickerColumn::new("change", |change: &FileChange, data: &FileChangeData| { match change { + FileChange::Added { .. } => Span::styled("+ added", data.style_untracked), FileChange::Untracked { .. } => Span::styled("+ untracked", data.style_untracked), FileChange::Modified { .. } => Span::styled("~ modified", data.style_modified), FileChange::Conflict { .. } => Span::styled("x conflict", data.style_conflict), @@ -3521,6 +3522,7 @@ fn changed_file_picker(cx: &mut Context) { .to_string() }; match change { + FileChange::Added { path } => display_path(path), FileChange::Untracked { path } => display_path(path), FileChange::Modified { path } => display_path(path), FileChange::Conflict { path } => display_path(path), diff --git a/helix-vcs/src/git.rs b/helix-vcs/src/git.rs index 92b5294a56ec..7a746a16ced2 100644 --- a/helix-vcs/src/git.rs +++ b/helix-vcs/src/git.rs @@ -39,6 +39,7 @@ pub fn get_diff_base(file: &Path, trust_full: bool) -> Result> { .context("failed to open git repo")? .to_thread_local(); let head = get_head_or_override(&repo)?; + log::debug!("git got head {:?}", head); let file_oid = find_file_in_commit(&repo, &head, &file)?; let file_object = repo.find_object(file_oid)?; @@ -160,12 +161,16 @@ fn status(repo: &Repository, f: impl Fn(Result) -> bool) -> Result<( .to_path_buf(); let mut status_platform = repo.status(gix::progress::Discard)?; - if let Ok(diff_id) = std::env::var("HELIX_GIT_HEAD") { - let diff_commit = repo.find_commit(diff_id.parse::()?)?; - let diff_tree = diff_commit.tree_id()?; - status_platform = status_platform.index(gix::worktree::IndexPersistedOrInMemory::InMemory( - repo.index_from_tree(gix::hash::oid::from_bytes_unchecked(diff_tree.as_bytes()))?, - )); + let head_commit = get_head_or_override(repo)?; + let is_override = std::env::var("HELIX_GIT_HEAD").is_ok(); + if is_override { + // repo.head_tree_id_or_empty() + let diff_tree = head_commit.tree_id()?; + status_platform = status_platform + .index(gix::worktree::IndexPersistedOrInMemory::InMemory( + repo.index_from_tree(gix::hash::oid::from_bytes_unchecked(diff_tree.as_bytes()))?, + )) + .head_tree(repo.head_tree_id_or_empty()?); }; status_platform = status_platform // Here we discard the `status.showUntrackedFiles` config, as it makes little sense in @@ -185,6 +190,7 @@ fn status(repo: &Repository, f: impl Fn(Result) -> bool) -> Result<( let empty_patterns = vec![]; let status_iter = status_platform.into_index_worktree_iter(empty_patterns)?; + let head_tree = repo.head_tree()?; for item in status_iter { let Ok(item) = item.map_err(|err| f(Err(err.into()))) else { @@ -212,8 +218,22 @@ fn status(repo: &Repository, f: impl Fn(Result) -> bool) -> Result<( } } Item::DirectoryContents { entry, .. } if entry.status == Status::Untracked => { - FileChange::Untracked { - path: work_dir.join(entry.rela_path.to_path()?), + let rela_path = entry.rela_path.to_path()?; + let path = work_dir.join(rela_path); + // if is_override { + // // let found = head_tree.find_entry(entry.rela_path); + // let found2 = head_tree.lookup_entry_by_path(path.clone()); + // log::debug!( + // "git find entry {:?} {:?}", + // entry.rela_path, + // // found.is_some(), + // found2 + // ); + // } + if is_override && head_tree.lookup_entry_by_path(rela_path)?.is_some() { + FileChange::Added { path } + } else { + FileChange::Untracked { path } } } Item::Rewrite { diff --git a/helix-vcs/src/status.rs b/helix-vcs/src/status.rs index 0240cad1a020..31db7f871e5d 100644 --- a/helix-vcs/src/status.rs +++ b/helix-vcs/src/status.rs @@ -2,6 +2,8 @@ use std::path::{Path, PathBuf}; /// States for a file having been changed. pub enum FileChange { + /// File has been added. + Added { path: PathBuf }, /// Not tracked by the VCS. Untracked { path: PathBuf }, /// File has been modified. @@ -20,6 +22,7 @@ pub enum FileChange { impl FileChange { pub fn path(&self) -> &Path { match self { + Self::Added { path } => path, Self::Untracked { path } => path, Self::Modified { path } => path, Self::Conflict { path } => path, From fc61803f3d99d4a7ad7ccb506355d4fdd115d8dd Mon Sep 17 00:00:00 2001 From: Emily M Klassen Date: Fri, 3 Oct 2025 19:57:08 -0700 Subject: [PATCH 3/7] feat: automatically select first changed range in diff picker --- helix-term/src/commands.rs | 21 ++++++++++++++++- helix-term/src/ui/picker.rs | 47 +++++++++++++++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 39ab8a56832f..7bb4e85195bc 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -3559,7 +3559,26 @@ fn changed_file_picker(cx: &mut Context) { } }, ) - .with_preview(|_editor, meta| Some((meta.path().into(), None))); + .with_preview(|_editor, meta| Some((meta.path().into(), None))) + .with_range(|_editor, doc| { + let path = match doc.path() { + None => { + return None; + } + Some(path) => path, + }; + let mut range: Option<(usize, usize)> = None; + if let Some(diff_handle) = doc.diff_handle() { + let diff = diff_handle.load(); + + if let Some(n) = diff.next_hunk(0) { + let hunk = diff.nth_hunk(n); + let hrange = hunk_range(hunk, doc.text().slice(..)); + range = Some(hrange.line_range(doc.text().slice(..))); + } + } + Some((path.as_path().into(), range)) + }); let injector = picker.injector(); let trust_full = cx diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index 81ad3df310f9..ca581413cdcf 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -266,6 +266,8 @@ pub struct Picker { read_buffer: Vec, /// Given an item in the picker, return the file path and line number to display. file_fn: Option>, + /// Given an item in the picker, return the file path and line number to display. + range_fn: Option>, /// An event handler for syntax highlighting the currently previewed file. preview_highlight_handler: Sender>, dynamic_query_handler: Option>, @@ -392,6 +394,7 @@ impl Picker { preview_cache: HashMap::new(), read_buffer: Vec::with_capacity(1024), file_fn: None, + range_fn: None, preview_highlight_handler: PreviewHighlightHandler::::default().spawn(), dynamic_query_handler: None, } @@ -424,6 +427,14 @@ impl Picker { self } + pub fn with_range( + mut self, + preview_fn: impl for<'a> Fn(&'a Editor, &'a Document) -> Option> + 'static, + ) -> Self { + self.range_fn = Some(Box::new(preview_fn)); + self + } + pub fn with_history_register(mut self, history_register: Option) -> Self { self.prompt.with_history_register(history_register); self @@ -580,6 +591,22 @@ impl Picker { } } + fn get_preview_range( + &self, + range: Option<(usize, usize)>, + editor: &Editor, + doc: &Document, + ) -> Option<(usize, usize)> { + if range.is_some() { + return range; + } + if let Some(range_fn) = &self.range_fn { + if let Some((_, range_result)) = range_fn(editor, doc) { + return range_result; + } + } + None + } /// Get (cached) preview for the currently selected item. If a document corresponding /// to the path is already open in the editor, it is used instead. fn get_preview<'picker, 'editor>( @@ -592,7 +619,10 @@ impl Picker { match path_or_id { PathOrId::Path(path) => { if let Some(doc) = editor.document_by_path(path) { - return Some((Preview::EditorDocument(doc), range)); + return Some(( + Preview::EditorDocument(doc), + self.get_preview_range(range, editor, doc), + )); } if self.preview_cache.contains_key(path) { @@ -603,6 +633,12 @@ impl Picker { if matches!(preview, CachedPreview::Document(doc) if doc.syntax().is_none()) { helix_event::send_blocking(&self.preview_highlight_handler, path.clone()); } + if let CachedPreview::Document(doc) = preview { + return Some(( + Preview::Cached(preview), + self.get_preview_range(range, editor, doc), + )); + } return Some((Preview::Cached(preview), range)); } @@ -671,7 +707,14 @@ impl Picker { }) .unwrap_or(CachedPreview::NotFound); self.preview_cache.insert(path.clone(), preview); - Some((Preview::Cached(&self.preview_cache[&path]), range)) + let cached_preview = &self.preview_cache[&path]; + if let CachedPreview::Document(doc) = cached_preview { + return Some(( + Preview::Cached(cached_preview), + self.get_preview_range(range, editor, doc), + )); + } + Some((Preview::Cached(cached_preview), range)) } PathOrId::Id(id) => { let doc = editor.documents.get(&id).unwrap(); From f1dc71bce68646435351f1724a8c5d6395504083 Mon Sep 17 00:00:00 2001 From: Emily M Klassen Date: Wed, 29 Jul 2026 17:56:16 -0700 Subject: [PATCH 4/7] fixup! feat: automatically select first changed range in diff picker --- helix-term/src/commands.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 7bb4e85195bc..e076a839bda7 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -3577,7 +3577,7 @@ fn changed_file_picker(cx: &mut Context) { range = Some(hrange.line_range(doc.text().slice(..))); } } - Some((path.as_path().into(), range)) + Some((path.into(), range)) }); let injector = picker.injector(); From 8c27e99059050c728cc0e06717797277df384e24 Mon Sep 17 00:00:00 2001 From: Emily M Klassen Date: Fri, 3 Oct 2025 18:09:41 -0700 Subject: [PATCH 5/7] feat: add diff gutter to picker preview --- helix-term/src/ui/picker.rs | 50 +++++++++++++++++++++++++++++++++---- helix-view/src/gutter.rs | 3 +++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index ca581413cdcf..253bf05a4cbc 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -47,6 +47,7 @@ use helix_core::{ use helix_view::{ editor::Action, graphics::{CursorKind, Margin, Modifier, Rect}, + gutter, theme::Style, view::ViewPosition, Document, DocumentId, Editor, @@ -697,6 +698,9 @@ impl Picker { path.clone(), ); } + if let Some(diff_base) = editor.diff_providers.get_diff_base(&path) { + doc.set_diff_base(diff_base); + } Ok(CachedPreview::Document(Box::new(doc))) } else { Err(std::io::Error::new( @@ -1022,14 +1026,50 @@ impl Picker { } } - EditorView::doc_diagnostics_highlights_into( - doc, - &cx.editor.theme, - &mut overlay_highlights, - ); + let theme = &cx.editor.theme; + EditorView::doc_diagnostics_highlights_into(doc, theme, &mut overlay_highlights); let mut decorations = DecorationManager::default(); + if doc.diff_handle().is_some() { + let gutter_style = theme.get("ui.gutter"); + let gutter_style_virtual = theme.get("ui.gutter.virtual"); + + let mut gutter = gutter::diff_style(doc, theme); + // equivalent to helix_view::editor::GutterType::Diff.width(_, doc); + let width = 1; + // avoid lots of small allocations by reusing a text buffer for each line + let mut text = String::with_capacity(width); + let gutter_decoration = move |renderer: &mut TextRenderer, pos: LinePos| { + // draw over the margin with width + let x = inner.x - 1; + let y = pos.visual_line; + + let gutter_style = match pos.first_visual_line { + true => gutter_style, + false => gutter_style_virtual, + }; + + if let Some(style) = + gutter(pos.doc_line, false, pos.first_visual_line, &mut text) + { + renderer.set_stringn(x, y, &text, width, gutter_style.patch(style)); + } else { + renderer.set_style( + Rect { + x, + y, + width: width as u16, + height: 1, + }, + gutter_style, + ); + } + text.clear(); + }; + decorations.add_decoration(gutter_decoration); + } + if let Some((start, end)) = range { let style = cx .editor diff --git a/helix-view/src/gutter.rs b/helix-view/src/gutter.rs index 176890ca223e..1387fdce03fe 100644 --- a/helix-view/src/gutter.rs +++ b/helix-view/src/gutter.rs @@ -96,6 +96,9 @@ pub fn diff<'doc>( theme: &Theme, _is_focused: bool, ) -> GutterFn<'doc> { + diff_style(doc, theme) +} +pub fn diff_style<'doc>(doc: &'doc Document, theme: &Theme) -> GutterFn<'doc> { let added = theme.get("diff.plus.gutter"); let deleted = theme.get("diff.minus.gutter"); let modified = theme.get("diff.delta.gutter"); From de0e89976747b493c44592e3c533bde4c3042717 Mon Sep 17 00:00:00 2001 From: Emily M Klassen Date: Wed, 29 Jul 2026 17:53:27 -0700 Subject: [PATCH 6/7] fixup! feat: add diff gutter to picker preview --- helix-term/src/ui/picker.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index 253bf05a4cbc..4f212dffc71c 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -698,7 +698,16 @@ impl Picker { path.clone(), ); } - if let Some(diff_base) = editor.diff_providers.get_diff_base(&path) { + let trust_full = editor + .workspace_trust + .query( + doc.workspace_root(), + helix_loader::workspace_trust::TrustQuery::Git, + ) + .is_trusted(); + if let Some(diff_base) = + editor.diff_providers.get_diff_base(&path, trust_full) + { doc.set_diff_base(diff_base); } Ok(CachedPreview::Document(Box::new(doc))) From 2db8ee28ecf2347efdd94a4ae123aa711cf6c8b5 Mon Sep 17 00:00:00 2001 From: Emily M Klassen Date: Sat, 4 Oct 2025 22:12:58 -0700 Subject: [PATCH 7/7] feat: support renames in gutter --- helix-vcs/src/git.rs | 53 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/helix-vcs/src/git.rs b/helix-vcs/src/git.rs index 7a746a16ced2..0619385b8590 100644 --- a/helix-vcs/src/git.rs +++ b/helix-vcs/src/git.rs @@ -1,6 +1,7 @@ use anyhow::{bail, Context, Result}; use arc_swap::ArcSwap; use gix::filter::plumbing::driver::apply::Delay; +use std::convert::Infallible; use std::io::Read; use std::path::Path; use std::sync::Arc; @@ -259,15 +260,49 @@ fn find_file_in_commit(repo: &Repository, commit: &Commit, file: &Path) -> Resul let repo_dir = repo.workdir().context("repo has no worktree")?; let rel_path = file.strip_prefix(repo_dir)?; let tree = commit.tree()?; - let tree_entry = tree - .lookup_entry_by_path(rel_path)? - .context("file is untracked")?; - match tree_entry.mode().kind() { - // not a file, everything is new, do not show diff - mode @ (EntryKind::Tree | EntryKind::Commit | EntryKind::Link) => { - bail!("entry at {} is not a file but a {mode:?}", file.display()) + let mut err = anyhow::Error::msg("file is untracked"); + match tree.lookup_entry_by_path(rel_path) { + Ok(Some(tree_entry)) => { + return match tree_entry.mode().kind() { + // not a file, everything is new, do not show diff + mode @ (EntryKind::Tree | EntryKind::Commit | EntryKind::Link) => { + bail!("entry at {} is not a file but a {mode:?}", file.display()) + } + // found a file + EntryKind::Blob | EntryKind::BlobExecutable => Ok(tree_entry.object_id()), + }; } - // found a file - EntryKind::Blob | EntryKind::BlobExecutable => Ok(tree_entry.object_id()), + Ok(None) => {} + Err(error) => err = error.into(), } + let index = repo.index()?; + let file_path = gix::path::try_into_bstr(rel_path)?; + let rewrites = Rewrites { + copies: None, + percentage: Some(0.5), + limit: 1000, + ..Default::default() + }; + let mut result: Result = Err(err); + repo.tree_index_status( + &commit.tree_id()?, + &index, + None, + gix::status::tree_index::TrackRenames::Given(rewrites), + |c, _, _| -> Result<_, Infallible> { + if let gix::diff::index::ChangeRef::Rewrite { + source_id, + location, + .. + } = c + { + if location == file_path { + result = Ok(source_id.into_owned()); + return Ok(gix::diff::index::Action::Break(())); + } + } + Ok(gix::diff::index::Action::Continue(())) + }, + )?; + result }