strop_engine/editor/git_memory/
mod.rs1mod file_list;
9mod hunk_set;
10mod jobs;
11mod presentation;
12mod sidebar;
13pub use file_list::PreparedFiles;
14pub(crate) use hunk_set::HunkSet;
15pub(crate) use jobs::{git_failure, repo_or_unavailable};
16pub use presentation::PreparedDiff;
17pub use sidebar::{Sidebar, SidebarRow};
18mod types;
19pub use types::GitJob;
20pub(crate) use types::{
21 BlameKey, CardKey, ContextKey, DiveData, DiveKey, DiveTarget, GitMutation, HunkData, HunkKey,
22 LogKey, MutationKey, MutationKind, MutationOp,
23};
24
25use std::path::{Path, PathBuf};
26use std::sync::mpsc::{channel, Receiver, Sender};
27
28use strop_core::id::{BufferRevision, DocumentId};
29use strop_core::worker::{CancelReason, FailureKind, Outcome, Ticket};
30use strop_git::exec::GitExec;
31use strop_git::memory::{self, BlameLine};
32use strop_git::{Hunk, LineOrigin, RepoTarget};
33
34use super::document::Surface;
35use super::{trace, Editor, Key};
36#[derive(Debug, Clone)]
43pub struct CommitFiles {
44 pub repo: RepoTarget,
45 pub sha: String,
46 pub files: PreparedFiles,
47 pub current: PathBuf,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
55pub struct HunkOrigin {
56 pub buffer: DocumentId,
57 pub revision: BufferRevision,
58 pub untracked: bool,
61}
62
63#[derive(Debug, Clone)]
68pub struct BlameGutter {
69 pub lines: Vec<BlameLine>,
70 pub revision: BufferRevision,
73 pub request: Option<Ticket<BlameKey>>,
77}
78
79impl Editor {
80 pub fn surface(&self) -> Option<&Surface> {
81 self.cur().surface_payload()
82 }
83 pub(crate) fn push_surface(
85 &mut self,
86 name: Option<&str>,
87 text: ropey::Rope,
88 mut surface: Surface,
89 ) {
90 let Some(context) = self.git_context().cloned() else {
91 self.message = "not a git repository".into();
92 return;
93 };
94 if self.surface().is_none() {
100 surface.set_return_point(self.jump_record());
101 }
102 let mut buf = strop_core::Buffer::from_snapshot(text);
103 buf.name = name.map(|n| n.to_string());
104 let id = self
107 .docs
108 .insert(super::Document::surface(buf, surface, context));
109 self.drop_stale_scratch(id);
110 self.push_jump(); self.generation += 1; self.switch_to(id);
113 self.set_head(0);
114 self.view_mut().view_top = 0;
115 }
116
117 pub fn open_delta(
120 &mut self,
121 name: &str,
122 hunks: PreparedDiff,
123 origin: Option<HunkOrigin>,
124 commit: Option<CommitFiles>,
125 ) {
126 let text = hunks.text();
127 self.push_surface(
128 Some(name),
129 text,
130 Surface::Diff {
131 hunks,
132 origin,
133 commit,
134 sidebar_focus: false,
135 return_to: None,
136 },
137 );
138 }
139
140 pub fn open_log(&mut self, file_scoped: bool) {
142 self.open_log_inner(file_scoped, None, None);
143 }
144
145 pub(crate) fn open_log_at(&mut self, sha: &str) {
148 self.open_log_inner(false, Some(sha.to_string()), None);
149 }
150
151 pub(crate) fn open_line_history(&mut self, start: usize, end: usize) {
154 self.open_log_inner(true, None, Some((start, end)));
155 }
156
157 fn open_log_inner(
158 &mut self,
159 file_scoped: bool,
160 focus: Option<String>,
161 range: Option<(usize, usize)>,
162 ) {
163 let Some(context) = self.git_context().cloned() else {
164 self.message = "not a git repo".into();
165 return;
166 };
167 if range.is_some() && self.remote_file().is_some() && !self.remote_window_complete() {
173 self.message = "partial remote snapshot — line history needs a full window".into();
174 return;
175 }
176 let repo = context.repo.clone();
177 let file = if file_scoped {
178 self.current_buffer_rel(&repo)
179 } else {
180 None
181 };
182 self.push_surface(
183 Some(if range.is_some() {
184 "git log ·lines"
185 } else if file_scoped {
186 "git log ·file"
187 } else {
188 "git log"
189 }),
190 ropey::Rope::from_str("loading log…"),
191 Surface::CommitLog {
192 rows: vec![],
193 focus,
194 return_to: None,
195 },
196 );
197 let doc = self.current();
200 let key = LogKey {
201 document: doc,
202 revision: self.buf().revision(),
203 repo: repo.clone(),
204 };
205 let Some(ticket) = self.git_ticket(key) else {
206 return;
207 };
208 self.log_requests.insert(doc, ticket.clone());
209 let revision = self.buf().revision().get();
210 strop_trace::record_with(strop_trace::EventKind::JobStarted, || {
211 serde_json::json!({
212 "service":"git","request":"log",
213 "target":if repo.is_remote() { "remote" } else { "local" },
214 "document":{"slot":doc.index(),"generation":doc.generation()},
215 "revision":revision,
216 "path":file.as_ref().map(|p|p.to_string_lossy()),
217 })
218 });
219 let args = (
220 ticket.clone(),
221 file.clone().map(trace::services::NativePath),
222 range,
223 );
224 self.launch_git_job(
225 "git-log",
226 "git.log",
227 ticket,
228 &args,
229 GitJob::Log,
230 move |cancel| {
231 if cancel.is_cancelled() {
232 return Outcome::Cancelled(CancelReason::Superseded);
233 }
234 let exec = GitExec::for_target(&repo);
235 match memory::log_graph_range(&exec, &cancel, 200, file.as_deref(), range) {
236 Ok(rows) => Outcome::Success(rows),
237 Err(message) => Outcome::failed(FailureKind::Exit, message),
238 }
239 },
240 );
241 }
242
243 pub(crate) fn feed_readonly(&mut self, key: Key) {
251 if key == Key::Esc {
252 self.cancel_remote_write(self.current());
253 self.stop_remote_follow(self.current());
254 self.cancel_directory_filter(self.current());
255 self.walker.clear();
256 return;
257 }
258 if self.walker.at_prefix(&["ctrl-w"]) && key == Key::Char('q') {
259 self.walker.clear();
260 self.close_surface();
261 return;
262 }
263 if key == Key::Char('f') && (self.walker.at_prefix(&["]"]) || self.walker.at_prefix(&["["]))
264 {
265 let forward = self.walker.at_prefix(&["]"]);
266 let n = self.walker.state.count().unwrap_or(1);
267 self.walker.clear();
268 for _ in 0..n {
269 self.commit_file_step(forward);
270 }
271 return;
272 }
273 if !self.walker.is_ground() {
274 return self.feed_command(key);
275 }
276 if self.directory_key(key) {
277 return;
278 }
279 match key {
280 Key::Char('q') => self.close_surface(),
281 Key::CtrlL => self.needs_repaint = true,
282 Key::CtrlO => self.jump_back(),
283 Key::Tab | Key::Backtab => {
284 if matches!(
285 self.surface(),
286 Some(Surface::Diff {
287 commit: Some(_),
288 ..
289 })
290 ) {
291 self.toggle_sidebar_focus();
292 } else {
293 self.jump_forward();
294 }
295 }
296 Key::Char('j') | Key::Down if self.sidebar_focused() => self.commit_file_step(true),
297 Key::Char('k') | Key::Up if self.sidebar_focused() => self.commit_file_step(false),
298 Key::Enter if self.sidebar_focused() => self.toggle_sidebar_focus(),
299 Key::Enter if self.surface().is_some() => self.dive(),
300 _ => self.feed_command(key),
301 }
302 }
303
304 pub(crate) fn yank_only(&mut self, command: &strop_grammar::Command) {
307 if self.defer_resolution(
308 command,
309 self.all_cursors(),
310 super::resolution::ResolutionPurpose::Execute,
311 ) {
312 return;
313 }
314 let plan = match self.resolved_plan(command, &self.all_cursors()) {
315 Ok(Some(plan)) => plan,
316 Ok(None) => {
317 self.message = "no target".into();
318 return;
319 }
320 Err(error) => {
321 self.message = error.to_string();
322 return;
323 }
324 };
325 let Some(first) = plan.targets.first() else {
326 return;
327 };
328 let range = first.range;
329 let text = plan
330 .targets
331 .iter()
332 .map(|target| self.buf().slice_string(target.range))
333 .collect::<Vec<_>>()
334 .join("\n");
335 self.set_register(
336 command.register,
337 if range.is_linewise() {
338 super::Register::linewise(text)
339 } else {
340 super::Register::characterwise(text)
341 },
342 );
343 self.note_search(command);
344 self.flash(range);
345 }
346
347 fn close_surface(&mut self) {
352 self.close_pane_or_buffer(true);
353 }
354}
355
356pub(crate) fn hunk_stats(hunks: &[Hunk]) -> (usize, usize) {
358 hunks.iter().fold((0, 0), |(a, d), h| {
359 let adds = h
360 .lines
361 .iter()
362 .filter(|l| l.origin == LineOrigin::Addition)
363 .count();
364 let dels = h
365 .lines
366 .iter()
367 .filter(|l| l.origin == LineOrigin::Deletion)
368 .count();
369 (a + adds, d + dels)
370 })
371}
372
373pub(crate) fn diff_surface_text(label: &str, hunks: &[Hunk]) -> String {
377 let (added, deleted) = hunk_stats(hunks);
378 let mut text = format!("{label} +{added} -{deleted}\n");
379 for hunk in hunks {
380 text.push_str(&hunk.header());
381 text.push('\n');
382 for line in &hunk.lines {
383 text.push_str(&line.text_str());
384 text.push('\n');
385 }
386 }
387 text
388}
389
390pub fn git_channel() -> (Sender<GitJob>, Receiver<GitJob>) {
392 channel()
393}
394
395impl Editor {
396 pub(crate) fn open_log_pub(&mut self, file_scoped: bool) {
398 self.open_log(file_scoped);
399 }
400}
401
402impl Editor {
403 pub(crate) fn current_buffer_rel(&self, repo: &RepoTarget) -> Option<PathBuf> {
409 match &self.cur().source {
410 super::document::DocumentSource::Remote(file) => match repo {
411 RepoTarget::Remote { endpoint, .. } if endpoint == file.file.endpoint() => {
412 repo.rel_of(file.file.path())
413 }
414 _ => None,
415 },
416 super::document::DocumentSource::File => {
417 let path = self.cur().buf.path.as_deref()?;
418 let abs = if Path::new(path).is_absolute() {
419 PathBuf::from(path)
420 } else {
421 repo.workdir().join(path)
422 };
423 repo.rel_of(&abs)
424 }
425 _ => None,
426 }
427 }
428}
429
430#[cfg(test)]
431mod tests;