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::{ReturnPoint, 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(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        // surfaces render via delta/plain rules: no tree-sitter;
110        // readonly derives from the source (0021 §4)
111        let id = self
112            .docs
113            .insert(super::Document::surface(buf, surface, context));
114        self.drop_stale_scratch(id);
115        self.push_jump(); // opening a surface is a jumplist entry
116        self.generation += 1; // document set changed: old jobs are stale (0011 §2)
117        self.switch_to(id);
118        self.set_head(0);
119        self.view_mut().view_top = 0;
120    }
121
122    /// A diff surface from structured hunks (0010 §2). `label` heads the
123    /// stats row; `origin` is set only for working-tree hunk previews.
124    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    /// `Space g l`: commit browser. `Space g h`: log scoped to the file.
146    pub fn open_log(&mut self, file_scoped: bool) {
147        self.open_log_inner(file_scoped, None, None);
148    }
149
150    /// Open the commit browser *at* a commit — the blame dive lands on
151    /// the row it was asked about (0011 §3), not the newest entry.
152    pub(crate) fn open_log_at(&mut self, sha: &str) {
153        self.open_log_inner(false, Some(sha.to_string()), None);
154    }
155
156    /// `Space g h` in visual mode: the history of the selected lines
157    /// (git log -L) — selection archaeology (0014 wave 4).
158    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        // `git log -L` speaks file line numbers: a partial remote
173        // window's lines are window-relative, and pretending they are
174        // file coordinates would show the history of the WRONG lines
175        // (0036: partial windows never masquerade as full-file Git
176        // inputs).
177        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        // the new surface document owns its request; registration
203        // happens before launch (replay contract)
204        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    // ---- surface interaction -------------------------------------------
249
250    /// Keys for readonly surface buffers (0001 §3): q closes, Enter
251    /// dives, and everything else flows through the shared Walker
252    /// command path — motions and yank resolve, mutations refuse
253    /// (0010 §6). The Walker owns the pending state, so `: / ?` and
254    /// multi-key sequences behave exactly as in normal mode.
255    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    /// Yank the plan's target ranges (shared with normal mode's
313    /// dispatch: one implementation, one behavior).
314    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    /// `q`: pop one surface (0011 §1). In a split the *pane* closes —
356    /// the buffer stays, vim `:q` semantics — and only the last pane's
357    /// close closes the buffer, running the guaranteed return-point
358    /// restore.
359    fn close_surface(&mut self) {
360        self.close_pane_or_buffer(true);
361    }
362}
363
364/// Added/deleted counts across hunks.
365pub(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
381/// The buffer text a diff surface shows: stats row, then per hunk a
382/// header row and unprefixed content rows — exactly the rendered
383/// layout (0010 §2).
384pub(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
398/// The git job channel ends (created once in `Editor::new`).
399pub fn git_channel() -> (Sender<GitJob>, Receiver<GitJob>) {
400    channel()
401}
402
403impl Editor {
404    /// Table shim (0008 stage 2).
405    pub(crate) fn open_log_pub(&mut self, file_scoped: bool) {
406        self.open_log(file_scoped);
407    }
408}
409
410impl Editor {
411    /// The current buffer's repo-relative path for `repo` — a local
412    /// buffer's path or a remote buffer's remote-file path, stripped
413    /// by the repository that owns it. A buffer never borrows another
414    /// machine's spelling: a remote file only resolves under its own
415    /// endpoint's repository, a local file only under a local one.
416    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;