diff --git a/asyncgit/src/sync/commit_files.rs b/asyncgit/src/sync/commit_files.rs index c03d7c13cf..6c29c78866 100644 --- a/asyncgit/src/sync/commit_files.rs +++ b/asyncgit/src/sync/commit_files.rs @@ -6,7 +6,7 @@ use crate::{ sync::{get_stashes, repository::repo}, StatusItem, StatusItemType, }; -use git2::{Diff, Repository}; +use git2::{Delta, Diff, Repository}; use scopetime::scope_time; use std::collections::HashSet; @@ -67,17 +67,28 @@ pub fn get_commit_files( )? }; + +fn path_from_delta(delta: git2::DiffDelta) -> String { + let path = if delta.status() == Delta::Deleted { + delta.old_file().path() + } else { + delta + .new_file() + .path() + .or_else(|| delta.old_file().path()) + }; + path + .map(|p| p.to_str().unwrap_or("").to_string()) + .unwrap_or_default() +} + let res = diff .deltas() .map(|delta| { let status = StatusItemType::from(delta.status()); StatusItem { - path: delta - .new_file() - .path() - .map(|p| p.to_str().unwrap_or("").to_string()) - .unwrap_or_default(), + path: path_from_delta(delta), status, } }) diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index 2a5f413e8f..ea36d6eba1 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -103,7 +103,7 @@ pub use tags::{ delete_tag, get_tags, get_tags_with_metadata, CommitTags, Tag, TagWithMetadata, Tags, }; -pub use tree::{tree_file_content, tree_files, TreeFile}; +pub use tree::{file_content_at_commit, tree_file_content, tree_files, TreeFile}; pub use utils::{ get_head, get_head_tuple, repo_dir, repo_open_error, stage_add_all, stage_add_file, stage_addremoved, Head, diff --git a/asyncgit/src/sync/tree.rs b/asyncgit/src/sync/tree.rs index a6fdfbe585..76e75d0744 100644 --- a/asyncgit/src/sync/tree.rs +++ b/asyncgit/src/sync/tree.rs @@ -71,6 +71,40 @@ fn path_cmp(a: &Path, b: &Path) -> Ordering { } } + +/// UTF-8 text content of `path` in `commit`, or in its first parent when `from_parent`. +pub fn file_content_at_commit( + repo_path: &RepoPath, + commit: CommitId, + path: &Path, + from_parent: bool, +) -> Result { + scope_time!("file_content_at_commit"); + + let repo = repo(repo_path)?; + let commit = repo.find_commit(commit.into())?; + let object_id = if from_parent { + if commit.parent_count() == 0 { + return Err(Error::Generic( + "commit has no parent".into(), + )); + } + commit.parent(0)?.id() + } else { + commit.id() + }; + let commit = repo.find_commit(object_id)?; + let tree = commit.tree()?; + let entry = tree.get_path(path)?; + let blob = repo.find_blob(entry.id())?; + + if blob.is_binary() { + return Err(Error::BinaryFile); + } + + Ok(String::from_utf8_lossy(blob.content()).to_string()) +} + /// will only work on utf8 content pub fn tree_file_content( repo_path: &RepoPath, diff --git a/src/app.rs b/src/app.rs index 8626aa3b8e..1390759a63 100644 --- a/src/app.rs +++ b/src/app.rs @@ -119,6 +119,7 @@ pub struct App { // "Flags" requires_redraw: Cell, file_to_open: Option, + pending_external_editor: Option<(String, Option, bool)>, } pub struct Environment { @@ -244,6 +245,7 @@ impl App { key_config: env.key_config, requires_redraw: Cell::new(false), file_to_open: None, + pending_external_editor: None, repo: env.repo, repo_path_text, popup_stack: PopupStack::default(), @@ -372,10 +374,19 @@ impl App { self.external_editor_popup.hide(); if matches!(polling_state, InputState::Paused) { let result = - if let Some(path) = self.file_to_open.take() { + if let Some((path, commit, from_parent)) = + self.pending_external_editor.take() + { ExternalEditorPopup::open_file_in_editor( &self.repo.borrow(), Path::new(&path), + commit.map(|c| (c, from_parent)), + ) + } else if let Some(path) = self.file_to_open.take() { + ExternalEditorPopup::open_file_in_editor( + &self.repo.borrow(), + Path::new(&path), + None, ) } else { let changes = @@ -816,11 +827,24 @@ impl App { } } InternalEvent::OpenExternalEditor(path) => { + self.pending_external_editor = None; self.input.set_polling(false); self.external_editor_popup.show()?; self.file_to_open = path; flags.insert(NeedsUpdate::COMMANDS); } + InternalEvent::OpenExternalEditorAtCommit { + path, + commit, + from_parent, + } => { + self.file_to_open = None; + self.pending_external_editor = + Some((path, Some(commit), from_parent)); + self.input.set_polling(false); + self.external_editor_popup.show()?; + flags.insert(NeedsUpdate::COMMANDS); + } InternalEvent::Push(branch, push_type, force, delete) => { self.push_popup .push(branch, push_type, force, delete)?; diff --git a/src/components/status_tree.rs b/src/components/status_tree.rs index ac4fc9f6a8..8dbbb9d712 100644 --- a/src/components/status_tree.rs +++ b/src/components/status_tree.rs @@ -15,7 +15,11 @@ use crate::{ ui::{self, style::SharedTheme}, }; use anyhow::Result; -use asyncgit::{hash, sync::CommitId, StatusItem, StatusItemType}; +use asyncgit::{ + hash, + sync::{utils::repo_work_dir, CommitId, RepoPathRef}, + StatusItem, StatusItemType, +}; use crossterm::event::Event; use ratatui::{layout::Rect, text::Span, Frame}; use std::{borrow::Cow, cell::Cell, path::Path}; @@ -32,6 +36,7 @@ pub struct StatusTreeComponent { focused: bool, show_selection: bool, queue: Queue, + repo: RepoPathRef, theme: SharedTheme, key_config: SharedKeyConfig, scroll_top: Cell, @@ -49,6 +54,7 @@ impl StatusTreeComponent { focused: focus, show_selection: focus, queue: env.queue.clone(), + repo: env.repo.clone(), theme: env.theme.clone(), key_config: env.key_config.clone(), scroll_top: Cell::new(0), @@ -508,6 +514,31 @@ impl Component for StatusTreeComponent { } else if key_match(e, self.key_config.keys.edit_file) { if let Some(status_item) = self.selection_file() { + if let Some(commit_id) = self.revision { + let from_parent = matches!( + status_item.status, + StatusItemType::Deleted + ); + let missing = repo_work_dir( + &self.repo.borrow(), + ) + .ok() + .map(|wd| { + std::path::Path::new(&wd) + .join(&status_item.path) + }) + .is_none_or(|p| !p.exists()); + if from_parent || missing { + self.queue.push( + InternalEvent::OpenExternalEditorAtCommit { + path: status_item.path, + commit: commit_id, + from_parent, + }, + ); + return Ok(EventState::Consumed); + } + } self.queue.push( InternalEvent::OpenExternalEditor(Some( status_item.path, diff --git a/src/popups/commit.rs b/src/popups/commit.rs index b5dff7677c..08d1e55708 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(); diff --git a/src/popups/externaleditor.rs b/src/popups/externaleditor.rs index 52a7327b13..3d99a85375 100644 --- a/src/popups/externaleditor.rs +++ b/src/popups/externaleditor.rs @@ -10,7 +10,8 @@ use crate::{ }; use anyhow::{anyhow, bail, Result}; use asyncgit::sync::{ - get_config_string, utils::repo_work_dir, RepoPath, + file_content_at_commit, get_config_string, utils::repo_work_dir, + CommitId, RepoPath, }; use crossterm::{ event::Event, @@ -25,6 +26,7 @@ use ratatui::{ }; use scopeguard::defer; use std::ffi::OsStr; +use std::fs; use std::{env, io, path::Path, process::Command}; /// @@ -48,6 +50,7 @@ impl ExternalEditorPopup { pub fn open_file_in_editor( repo: &RepoPath, path: &Path, + at_commit: Option<(CommitId, bool)>, ) -> Result<()> { let work_dir = repo_work_dir(repo)?; @@ -57,9 +60,27 @@ impl ExternalEditorPopup { path.into() }; - if !path.exists() { + let editor_path = if path.exists() { + path + } else if let Some((commit, from_parent)) = at_commit { + let content = file_content_at_commit( + repo, + commit, + &path, + from_parent, + )?; + let file_name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| String::from("file")); + let editor_path = Path::new(&work_dir).join(".git").join( + format!("gitui-edit-{commit}-{file_name}"), + ); + fs::write(&editor_path, content.as_bytes())?; + editor_path + } else { bail!("file not found: {path:?}"); - } + }; io::stdout().execute(LeaveAlternateScreen)?; defer! { @@ -107,7 +128,7 @@ impl ExternalEditorPopup { let mut args: Vec<&OsStr> = remainder.map(OsStr::new).collect(); - args.push(path.as_os_str()); + args.push(editor_path.as_os_str()); Command::new(command.clone()) .current_dir(work_dir) diff --git a/src/queue.rs b/src/queue.rs index 5cdfe3cef0..750eb4a246 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -123,6 +123,12 @@ pub enum InternalEvent { /// OpenExternalEditor(Option), /// + OpenExternalEditorAtCommit { + path: String, + commit: CommitId, + from_parent: bool, + }, + /// Push(String, PushType, bool, bool), /// Pull(String),