feat(diff): open editor at cursor line

- extend OpenExternalEditor event to carry an optional line number
- use the selection cursor index into the flat line list to find the
  exact line, using the same index space as selected_lines()
- add editor_goto_style() to map editor name to the correct
  goto-line argument style (hx: file:line, code: --goto file:line,
  vi/vim/nvim/emacs/nano/etc: +line file)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Code 2.1.143 (Claude Code)
Signed-Off-By: Paal Øye-Strømme <paal.o.eye@gmail.com>
This commit is contained in:
Paal Øye-Strømme 2026-05-17 18:42:20 +02:00
parent 8619c07f3f
commit 5e304f6ea5
No known key found for this signature in database
7 changed files with 94 additions and 13 deletions

View file

@ -119,6 +119,7 @@ pub struct App {
// "Flags"
requires_redraw: Cell<bool>,
file_to_open: Option<String>,
line_to_open: Option<u32>,
}
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) => {

View file

@ -888,10 +888,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,7 +1077,7 @@ 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"
));
}

View file

@ -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);
}

View file

@ -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)

View file

@ -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

View file

@ -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<u32>) -> 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<u32>,
) -> 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)

View file

@ -121,7 +121,7 @@ pub enum InternalEvent {
///
SelectBranch,
///
OpenExternalEditor(Option<String>),
OpenExternalEditor(Option<String>, Option<u32>),
///
Push(String, PushType, bool, bool),
///