diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bdad9ba8f..1c9038c446 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed * use [tombi](https://github.com/tombi-toml/tombi) for all toml file formatting * open the external editor from the status diff view [[@WaterWhisperer](https://github.com/WaterWhisperer)] ([#2805](https://github.com/gitui-org/gitui/issues/2805)) +* open external editor at the cursor line from any diff view (status, log, blame), with editor-specific goto syntax (Helix, VS Code, vi-style) [@paaloeye](https://github.com/paaloeye) ([#2952](https://github.com/gitui-org/gitui/pull/2952)) ### Fixes * crash when opening submodule ([#2895](https://github.com/gitui-org/gitui/issues/2895)) diff --git a/src/app.rs b/src/app.rs index 8626aa3b8e..99acba802b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -119,6 +119,7 @@ pub struct App { // "Flags" requires_redraw: Cell, file_to_open: Option, + line_to_open: Option, } pub struct Environment { @@ -244,6 +245,7 @@ impl App { key_config: env.key_config, requires_redraw: Cell::new(false), file_to_open: None, + line_to_open: None, repo: env.repo, repo_path_text, popup_stack: PopupStack::default(), @@ -376,6 +378,7 @@ impl App { ExternalEditorPopup::open_file_in_editor( &self.repo.borrow(), Path::new(&path), + self.line_to_open.take(), ) } else { let changes = @@ -815,10 +818,11 @@ impl App { flags.insert(NeedsUpdate::ALL); } } - InternalEvent::OpenExternalEditor(path) => { + InternalEvent::OpenExternalEditor(path, line) => { self.input.set_polling(false); self.external_editor_popup.show()?; self.file_to_open = path; + self.line_to_open = line; flags.insert(NeedsUpdate::COMMANDS); } InternalEvent::Push(branch, push_type, force, delete) => { diff --git a/src/components/diff.rs b/src/components/diff.rs index 04779caada..a6d6ec81b8 100644 --- a/src/components/diff.rs +++ b/src/components/diff.rs @@ -153,7 +153,7 @@ impl DiffComponent { } /// const fn can_edit_file(&self) -> bool { - !self.is_immutable && !self.current.path.is_empty() + !self.current.path.is_empty() } /// pub fn clear(&mut self, pending: bool) { @@ -773,12 +773,13 @@ impl Component for DiffComponent { .hidden(), ); + out.push(CommandInfo::new( + strings::commands::edit_item(&self.key_config), + self.can_edit_file(), + self.focused() && self.can_edit_file(), + )); + if !self.is_immutable { - out.push(CommandInfo::new( - strings::commands::edit_item(&self.key_config), - self.can_edit_file(), - self.focused() && self.can_edit_file(), - )); out.push(CommandInfo::new( strings::commands::diff_hunk_remove(&self.key_config), self.selected_hunk.is_some(), @@ -888,10 +889,32 @@ impl Component for DiffComponent { } else if key_match(e, self.key_config.keys.edit_file) && self.can_edit_file() { + let line = self.diff.as_ref().and_then(|d| { + let cursor = self.selection.get_start(); + // walk the flat line list to the cursor position, + // same index space as selected_lines() + d.hunks + .iter() + .flat_map(|h| h.lines.iter()) + .nth(cursor) + .and_then(|l| l.position.new_lineno) + .or_else(|| { + // cursor is on a deletion — use old_lineno + // as a best-effort fallback + d.hunks + .iter() + .flat_map(|h| h.lines.iter()) + .nth(cursor) + .and_then(|l| { + l.position.old_lineno + }) + }) + }); self.queue.push( - InternalEvent::OpenExternalEditor(Some( - self.current.path.clone(), - )), + InternalEvent::OpenExternalEditor( + Some(self.current.path.clone()), + line, + ), ); Ok(EventState::Consumed) } else if key_match( @@ -1055,8 +1078,84 @@ mod tests { let event = env.queue.pop(); assert!(matches!( event, - Some(InternalEvent::OpenExternalEditor(Some(path))) + Some(InternalEvent::OpenExternalEditor(Some(path), _)) if path == "src/main.rs" )); } + + #[test] + fn diff_component_opens_editor_at_cursor_line() { + use asyncgit::sync::diff::{DiffLinePosition, Hunk}; + use asyncgit::FileDiff; + + let env = Environment::test_env(); + let mut comp = DiffComponent::new(&env, false); + comp.focus(true); + comp.current.path = String::from("src/main.rs"); + + // build a minimal FileDiff: one hunk with a header line and + // two content lines at known new_lineno values + let hunk = Hunk { + header_hash: 0, + lines: vec![ + DiffLine { + content: "@@ -1,2 +1,2 @@".into(), + line_type: DiffLineType::Header, + position: DiffLinePosition { + old_lineno: None, + new_lineno: None, + }, + }, + DiffLine { + content: "context".into(), + line_type: DiffLineType::None, + position: DiffLinePosition { + old_lineno: Some(1), + new_lineno: Some(1), + }, + }, + DiffLine { + content: "added line".into(), + line_type: DiffLineType::Add, + position: DiffLinePosition { + old_lineno: None, + new_lineno: Some(2), + }, + }, + ], + }; + let file_diff = FileDiff { + hunks: vec![hunk], + lines: 3, + untracked: false, + sizes: (0, 0), + size_delta: 0, + }; + comp.update(String::from("src/main.rs"), false, file_diff); + + // move cursor to the Add line (index 2 in the flat list) + comp.move_selection(ScrollType::Down); + comp.move_selection(ScrollType::Down); + + let event = Event::Key(KeyEvent::new( + KeyCode::Char('e'), + KeyModifiers::empty(), + )); + assert!(matches!( + comp.event(&event).unwrap(), + EventState::Consumed + )); + + let queued = env.queue.pop(); + assert!( + matches!( + queued, + Some(InternalEvent::OpenExternalEditor( + Some(_), + Some(2) + )) + ), + "expected OpenExternalEditor with line Some(2)" + ); + } } diff --git a/src/components/revision_files.rs b/src/components/revision_files.rs index 1e15ec086f..28ece1a0a8 100644 --- a/src/components/revision_files.rs +++ b/src/components/revision_files.rs @@ -524,7 +524,10 @@ impl Component for RevisionFilesComponent { // not altering a file inside a revision here self.queue.push(InternalEvent::TabSwitchStatus); self.queue.push( - InternalEvent::OpenExternalEditor(Some(file)), + InternalEvent::OpenExternalEditor( + Some(file), + None, + ), ); return Ok(EventState::Consumed); } diff --git a/src/components/status_tree.rs b/src/components/status_tree.rs index ac4fc9f6a8..3a6c2ab70a 100644 --- a/src/components/status_tree.rs +++ b/src/components/status_tree.rs @@ -509,9 +509,10 @@ impl Component for StatusTreeComponent { { if let Some(status_item) = self.selection_file() { self.queue.push( - InternalEvent::OpenExternalEditor(Some( - status_item.path, - )), + InternalEvent::OpenExternalEditor( + Some(status_item.path), + None, + ), ); } Ok(EventState::Consumed) diff --git a/src/popups/commit.rs b/src/popups/commit.rs index b5dff7677c..da0f8bff6e 100644 --- a/src/popups/commit.rs +++ b/src/popups/commit.rs @@ -189,6 +189,7 @@ impl CommitPopup { ExternalEditorPopup::open_file_in_editor( &self.repo.borrow(), &file_path, + None, )?; let mut message = String::new(); @@ -587,7 +588,9 @@ impl Component for CommitPopup { self.key_config.keys.open_commit_editor, ) { self.queue.push( - InternalEvent::OpenExternalEditor(None), + InternalEvent::OpenExternalEditor( + None, None, + ), ); self.hide(); true diff --git a/src/popups/externaleditor.rs b/src/popups/externaleditor.rs index 52a7327b13..cd699d0599 100644 --- a/src/popups/externaleditor.rs +++ b/src/popups/externaleditor.rs @@ -27,6 +27,28 @@ use scopeguard::defer; use std::ffi::OsStr; use std::{env, io, path::Path, process::Command}; +enum EditorGoto { + None, + PlusLine(u32), + FileColon(u32), + GotoFlag(u32), +} + +fn editor_goto_style(command: &str, line: Option) -> EditorGoto { + let Some(ln) = line else { + return EditorGoto::None; + }; + let cmd = command.to_lowercase(); + if cmd.ends_with("hx") { + EditorGoto::FileColon(ln) + } else if cmd.ends_with("code") || cmd.ends_with("code-insiders") + { + EditorGoto::GotoFlag(ln) + } else { + EditorGoto::PlusLine(ln) + } +} + /// pub struct ExternalEditorPopup { visible: bool, @@ -44,10 +66,11 @@ impl ExternalEditorPopup { } } - /// opens file at given `path` in an available editor + /// opens file at given `path` in an available editor, optionally at `line` pub fn open_file_in_editor( repo: &RepoPath, path: &Path, + line: Option, ) -> Result<()> { let work_dir = repo_work_dir(repo)?; @@ -107,7 +130,32 @@ impl ExternalEditorPopup { let mut args: Vec<&OsStr> = remainder.map(OsStr::new).collect(); - args.push(path.as_os_str()); + let line_arg: String; + let helix_path: std::path::PathBuf; + match editor_goto_style(&command, line) { + EditorGoto::None => { + args.push(path.as_os_str()); + } + EditorGoto::PlusLine(ln) => { + line_arg = format!("+{ln}"); + args.push(OsStr::new(&line_arg)); + args.push(path.as_os_str()); + } + EditorGoto::FileColon(ln) => { + helix_path = path.with_file_name(format!( + "{}:{ln}", + path.file_name() + .unwrap_or_default() + .to_string_lossy(), + )); + args.push(helix_path.as_os_str()); + } + EditorGoto::GotoFlag(ln) => { + args.push(OsStr::new("--goto")); + line_arg = format!("{}:{ln}", path.to_string_lossy()); + args.push(OsStr::new(&line_arg)); + } + } Command::new(command.clone()) .current_dir(work_dir) diff --git a/src/queue.rs b/src/queue.rs index 5cdfe3cef0..c49e0ad546 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -121,7 +121,7 @@ pub enum InternalEvent { /// SelectBranch, /// - OpenExternalEditor(Option), + OpenExternalEditor(Option, Option), /// Push(String, PushType, bool, bool), ///