mod file_list;
mod hunk_set;
mod jobs;
mod presentation;
mod sidebar;
pub use file_list::PreparedFiles;
pub(crate) use hunk_set::HunkSet;
pub(crate) use jobs::{git_failure, repo_or_unavailable};
pub use presentation::PreparedDiff;
pub use sidebar::{Sidebar, SidebarRow};
mod types;
pub use types::GitJob;
pub(crate) use types::{
BlameKey, CardKey, ContextKey, DiveData, DiveKey, DiveTarget, GitMutation, HunkData, HunkKey,
LogKey, MutationKey, MutationKind, MutationOp,
};
use std::path::{Path, PathBuf};
use std::sync::mpsc::{channel, Receiver, Sender};
use strop_core::id::{BufferRevision, DocumentId};
use strop_core::worker::{CancelReason, FailureKind, Outcome, Ticket};
use strop_git::exec::GitExec;
use strop_git::memory::{self, BlameLine};
use strop_git::{Hunk, LineOrigin, RepoTarget};
use super::document::{ReturnPoint, Surface};
use super::{trace, Editor, Key};
#[derive(Debug, Clone)]
pub struct CommitFiles {
pub repo: RepoTarget,
pub sha: String,
pub files: PreparedFiles,
pub current: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct HunkOrigin {
pub buffer: DocumentId,
pub revision: BufferRevision,
pub untracked: bool,
}
#[derive(Debug, Clone)]
pub struct BlameGutter {
pub lines: Vec<BlameLine>,
pub revision: BufferRevision,
pub request: Option<Ticket<BlameKey>>,
}
impl Editor {
pub fn surface(&self) -> Option<&Surface> {
self.cur().surface_payload()
}
pub(crate) fn push_surface(
&mut self,
name: Option<&str>,
text: ropey::Rope,
mut surface: Surface,
) {
let Some(context) = self.git_context().cloned() else {
self.message = "not a git repository".into();
return;
};
if self.surface().is_none() {
surface.set_return_point(ReturnPoint {
buffer: self.current(),
cursor: self.head(),
view_top: self.view_top(),
hscroll: self.view().hscroll,
});
}
let mut buf = strop_core::Buffer::from_snapshot(text);
buf.name = name.map(|n| n.to_string());
let id = self
.docs
.insert(super::Document::surface(buf, surface, context));
self.drop_stale_scratch(id);
self.push_jump(); self.generation += 1; self.switch_to(id);
self.set_head(0);
self.view_mut().view_top = 0;
}
pub fn open_delta(
&mut self,
name: &str,
hunks: PreparedDiff,
origin: Option<HunkOrigin>,
commit: Option<CommitFiles>,
) {
let text = hunks.text();
self.push_surface(
Some(name),
text,
Surface::Diff {
hunks,
origin,
commit,
sidebar_focus: false,
return_to: None,
},
);
}
pub fn open_log(&mut self, file_scoped: bool) {
self.open_log_inner(file_scoped, None, None);
}
pub(crate) fn open_log_at(&mut self, sha: &str) {
self.open_log_inner(false, Some(sha.to_string()), None);
}
pub(crate) fn open_line_history(&mut self, start: usize, end: usize) {
self.open_log_inner(true, None, Some((start, end)));
}
fn open_log_inner(
&mut self,
file_scoped: bool,
focus: Option<String>,
range: Option<(usize, usize)>,
) {
let Some(context) = self.git_context().cloned() else {
self.message = "not a git repo".into();
return;
};
if range.is_some() && self.remote_file().is_some() && !self.remote_window_complete() {
self.message = "partial remote snapshot — line history needs a full window".into();
return;
}
let repo = context.repo.clone();
let file = if file_scoped {
self.current_buffer_rel(&repo)
} else {
None
};
self.push_surface(
Some(if range.is_some() {
"git log ·lines"
} else if file_scoped {
"git log ·file"
} else {
"git log"
}),
ropey::Rope::from_str("loading log…"),
Surface::CommitLog {
rows: vec![],
focus,
return_to: None,
},
);
let doc = self.current();
let key = LogKey {
document: doc,
revision: self.buf().revision(),
repo: repo.clone(),
};
let Some(ticket) = self.git_ticket(key) else {
return;
};
self.log_requests.insert(doc, ticket.clone());
let revision = self.buf().revision().get();
strop_trace::record_with(strop_trace::EventKind::JobStarted, || {
serde_json::json!({
"service":"git","request":"log",
"target":if repo.is_remote() { "remote" } else { "local" },
"document":{"slot":doc.index(),"generation":doc.generation()},
"revision":revision,
"path":file.as_ref().map(|p|p.to_string_lossy()),
})
});
let args = (
ticket.clone(),
file.clone().map(trace::services::NativePath),
range,
);
self.launch_git_job(
"git-log",
"git.log",
ticket,
&args,
GitJob::Log,
move |cancel| {
if cancel.is_cancelled() {
return Outcome::Cancelled(CancelReason::Superseded);
}
let exec = GitExec::for_target(&repo);
match memory::log_graph_range(&exec, &cancel, 200, file.as_deref(), range) {
Ok(rows) => Outcome::Success(rows),
Err(message) => Outcome::failed(FailureKind::Exit, message),
}
},
);
}
pub(crate) fn feed_readonly(&mut self, key: Key) {
if key == Key::Esc {
self.cancel_remote_write(self.current());
self.stop_remote_follow(self.current());
self.cancel_remote_filter(self.current());
self.walker.clear();
return;
}
if self.walker.at_prefix(&["ctrl-w"]) && key == Key::Char('q') {
self.walker.clear();
self.close_surface();
return;
}
if key == Key::Char('f') && (self.walker.at_prefix(&["]"]) || self.walker.at_prefix(&["["]))
{
let forward = self.walker.at_prefix(&["]"]);
let n = self.walker.state.count().unwrap_or(1);
self.walker.clear();
for _ in 0..n {
self.commit_file_step(forward);
}
return;
}
if !self.walker.is_ground() {
return self.feed_command(key);
}
if self.remote_directory_key(key) {
return;
}
if self.container_key(key) {
return;
}
match key {
Key::Char('q') => self.close_surface(),
Key::CtrlL => self.needs_repaint = true,
Key::CtrlO => self.jump_back(),
Key::Tab | Key::Backtab => {
if matches!(
self.surface(),
Some(Surface::Diff {
commit: Some(_),
..
})
) {
self.toggle_sidebar_focus();
} else {
self.jump_forward();
}
}
Key::Char('j') | Key::Down if self.sidebar_focused() => self.commit_file_step(true),
Key::Char('k') | Key::Up if self.sidebar_focused() => self.commit_file_step(false),
Key::Enter if self.sidebar_focused() => self.toggle_sidebar_focus(),
Key::Enter if self.surface().is_some() => self.dive(),
_ => self.feed_command(key),
}
}
pub(crate) fn yank_only(&mut self, command: &strop_grammar::Command) {
if self.defer_resolution(
command,
self.all_cursors(),
super::resolution::ResolutionPurpose::Execute,
) {
return;
}
let plan = match self.resolved_plan(command, &self.all_cursors()) {
Ok(Some(plan)) => plan,
Ok(None) => {
self.message = "no target".into();
return;
}
Err(error) => {
self.message = error.to_string();
return;
}
};
let Some(first) = plan.targets.first() else {
return;
};
let range = first.range;
let text = plan
.targets
.iter()
.map(|target| self.buf().slice_string(target.range))
.collect::<Vec<_>>()
.join("\n");
self.set_register(
command.register,
if range.is_linewise() {
super::Register::linewise(text)
} else {
super::Register::characterwise(text)
},
);
self.note_search(command);
self.flash(range);
}
fn close_surface(&mut self) {
self.close_pane_or_buffer(true);
}
}
pub(crate) fn hunk_stats(hunks: &[Hunk]) -> (usize, usize) {
hunks.iter().fold((0, 0), |(a, d), h| {
let adds = h
.lines
.iter()
.filter(|l| l.origin == LineOrigin::Addition)
.count();
let dels = h
.lines
.iter()
.filter(|l| l.origin == LineOrigin::Deletion)
.count();
(a + adds, d + dels)
})
}
pub(crate) fn diff_surface_text(label: &str, hunks: &[Hunk]) -> String {
let (added, deleted) = hunk_stats(hunks);
let mut text = format!("{label} +{added} -{deleted}\n");
for hunk in hunks {
text.push_str(&hunk.header());
text.push('\n');
for line in &hunk.lines {
text.push_str(&line.text_str());
text.push('\n');
}
}
text
}
pub fn git_channel() -> (Sender<GitJob>, Receiver<GitJob>) {
channel()
}
impl Editor {
pub(crate) fn open_log_pub(&mut self, file_scoped: bool) {
self.open_log(file_scoped);
}
}
impl Editor {
pub(crate) fn current_buffer_rel(&self, repo: &RepoTarget) -> Option<PathBuf> {
match &self.cur().source {
super::document::DocumentSource::Remote(file) => match repo {
RepoTarget::Remote { endpoint, .. } if endpoint == file.file.endpoint() => {
repo.rel_of(file.file.path())
}
_ => None,
},
super::document::DocumentSource::File => {
let path = self.cur().buf.path.as_deref()?;
let abs = if Path::new(path).is_absolute() {
PathBuf::from(path)
} else {
repo.workdir().join(path)
};
repo.rel_of(&abs)
}
_ => None,
}
}
}
#[cfg(test)]
mod tests;