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::{ReturnPoint, 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(ReturnPoint {
101 buffer: self.current(),
102 cursor: self.head(),
103 view_top: self.view_top(),
104 hscroll: self.view().hscroll,
105 });
106 }
107 let mut buf = strop_core::Buffer::from_snapshot(text);
108 buf.name = name.map(|n| n.to_string());
109 let id = self
112 .docs
113 .insert(super::Document::surface(buf, surface, context));
114 self.drop_stale_scratch(id);
115 self.push_jump(); self.generation += 1; self.switch_to(id);
118 self.set_head(0);
119 self.view_mut().view_top = 0;
120 }
121
122 pub fn open_delta(
125 &mut self,
126 name: &str,
127 hunks: PreparedDiff,
128 origin: Option<HunkOrigin>,
129 commit: Option<CommitFiles>,
130 ) {
131 let text = hunks.text();
132 self.push_surface(
133 Some(name),
134 text,
135 Surface::Diff {
136 hunks,
137 origin,
138 commit,
139 sidebar_focus: false,
140 return_to: None,
141 },
142 );
143 }
144
145 pub fn open_log(&mut self, file_scoped: bool) {
147 self.open_log_inner(file_scoped, None, None);
148 }
149
150 pub(crate) fn open_log_at(&mut self, sha: &str) {
153 self.open_log_inner(false, Some(sha.to_string()), None);
154 }
155
156 pub(crate) fn open_line_history(&mut self, start: usize, end: usize) {
159 self.open_log_inner(true, None, Some((start, end)));
160 }
161
162 fn open_log_inner(
163 &mut self,
164 file_scoped: bool,
165 focus: Option<String>,
166 range: Option<(usize, usize)>,
167 ) {
168 let Some(context) = self.git_context().cloned() else {
169 self.message = "not a git repo".into();
170 return;
171 };
172 if range.is_some() && self.remote_file().is_some() && !self.remote_window_complete() {
178 self.message = "partial remote snapshot — line history needs a full window".into();
179 return;
180 }
181 let repo = context.repo.clone();
182 let file = if file_scoped {
183 self.current_buffer_rel(&repo)
184 } else {
185 None
186 };
187 self.push_surface(
188 Some(if range.is_some() {
189 "git log ·lines"
190 } else if file_scoped {
191 "git log ·file"
192 } else {
193 "git log"
194 }),
195 ropey::Rope::from_str("loading log…"),
196 Surface::CommitLog {
197 rows: vec![],
198 focus,
199 return_to: None,
200 },
201 );
202 let doc = self.current();
205 let key = LogKey {
206 document: doc,
207 revision: self.buf().revision(),
208 repo: repo.clone(),
209 };
210 let Some(ticket) = self.git_ticket(key) else {
211 return;
212 };
213 self.log_requests.insert(doc, ticket.clone());
214 let revision = self.buf().revision().get();
215 strop_trace::record_with(strop_trace::EventKind::JobStarted, || {
216 serde_json::json!({
217 "service":"git","request":"log",
218 "target":if repo.is_remote() { "remote" } else { "local" },
219 "document":{"slot":doc.index(),"generation":doc.generation()},
220 "revision":revision,
221 "path":file.as_ref().map(|p|p.to_string_lossy()),
222 })
223 });
224 let args = (
225 ticket.clone(),
226 file.clone().map(trace::services::NativePath),
227 range,
228 );
229 self.launch_git_job(
230 "git-log",
231 "git.log",
232 ticket,
233 &args,
234 GitJob::Log,
235 move |cancel| {
236 if cancel.is_cancelled() {
237 return Outcome::Cancelled(CancelReason::Superseded);
238 }
239 let exec = GitExec::for_target(&repo);
240 match memory::log_graph_range(&exec, &cancel, 200, file.as_deref(), range) {
241 Ok(rows) => Outcome::Success(rows),
242 Err(message) => Outcome::failed(FailureKind::Exit, message),
243 }
244 },
245 );
246 }
247
248 pub(crate) fn feed_readonly(&mut self, key: Key) {
256 if key == Key::Esc {
257 self.cancel_remote_write(self.current());
258 self.stop_remote_follow(self.current());
259 self.cancel_remote_filter(self.current());
260 self.walker.clear();
261 return;
262 }
263 if self.walker.at_prefix(&["ctrl-w"]) && key == Key::Char('q') {
264 self.walker.clear();
265 self.close_surface();
266 return;
267 }
268 if key == Key::Char('f') && (self.walker.at_prefix(&["]"]) || self.walker.at_prefix(&["["]))
269 {
270 let forward = self.walker.at_prefix(&["]"]);
271 let n = self.walker.state.count().unwrap_or(1);
272 self.walker.clear();
273 for _ in 0..n {
274 self.commit_file_step(forward);
275 }
276 return;
277 }
278 if !self.walker.is_ground() {
279 return self.feed_command(key);
280 }
281 if self.remote_directory_key(key) {
282 return;
283 }
284 if self.container_key(key) {
285 return;
286 }
287 match key {
288 Key::Char('q') => self.close_surface(),
289 Key::CtrlL => self.needs_repaint = true,
290 Key::CtrlO => self.jump_back(),
291 Key::Tab | Key::Backtab => {
292 if matches!(
293 self.surface(),
294 Some(Surface::Diff {
295 commit: Some(_),
296 ..
297 })
298 ) {
299 self.toggle_sidebar_focus();
300 } else {
301 self.jump_forward();
302 }
303 }
304 Key::Char('j') | Key::Down if self.sidebar_focused() => self.commit_file_step(true),
305 Key::Char('k') | Key::Up if self.sidebar_focused() => self.commit_file_step(false),
306 Key::Enter if self.sidebar_focused() => self.toggle_sidebar_focus(),
307 Key::Enter if self.surface().is_some() => self.dive(),
308 _ => self.feed_command(key),
309 }
310 }
311
312 pub(crate) fn yank_only(&mut self, command: &strop_grammar::Command) {
315 if self.defer_resolution(
316 command,
317 self.all_cursors(),
318 super::resolution::ResolutionPurpose::Execute,
319 ) {
320 return;
321 }
322 let plan = match self.resolved_plan(command, &self.all_cursors()) {
323 Ok(Some(plan)) => plan,
324 Ok(None) => {
325 self.message = "no target".into();
326 return;
327 }
328 Err(error) => {
329 self.message = error.to_string();
330 return;
331 }
332 };
333 let Some(first) = plan.targets.first() else {
334 return;
335 };
336 let range = first.range;
337 let text = plan
338 .targets
339 .iter()
340 .map(|target| self.buf().slice_string(target.range))
341 .collect::<Vec<_>>()
342 .join("\n");
343 self.set_register(
344 command.register,
345 if range.is_linewise() {
346 super::Register::linewise(text)
347 } else {
348 super::Register::characterwise(text)
349 },
350 );
351 self.note_search(command);
352 self.flash(range);
353 }
354
355 fn close_surface(&mut self) {
360 self.close_pane_or_buffer(true);
361 }
362}
363
364pub(crate) fn hunk_stats(hunks: &[Hunk]) -> (usize, usize) {
366 hunks.iter().fold((0, 0), |(a, d), h| {
367 let adds = h
368 .lines
369 .iter()
370 .filter(|l| l.origin == LineOrigin::Addition)
371 .count();
372 let dels = h
373 .lines
374 .iter()
375 .filter(|l| l.origin == LineOrigin::Deletion)
376 .count();
377 (a + adds, d + dels)
378 })
379}
380
381pub(crate) fn diff_surface_text(label: &str, hunks: &[Hunk]) -> String {
385 let (added, deleted) = hunk_stats(hunks);
386 let mut text = format!("{label} +{added} -{deleted}\n");
387 for hunk in hunks {
388 text.push_str(&hunk.header());
389 text.push('\n');
390 for line in &hunk.lines {
391 text.push_str(&line.text_str());
392 text.push('\n');
393 }
394 }
395 text
396}
397
398pub fn git_channel() -> (Sender<GitJob>, Receiver<GitJob>) {
400 channel()
401}
402
403impl Editor {
404 pub(crate) fn open_log_pub(&mut self, file_scoped: bool) {
406 self.open_log(file_scoped);
407 }
408}
409
410impl Editor {
411 pub(crate) fn current_buffer_rel(&self, repo: &RepoTarget) -> Option<PathBuf> {
417 match &self.cur().source {
418 super::document::DocumentSource::Remote(file) => match repo {
419 RepoTarget::Remote { endpoint, .. } if endpoint == file.file.endpoint() => {
420 repo.rel_of(file.file.path())
421 }
422 _ => None,
423 },
424 super::document::DocumentSource::File => {
425 let path = self.cur().buf.path.as_deref()?;
426 let abs = if Path::new(path).is_absolute() {
427 PathBuf::from(path)
428 } else {
429 repo.workdir().join(path)
430 };
431 repo.rel_of(&abs)
432 }
433 _ => None,
434 }
435 }
436}
437
438#[cfg(test)]
439mod tests;