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_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    /// Yank the plan's target ranges (shared with normal mode's
308    /// dispatch: one implementation, one behavior).
309    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    /// `q`: pop one surface (0011 §1). In a split the *pane* closes —
351    /// the buffer stays, vim `:q` semantics — and only the last pane's
352    /// close closes the buffer, running the guaranteed return-point
353    /// restore.
354    fn close_surface(&mut self) {
355        self.close_pane_or_buffer(true);
356    }
357}
358
359/// Added/deleted counts across hunks.
360pub(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
376/// The buffer text a diff surface shows: stats row, then per hunk a
377/// header row and unprefixed content rows — exactly the rendered
378/// layout (0010 §2).
379pub(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
393/// The git job channel ends (created once in `Editor::new`).
394pub fn git_channel() -> (Sender<GitJob>, Receiver<GitJob>) {
395    channel()
396}
397
398impl Editor {
399    /// Table shim (0008 stage 2).
400    pub(crate) fn open_log_pub(&mut self, file_scoped: bool) {
401        self.open_log(file_scoped);
402    }
403}
404
405impl Editor {
406    /// The current buffer's repo-relative path for `repo` — a local
407    /// buffer's path or a remote buffer's remote-file path, stripped
408    /// by the repository that owns it. A buffer never borrows another
409    /// machine's spelling: a remote file only resolves under its own
410    /// endpoint's repository, a local file only under a local one.
411    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;