mirror of
https://github.com/gitui-org/gitui
synced 2026-05-23 17:08:21 +00:00
Merge 9026941a66 into 8619c07f3f
This commit is contained in:
commit
db99af8449
8 changed files with 178 additions and 19 deletions
|
|
@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
### Changed
|
### Changed
|
||||||
* use [tombi](https://github.com/tombi-toml/tombi) for all toml file formatting
|
* 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 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
|
### Fixes
|
||||||
* crash when opening submodule ([#2895](https://github.com/gitui-org/gitui/issues/2895))
|
* crash when opening submodule ([#2895](https://github.com/gitui-org/gitui/issues/2895))
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,7 @@ pub struct App {
|
||||||
// "Flags"
|
// "Flags"
|
||||||
requires_redraw: Cell<bool>,
|
requires_redraw: Cell<bool>,
|
||||||
file_to_open: Option<String>,
|
file_to_open: Option<String>,
|
||||||
|
line_to_open: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Environment {
|
pub struct Environment {
|
||||||
|
|
@ -244,6 +245,7 @@ impl App {
|
||||||
key_config: env.key_config,
|
key_config: env.key_config,
|
||||||
requires_redraw: Cell::new(false),
|
requires_redraw: Cell::new(false),
|
||||||
file_to_open: None,
|
file_to_open: None,
|
||||||
|
line_to_open: None,
|
||||||
repo: env.repo,
|
repo: env.repo,
|
||||||
repo_path_text,
|
repo_path_text,
|
||||||
popup_stack: PopupStack::default(),
|
popup_stack: PopupStack::default(),
|
||||||
|
|
@ -376,6 +378,7 @@ impl App {
|
||||||
ExternalEditorPopup::open_file_in_editor(
|
ExternalEditorPopup::open_file_in_editor(
|
||||||
&self.repo.borrow(),
|
&self.repo.borrow(),
|
||||||
Path::new(&path),
|
Path::new(&path),
|
||||||
|
self.line_to_open.take(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
let changes =
|
let changes =
|
||||||
|
|
@ -815,10 +818,11 @@ impl App {
|
||||||
flags.insert(NeedsUpdate::ALL);
|
flags.insert(NeedsUpdate::ALL);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
InternalEvent::OpenExternalEditor(path) => {
|
InternalEvent::OpenExternalEditor(path, line) => {
|
||||||
self.input.set_polling(false);
|
self.input.set_polling(false);
|
||||||
self.external_editor_popup.show()?;
|
self.external_editor_popup.show()?;
|
||||||
self.file_to_open = path;
|
self.file_to_open = path;
|
||||||
|
self.line_to_open = line;
|
||||||
flags.insert(NeedsUpdate::COMMANDS);
|
flags.insert(NeedsUpdate::COMMANDS);
|
||||||
}
|
}
|
||||||
InternalEvent::Push(branch, push_type, force, delete) => {
|
InternalEvent::Push(branch, push_type, force, delete) => {
|
||||||
|
|
|
||||||
|
|
@ -153,7 +153,7 @@ impl DiffComponent {
|
||||||
}
|
}
|
||||||
///
|
///
|
||||||
const fn can_edit_file(&self) -> bool {
|
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) {
|
pub fn clear(&mut self, pending: bool) {
|
||||||
|
|
@ -773,12 +773,13 @@ impl Component for DiffComponent {
|
||||||
.hidden(),
|
.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 {
|
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(
|
out.push(CommandInfo::new(
|
||||||
strings::commands::diff_hunk_remove(&self.key_config),
|
strings::commands::diff_hunk_remove(&self.key_config),
|
||||||
self.selected_hunk.is_some(),
|
self.selected_hunk.is_some(),
|
||||||
|
|
@ -888,10 +889,32 @@ impl Component for DiffComponent {
|
||||||
} else if key_match(e, self.key_config.keys.edit_file)
|
} else if key_match(e, self.key_config.keys.edit_file)
|
||||||
&& self.can_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(
|
self.queue.push(
|
||||||
InternalEvent::OpenExternalEditor(Some(
|
InternalEvent::OpenExternalEditor(
|
||||||
self.current.path.clone(),
|
Some(self.current.path.clone()),
|
||||||
)),
|
line,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
Ok(EventState::Consumed)
|
Ok(EventState::Consumed)
|
||||||
} else if key_match(
|
} else if key_match(
|
||||||
|
|
@ -1055,8 +1078,84 @@ mod tests {
|
||||||
let event = env.queue.pop();
|
let event = env.queue.pop();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
event,
|
event,
|
||||||
Some(InternalEvent::OpenExternalEditor(Some(path)))
|
Some(InternalEvent::OpenExternalEditor(Some(path), _))
|
||||||
if path == "src/main.rs"
|
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)"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -524,7 +524,10 @@ impl Component for RevisionFilesComponent {
|
||||||
// not altering a file inside a revision here
|
// not altering a file inside a revision here
|
||||||
self.queue.push(InternalEvent::TabSwitchStatus);
|
self.queue.push(InternalEvent::TabSwitchStatus);
|
||||||
self.queue.push(
|
self.queue.push(
|
||||||
InternalEvent::OpenExternalEditor(Some(file)),
|
InternalEvent::OpenExternalEditor(
|
||||||
|
Some(file),
|
||||||
|
None,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return Ok(EventState::Consumed);
|
return Ok(EventState::Consumed);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -509,9 +509,10 @@ impl Component for StatusTreeComponent {
|
||||||
{
|
{
|
||||||
if let Some(status_item) = self.selection_file() {
|
if let Some(status_item) = self.selection_file() {
|
||||||
self.queue.push(
|
self.queue.push(
|
||||||
InternalEvent::OpenExternalEditor(Some(
|
InternalEvent::OpenExternalEditor(
|
||||||
status_item.path,
|
Some(status_item.path),
|
||||||
)),
|
None,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Ok(EventState::Consumed)
|
Ok(EventState::Consumed)
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,7 @@ impl CommitPopup {
|
||||||
ExternalEditorPopup::open_file_in_editor(
|
ExternalEditorPopup::open_file_in_editor(
|
||||||
&self.repo.borrow(),
|
&self.repo.borrow(),
|
||||||
&file_path,
|
&file_path,
|
||||||
|
None,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let mut message = String::new();
|
let mut message = String::new();
|
||||||
|
|
@ -587,7 +588,9 @@ impl Component for CommitPopup {
|
||||||
self.key_config.keys.open_commit_editor,
|
self.key_config.keys.open_commit_editor,
|
||||||
) {
|
) {
|
||||||
self.queue.push(
|
self.queue.push(
|
||||||
InternalEvent::OpenExternalEditor(None),
|
InternalEvent::OpenExternalEditor(
|
||||||
|
None, None,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
self.hide();
|
self.hide();
|
||||||
true
|
true
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,28 @@ use scopeguard::defer;
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
use std::{env, io, path::Path, process::Command};
|
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 {
|
pub struct ExternalEditorPopup {
|
||||||
visible: bool,
|
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(
|
pub fn open_file_in_editor(
|
||||||
repo: &RepoPath,
|
repo: &RepoPath,
|
||||||
path: &Path,
|
path: &Path,
|
||||||
|
line: Option<u32>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let work_dir = repo_work_dir(repo)?;
|
let work_dir = repo_work_dir(repo)?;
|
||||||
|
|
||||||
|
|
@ -107,7 +130,32 @@ impl ExternalEditorPopup {
|
||||||
let mut args: Vec<&OsStr> =
|
let mut args: Vec<&OsStr> =
|
||||||
remainder.map(OsStr::new).collect();
|
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())
|
Command::new(command.clone())
|
||||||
.current_dir(work_dir)
|
.current_dir(work_dir)
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@ pub enum InternalEvent {
|
||||||
///
|
///
|
||||||
SelectBranch,
|
SelectBranch,
|
||||||
///
|
///
|
||||||
OpenExternalEditor(Option<String>),
|
OpenExternalEditor(Option<String>, Option<u32>),
|
||||||
///
|
///
|
||||||
Push(String, PushType, bool, bool),
|
Push(String, PushType, bool, bool),
|
||||||
///
|
///
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue