Skip to main content

strop_engine/editor/
blame.rs

1//! Blame (0011 §3, R9/R6): the left-margin gutter (rootle's shape),
2//! the card, and the dive into the blamed commit. Requests own
3//! tickets; a stale revision, a superseded request or an edited buffer
4//! drops the pairing honestly — and every failure is terminal, so the
5//! next explicit toggle genuinely starts a new request.
6//!
7//! Remote buffers (0036 RW8) blame through the same seam: bounded
8//! remote `git blame` on the endpoint, keyed by an endpoint-qualified
9//! identity so a remote entry can never pair with a local buffer — or
10//! the reverse. Blame speaks file line numbers, so a partial remote
11//! window refuses instead of attributing window-relative lines.
12
13use std::path::PathBuf;
14
15use strop_core::worker::{CancelReason, Outcome};
16
17use super::git_memory::{BlameGutter, BlameKey, CardKey, GitJob};
18use super::Editor;
19
20impl Editor {
21    /// `Space g b`: on a file buffer (local, or a remote buffer with a
22    /// complete window), toggle the blame gutter; anywhere else (or as
23    /// feedback while the gutter loads) the single-line card (0011 §3).
24    pub(crate) fn toggle_blame_gutter(&mut self) {
25        let remote = self.remote_file().cloned();
26        let local_file = remote.is_none() && !self.buf().readonly && self.buf().path.is_some();
27        if !local_file {
28            if remote.is_some() {
29                if !self.remote_window_complete() {
30                    self.message = "partial remote snapshot — blame needs a full window".into();
31                    return;
32                }
33            } else {
34                return self.blame_line();
35            }
36        }
37        let Some(context) = self.git_context().cloned() else {
38            self.message = "not a git repo".into();
39            return;
40        };
41        // the context must be this buffer's repository: a remote file
42        // blames on its own endpoint, never any local workdir
43        if let Some(file) = &remote {
44            if context.repo.endpoint() != Some(file.endpoint()) {
45                self.message = "buffer's repository is not the current context".into();
46                return;
47            }
48        }
49        let key = self.current();
50        if let Some(mut gutter) = self.blame_gutters.remove(&key) {
51            // toggle off: revoke the pending request — its late result
52            // cannot repopulate a newer incarnation of this entry
53            if let Some(ticket) = gutter.request.take() {
54                self.cancel_git_worker(ticket.request, CancelReason::Dismissed);
55            }
56            return;
57        }
58        let doc = self.current();
59        let revision = self.buf().revision();
60        let Some(file) = self.doc(doc).file_target(&self.cwd) else {
61            return;
62        };
63        // the loading marker owns its ticket BEFORE any launch decision
64        // (replay contract); pure-validation failures settle through
65        // the same terminal path as worker failures
66        let Some(ticket) = self.git_ticket(BlameKey {
67            document: doc,
68            revision,
69            file,
70            repo: context.repo.clone(),
71        }) else {
72            return;
73        };
74        self.blame_gutters.insert(
75            key,
76            BlameGutter {
77                lines: Vec::new(),
78                revision,
79                request: Some(ticket.clone()),
80            },
81        );
82        self.spawn_blame_file(ticket);
83        self.blame_line(); // the card covers the line until data lands
84    }
85
86    fn spawn_blame_file(&mut self, ticket: strop_core::worker::Ticket<BlameKey>) {
87        let repo = ticket.key.repo.clone();
88        let rel = match (&repo, &ticket.key.file) {
89            (strop_git::RepoTarget::Local { .. }, crate::files::FileTarget::Local(path)) => {
90                repo.rel_of(path)
91            }
92            (
93                strop_git::RepoTarget::Remote { endpoint, .. },
94                crate::files::FileTarget::Remote(location),
95            ) if location.endpoint() == endpoint => location
96                .absolute_file()
97                .and_then(|file| repo.rel_of(file.path())),
98            _ => None,
99        };
100        let Some(rel) = rel else {
101            self.send_git_failure(
102                ticket,
103                strop_core::worker::FailureKind::InvalidInput,
104                "blame failed: buffer not under workdir",
105                GitJob::Gutter,
106            );
107            return;
108        };
109        strop_trace::record_with(strop_trace::EventKind::JobStarted, || {
110            serde_json::json!({
111                "service":"git","request":"blame_gutter",
112                "repository":if repo.is_remote() { "remote" } else { "local" },
113                "file": &ticket.key.file,
114                "document":{"slot":ticket.key.document.index(),"generation":ticket.key.document.generation()},
115                "revision":ticket.key.revision.get(),
116            })
117        });
118        let args = ticket.clone();
119        self.launch_git_job(
120            "git-blame-file",
121            "git.blame_gutter",
122            ticket,
123            &args,
124            GitJob::Gutter,
125            move |cancel| {
126                if cancel.is_cancelled() {
127                    return Outcome::Cancelled(CancelReason::Superseded);
128                }
129                let exec = strop_git::GitExec::for_target(&repo);
130                match strop_git::memory::blame_file(&exec, &cancel, &rel) {
131                    Ok(lines) => Outcome::Success(lines),
132                    Err(message) => Outcome::failed(strop_core::worker::FailureKind::Exit, message),
133                }
134            },
135        );
136    }
137
138    /// The buffer's blame gutter, if its data is still trustworthy:
139    /// same revision, same line count. Any edit since the capture
140    /// voids the line↔buffer-line pairing. A remote buffer pairs only
141    /// with its endpoint-qualified entry.
142    pub fn blame_gutter_for(&self, buffer: strop_core::id::DocumentId) -> Option<&BlameGutter> {
143        let document = self.docs.get(buffer)?;
144        let buf = &document.buf;
145        if document
146            .remote_metadata()
147            .is_some_and(|source| !source.window.is_complete())
148            || self.remote_following(buffer)
149        {
150            return None;
151        }
152        let gutter = self.blame_gutters.get(&buffer)?;
153        // len_lines counts the trailing newline's phantom line — the
154        // content count is what blame rows pair with
155        let content_lines = buf.last_content_line() + 1;
156        (gutter.revision == buf.revision() && gutter.lines.len() == content_lines).then_some(gutter)
157    }
158
159    /// `Space g b` fallback / surface blame: the card for the cursor
160    /// line. One card request at a time — a new request supersedes (and
161    /// cancels) the old one. The card speaks file line numbers: a
162    /// partial remote window refuses rather than blaming the wrong
163    /// line of the file.
164    pub(crate) fn blame_line(&mut self) {
165        match self.remote_file().cloned() {
166            Some(file) => {
167                // the card names a file line: a partial window's line
168                // numbers are window-relative — refuse, never blame
169                // the wrong line of the file
170                if !self.remote_window_complete() {
171                    self.message = "partial remote snapshot — blame needs a full window".into();
172                    return;
173                }
174                self.blame_line_remote(file);
175            }
176            None => match self.buf().path.clone() {
177                Some(path) => self.blame_line_local(path),
178                None => self.message = "blame works on file buffers".into(),
179            },
180        }
181    }
182    fn blame_line_local(&mut self, path: PathBuf) {
183        let Some(context) = self.git_context().cloned() else {
184            self.message = "not a git repo".into();
185            return;
186        };
187        if context.repo.is_remote() {
188            self.message = "buffer's repository is not the current context".into();
189            return;
190        }
191        if let Some(ticket) = self.card_request.take() {
192            self.cancel_git_worker(ticket.request, CancelReason::Superseded);
193        }
194        let doc = self.current();
195        let revision = self.buf().revision();
196        let line = self.buf().line_of(self.head()) + 1;
197        let Some(file) = self.doc(doc).file_target(&self.cwd) else {
198            return;
199        };
200        let Some(ticket) = self.git_ticket(CardKey {
201            origin: BlameKey {
202                document: doc,
203                revision,
204                file,
205                repo: context.repo.clone(),
206            },
207            line,
208        }) else {
209            return;
210        };
211        self.card_request = Some(ticket.clone());
212        strop_trace::record_with(strop_trace::EventKind::JobStarted, || {
213            serde_json::json!({
214                "service":"git","request":"blame_line",
215                "repository":"local",
216                "path":path.to_string_lossy(),"line":line,
217                "document":{"slot":doc.index(),"generation":doc.generation()},
218                "revision":revision.get(),
219            })
220        });
221        let abs = if path.is_absolute() {
222            path.clone()
223        } else {
224            context.workdir().join(&path)
225        };
226        let Some(rel) = context.repo.rel_of(&abs) else {
227            self.send_git_failure(
228                ticket,
229                strop_core::worker::FailureKind::InvalidInput,
230                "blame failed: not under workdir",
231                GitJob::Card,
232            );
233            return;
234        };
235        self.launch_blame_line(ticket, context.repo, rel, line);
236    }
237
238    fn blame_line_remote(&mut self, file: strop_workspace::RemoteFile) {
239        let Some(context) = self.git_context().cloned() else {
240            self.message = "not a git repo".into();
241            return;
242        };
243        if context.repo.endpoint() != Some(file.endpoint()) {
244            self.message = "buffer's repository is not the current context".into();
245            return;
246        }
247        if let Some(ticket) = self.card_request.take() {
248            self.cancel_git_worker(ticket.request, CancelReason::Superseded);
249        }
250        let doc = self.current();
251        let revision = self.buf().revision();
252        let line = self.buf().line_of(self.head()) + 1;
253        let Some(ticket) = self.git_ticket(CardKey {
254            origin: BlameKey {
255                document: doc,
256                revision,
257                file: crate::files::FileTarget::Remote(file.clone().into()),
258                repo: context.repo.clone(),
259            },
260            line,
261        }) else {
262            return;
263        };
264        self.card_request = Some(ticket.clone());
265        strop_trace::record_with(strop_trace::EventKind::JobStarted, || {
266            serde_json::json!({
267                "service":"git","request":"blame_line",
268                "repository":"remote",
269                "path":file.path().to_string_lossy(),"line":line,
270                "document":{"slot":doc.index(),"generation":doc.generation()},
271                "revision":revision.get(),
272            })
273        });
274        let Some(rel) = context.repo.rel_of(file.path()) else {
275            self.send_git_failure(
276                ticket,
277                strop_core::worker::FailureKind::InvalidInput,
278                "blame failed: buffer not under the remote workdir",
279                GitJob::Card,
280            );
281            return;
282        };
283        self.launch_blame_line(ticket, context.repo, rel, line);
284    }
285
286    /// The card's one bounded run: `git blame --line-porcelain -L` on
287    /// whichever backend owns the repository.
288    fn launch_blame_line(
289        &mut self,
290        ticket: strop_core::worker::Ticket<CardKey>,
291        repo: strop_git::RepoTarget,
292        rel: PathBuf,
293        line: usize,
294    ) {
295        let args = ticket.clone();
296        self.launch_git_job(
297            "git-blame-line",
298            "git.blame_line",
299            ticket,
300            &args,
301            GitJob::Card,
302            move |cancel| {
303                if cancel.is_cancelled() {
304                    return Outcome::Cancelled(CancelReason::Superseded);
305                }
306                let exec = strop_git::GitExec::for_target(&repo);
307                match strop_git::memory::blame_line(&exec, &cancel, &rel, line) {
308                    Ok(card) => Outcome::Success(Box::new(card)),
309                    Err(message) => Outcome::failed(strop_core::worker::FailureKind::Exit, message),
310                }
311            },
312        );
313    }
314
315    /// Card authority dismissal (the feed path calls this BEFORE the
316    /// key is interpreted): a visible card is taken (the caller decides
317    /// whether the key also acts), and any pending request is revoked —
318    /// a late card can never reappear after its dismissal.
319    pub(crate) fn dismiss_card_authority(&mut self) -> Option<strop_git::memory::BlameCard> {
320        if let Some(ticket) = self.card_request.take() {
321            self.cancel_git_worker(ticket.request, CancelReason::Dismissed);
322        }
323        self.blame_card.take()
324    }
325}