Skip to main content

strop_engine/editor/git_memory/
mod.rs

1//! Git memory surfaces (M3, reworked 0010): commit browser, changed-files
2//! dive, diff view, blame card, permalinks. Every surface is a real
3//! readonly buffer (0001 §3: motions, /, yank work); jobs post onto the
4//! event loop (0001 §5.6: no blocking the input path on shell git).
5//! R9/R6: every job owns a ticket; results land through
6//! `git_memory::jobs` handlers which validate ownership first.
7
8mod 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/// The commit a Diff surface's file belongs to, with the commit's full
37/// changed-file list — the sidebar's data (typed numstat rows, the same
38/// ones the changed-files surface renders from; 0011 §4). `repo` is
39/// the provenance the whole delta chain replays: `]f` steps and dives
40/// from this surface run against that repository — remote surfaces
41/// never answer from the local cwd (0036 RW8).
42#[derive(Debug, Clone)]
43pub struct CommitFiles {
44    pub repo: RepoTarget,
45    pub sha: String,
46    pub files: PreparedFiles,
47    /// Selected file identity; display labels are not reversible native paths.
48    pub current: PathBuf,
49}
50
51/// Where a hunk preview came from: the buffer it undoes/stages in, at
52/// the revision it was captured. Edits since then invalidate it —
53/// applying a stale region would cut the wrong lines.
54#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
55pub struct HunkOrigin {
56    pub buffer: DocumentId,
57    pub revision: BufferRevision,
58    /// The origin was untracked when captured: undo refuses (there is
59    /// no HEAD content to restore from).
60    pub untracked: bool,
61}
62
63/// Per-buffer blame gutter state (0011 §3), keyed by canonical path —
64/// no parallel vector to keep aligned, and index churn can never pair
65/// one buffer with another's blame. Valid only while the buffer's
66/// revision and line count still match the capture.
67#[derive(Debug, Clone)]
68pub struct BlameGutter {
69    pub lines: Vec<BlameLine>,
70    /// Buffer revision when the blame was captured; any edit since
71    /// invalidates the line↔buffer-line pairing.
72    pub revision: BufferRevision,
73    /// The pending request while the gutter loads; `None` once loaded.
74    /// A late result for a removed request is rejected by ticket, so
75    /// toggle-off/on at the same path can never cross-pollinate.
76    pub request: Option<Ticket<BlameKey>>,
77}
78
79impl Editor {
80    pub fn surface(&self) -> Option<&Surface> {
81        self.cur().surface_payload()
82    }
83    // ---- surface lifecycle --------------------------------------------
84    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        // rebind-after-insert happens in Document::surface insertion
95        // below — dropping before the new id exists strands panes
96
97        // surfaces stack: only the first one opened from a plain buffer
98        // carries a return point (closing the deepest unwinds the chain)
99        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        // surfaces render via delta/plain rules: no tree-sitter;
105        // readonly derives from the source (0021 §4)
106        let id = self
107            .docs
108            .insert(super::Document::surface(buf, surface, context));
109        self.drop_stale_scratch(id);
110        self.push_jump(); // opening a surface is a jumplist entry
111        self.generation += 1; // document set changed: old jobs are stale (0011 §2)
112        self.switch_to(id);
113        self.set_head(0);
114        self.view_mut().view_top = 0;
115    }
116
117    /// A diff surface from structured hunks (0010 §2). `label` heads the
118    /// stats row; `origin` is set only for working-tree hunk previews.
119    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    /// `Space g l`: commit browser. `Space g h`: log scoped to the file.
141    pub fn open_log(&mut self, file_scoped: bool) {
142        self.open_log_inner(file_scoped, None, None);
143    }
144
145    /// Open the commit browser *at* a commit — the blame dive lands on
146    /// the row it was asked about (0011 §3), not the newest entry.
147    pub(crate) fn open_log_at(&mut self, sha: &str) {
148        self.open_log_inner(false, Some(sha.to_string()), None);
149    }
150
151    /// `Space g h` in visual mode: the history of the selected lines
152    /// (git log -L) — selection archaeology (0014 wave 4).
153    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        // `git log -L` speaks file line numbers: a partial remote
168        // window's lines are window-relative, and pretending they are
169        // file coordinates would show the history of the WRONG lines
170        // (0036: partial windows never masquerade as full-file Git
171        // inputs).
172        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        // the new surface document owns its request; registration
198        // happens before launch (replay contract)
199        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    // ---- surface interaction -------------------------------------------
244
245    /// Keys for readonly surface buffers (0001 §3): q closes, Enter
246    /// dives, and everything else flows through the shared Walker
247    /// command path — motions and yank resolve, mutations refuse
248    /// (0010 §6). The Walker owns the pending state, so `: / ?` and
249    /// multi-key sequences behave exactly as in normal mode.
250    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    /// Yank the plan's target ranges (shared with normal mode's
305    /// dispatch: one implementation, one behavior).
306    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    /// `q`: pop one surface (0011 §1). In a split the *pane* closes —
348    /// the buffer stays, vim `:q` semantics — and only the last pane's
349    /// close closes the buffer, running the guaranteed return-point
350    /// restore.
351    fn close_surface(&mut self) {
352        self.close_pane_or_buffer(true);
353    }
354}
355
356/// Added/deleted counts across hunks.
357pub(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
373/// The buffer text a diff surface shows: stats row, then per hunk a
374/// header row and unprefixed content rows — exactly the rendered
375/// layout (0010 §2).
376pub(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
390/// The git job channel ends (created once in `Editor::new`).
391pub fn git_channel() -> (Sender<GitJob>, Receiver<GitJob>) {
392    channel()
393}
394
395impl Editor {
396    /// Table shim (0008 stage 2).
397    pub(crate) fn open_log_pub(&mut self, file_scoped: bool) {
398        self.open_log(file_scoped);
399    }
400}
401
402impl Editor {
403    /// The current buffer's repo-relative path for `repo` — a local
404    /// buffer's path or a remote buffer's remote-file path, stripped
405    /// by the repository that owns it. A buffer never borrows another
406    /// machine's spelling: a remote file only resolves under its own
407    /// endpoint's repository, a local file only under a local one.
408    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;