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_remote_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.remote_directory_key(key) {
277 return;
278 }
279 if self.container_key(key) {
280 return;
281 }
282 match key {
283 Key::Char('q') => self.close_surface(),
284 Key::CtrlL => self.needs_repaint = true,
285 Key::CtrlO => self.jump_back(),
286 Key::Tab | Key::Backtab => {
287 if matches!(
288 self.surface(),
289 Some(Surface::Diff {
290 commit: Some(_),
291 ..
292 })
293 ) {
294 self.toggle_sidebar_focus();
295 } else {
296 self.jump_forward();
297 }
298 }
299 Key::Char('j') | Key::Down if self.sidebar_focused() => self.commit_file_step(true),
300 Key::Char('k') | Key::Up if self.sidebar_focused() => self.commit_file_step(false),
301 Key::Enter if self.sidebar_focused() => self.toggle_sidebar_focus(),
302 Key::Enter if self.surface().is_some() => self.dive(),
303 _ => self.feed_command(key),
304 }
305 }
306
307 pub(crate) fn yank_only(&mut self, command: &strop_grammar::Command) {
310 if self.defer_resolution(
311 command,
312 self.all_cursors(),
313 super::resolution::ResolutionPurpose::Execute,
314 ) {
315 return;
316 }
317 let plan = match self.resolved_plan(command, &self.all_cursors()) {
318 Ok(Some(plan)) => plan,
319 Ok(None) => {
320 self.message = "no target".into();
321 return;
322 }
323 Err(error) => {
324 self.message = error.to_string();
325 return;
326 }
327 };
328 let Some(first) = plan.targets.first() else {
329 return;
330 };
331 let range = first.range;
332 let text = plan
333 .targets
334 .iter()
335 .map(|target| self.buf().slice_string(target.range))
336 .collect::<Vec<_>>()
337 .join("\n");
338 self.set_register(
339 command.register,
340 if range.is_linewise() {
341 super::Register::linewise(text)
342 } else {
343 super::Register::characterwise(text)
344 },
345 );
346 self.note_search(command);
347 self.flash(range);
348 }
349
350 fn close_surface(&mut self) {
355 self.close_pane_or_buffer(true);
356 }
357}
358
359pub(crate) fn hunk_stats(hunks: &[Hunk]) -> (usize, usize) {
361 hunks.iter().fold((0, 0), |(a, d), h| {
362 let adds = h
363 .lines
364 .iter()
365 .filter(|l| l.origin == LineOrigin::Addition)
366 .count();
367 let dels = h
368 .lines
369 .iter()
370 .filter(|l| l.origin == LineOrigin::Deletion)
371 .count();
372 (a + adds, d + dels)
373 })
374}
375
376pub(crate) fn diff_surface_text(label: &str, hunks: &[Hunk]) -> String {
380 let (added, deleted) = hunk_stats(hunks);
381 let mut text = format!("{label} +{added} -{deleted}\n");
382 for hunk in hunks {
383 text.push_str(&hunk.header());
384 text.push('\n');
385 for line in &hunk.lines {
386 text.push_str(&line.text_str());
387 text.push('\n');
388 }
389 }
390 text
391}
392
393pub fn git_channel() -> (Sender<GitJob>, Receiver<GitJob>) {
395 channel()
396}
397
398impl Editor {
399 pub(crate) fn open_log_pub(&mut self, file_scoped: bool) {
401 self.open_log(file_scoped);
402 }
403}
404
405impl Editor {
406 pub(crate) fn current_buffer_rel(&self, repo: &RepoTarget) -> Option<PathBuf> {
412 match &self.cur().source {
413 super::document::DocumentSource::Remote(file) => match repo {
414 RepoTarget::Remote { endpoint, .. } if endpoint == file.file.endpoint() => {
415 repo.rel_of(file.file.path())
416 }
417 _ => None,
418 },
419 super::document::DocumentSource::File => {
420 let path = self.cur().buf.path.as_deref()?;
421 let abs = if Path::new(path).is_absolute() {
422 PathBuf::from(path)
423 } else {
424 repo.workdir().join(path)
425 };
426 repo.rel_of(&abs)
427 }
428 _ => None,
429 }
430 }
431}
432
433#[cfg(test)]
434mod tests;