git-ents.gitmain
⌘K
foforge
lens.rs529 lines · 19.7 KB · rusthistorycomment on this file
1//! The lens core: a read-time view over `refs/meta/comments/*` projected
2//! into whatever buffer the client has open, plus the compose flow that
3//! writes new comments back through the shared library.
4//!
5//! Every response is derived per request from an anchor projection onto the
6//! working tree — never cached across a comment-ref mutation (`lens.lenses`)
7//! — and every listing, projection, and write is the same
8//! `ents_forge::comment` call the CLI porcelain makes (`lens.parity`), so a
9//! comment is one entity across the editor, the CLI, and the web.
10
11use std::path::PathBuf;
12
13use ents_forge::comment::{self, ListFilter, Listed, NewComment};
14use ents_receive::{EventSink, Identity, Mode};
15use gix_object::{CommitRef, Find, Write};
16use gix_ref_store::RefStore;
17use lsp_types::{
18 CodeAction, CodeActionKind, CodeActionOrCommand, CodeLens, Command, Diagnostic, Hover,
19 HoverContents, Position, Range, Url,
20};
21use serde_json::{Value, json};
22
23use crate::compose::{self, Target};
24use crate::document::{self, Documents};
25use crate::error::{Error, Result};
26use crate::render;
27use crate::signing::Signing;
28
29/// What an `executeCommand` or a `didSave` produced, in protocol-neutral
30/// terms the server layer turns into LSP messages: an optional command
31/// result value, an optional file to open with `window/showDocument`, and
32/// whether the open documents' diagnostics should be republished
33/// (a comment-ref mutation invalidates every derived view, `lens.lenses`).
34#[derive(Debug, Default)]
35pub struct Outcome {
36 /// The `workspace/executeCommand` result value (the thread markup, for
37 /// View); `None` for a command whose effect is a side effect only.
38 pub response: Option<Value>,
39 /// A file the client should open (`window/showDocument`) — the compose
40 /// template, for Compose and Reply (`lens.compose`).
41 pub show_document: Option<PathBuf>,
42 /// Whether every open document's diagnostics should be recomputed and
43 /// republished, because a comment ref just changed.
44 pub refresh: bool,
45}
46
47/// The editor surface over one repository's comments. Holds the four
48/// composition-root seams it needs (the ref store, the object store, the
49/// event sink, and the signing identity — all injected, `lens.serve`), the
50/// gate mode, the working-tree path, and the client's open buffers; owns no
51/// derived state.
52///
53/// Generic over only the object store `O`, exactly as
54/// `ents_web::state::AppState` is and for the same reason: `refs` and
55/// `events` are already trait objects everywhere in this codebase, while
56/// every mutation primitive takes the object store as `&(impl Find +
57/// Write)`.
58pub struct Lens<O> {
59 refs: Box<dyn RefStore>,
60 objects: O,
61 events: Box<dyn EventSink>,
62 mode: Mode,
63 signing: Signing,
64 path: PathBuf,
65 documents: Documents,
66}
67
68impl<O: Find + Write> Lens<O> {
69 /// Wire a lens from already-resolved seams — the one constructor a
70 /// composition root calls (`git ents lsp`); the lens never opens a
71 /// store or resolves a key itself.
72 pub fn new(
73 refs: Box<dyn RefStore>,
74 objects: O,
75 events: Box<dyn EventSink>,
76 mode: Mode,
77 signing: Signing,
78 path: PathBuf,
79 ) -> Self {
80 Self {
81 refs,
82 objects,
83 events,
84 mode,
85 signing,
86 path,
87 documents: Documents::default(),
88 }
89 }
90
91 /// Record a document the client opened, with its full text
92 /// (`textDocument/didOpen`) — projection targets this buffer afterward
93 /// (`lens.working-tree`).
94 pub fn did_open(&mut self, uri: Url, text: String) {
95 self.documents.set(uri, text);
96 }
97
98 /// Replace an open document's text on a full-sync change
99 /// (`textDocument/didChange`), so ranges re-project against the unsaved
100 /// edit (`lens.working-tree`).
101 pub fn did_change(&mut self, uri: Url, text: String) {
102 self.documents.set(uri, text);
103 }
104
105 /// Forget a closed document (`textDocument/didClose`); projection falls
106 /// back to on-disk bytes.
107 pub fn did_close(&mut self, uri: &Url) {
108 self.documents.remove(uri);
109 }
110
111 /// The open comments (`model.comment-state`) anchored to `uri`'s
112 /// document, each projected onto its live buffer when open, on-disk
113 /// bytes otherwise — the one derivation every read response is built
114 /// from, recomputed here on every call and never cached (`lens.lenses`,
115 /// `lens.working-tree`, `lens.parity`).
116 ///
117 /// # Errors
118 ///
119 /// Propagates a ref-store, object, repository, or projection failure.
120 // @relation(lens.lenses, lens.working-tree, lens.parity, scope=function)
121 fn document_comments(&self, uri: &Url) -> Result<Vec<Listed>> {
122 let Some(rel) = document::relative_path(&self.path, uri) else {
123 return Ok(Vec::new());
124 };
125 let buffer = self.documents.text(uri).map(str::as_bytes);
126 let filter = ListFilter {
127 state: Some("open".to_owned()),
128 context: None,
129 };
130 let (rows, unreadable) = comment::list_for_document(
131 self.refs.as_ref(),
132 &self.objects,
133 &self.path,
134 &rel,
135 buffer,
136 &filter,
137 )?;
138 // An unreadable comment ref names no document, so it has no lens,
139 // diagnostic, or hover to appear in -- the web listing's
140 // disclosure and `git ents comment list`'s trailing note are the
141 // surfaces that report it; here it is dropped deliberately, not
142 // silently (the library returns it either way, `lens.parity`).
143 let _ = unreadable;
144 Ok(rows)
145 }
146
147 /// The code lenses for `uri` (`lens.lenses`): three per open comment
148 /// that projects onto the document — a summary lens plus Reply and
149 /// Resolve — omitting a comment whose anchor no longer lands there.
150 ///
151 /// # Errors
152 ///
153 /// Propagates a ref-store, object, repository, or projection failure.
154 // @relation(lens.lenses, scope=function)
155 pub fn code_lenses(&self, uri: &Url) -> Result<Vec<CodeLens>> {
156 let mut out = Vec::new();
157 for row in self.document_comments(uri)? {
158 if let Some((range, outdated)) = landed(&row) {
159 out.extend(render::code_lenses(&row.id, &row.comment, range, outdated));
160 }
161 }
162 Ok(out)
163 }
164
165 /// The hint-severity diagnostics for `uri` (`lens.diagnostics`): the
166 /// same projected comments as the lenses, one hint each, for clients
167 /// that do not render lenses. Never a warning or error.
168 ///
169 /// # Errors
170 ///
171 /// Propagates a ref-store, object, repository, or projection failure.
172 // @relation(lens.diagnostics, scope=function)
173 pub fn diagnostics(&self, uri: &Url) -> Result<Vec<Diagnostic>> {
174 let mut out = Vec::new();
175 for row in self.document_comments(uri)? {
176 if let Some((range, outdated)) = landed(&row) {
177 out.push(render::diagnostic(&row.id, &row.comment, range, outdated));
178 }
179 }
180 Ok(out)
181 }
182
183 /// The hover for a position in `uri` (`lens.hover`): if it falls on a
184 /// projected comment's range, the whole thread rendered as Markdown —
185 /// bodies, states, and authorship read from each ref's commit chain.
186 ///
187 /// # Errors
188 ///
189 /// Propagates a ref-store, object, repository, or projection failure.
190 // @relation(lens.hover, scope=function)
191 pub fn hover(&self, uri: &Url, position: Position) -> Result<Option<Hover>> {
192 for row in self.document_comments(uri)? {
193 let Some((range, _outdated)) = landed(&row) else {
194 continue;
195 };
196 if position_in(position, range) {
197 let markup = self.thread_markup(&row.id)?;
198 return Ok(Some(Hover {
199 contents: HoverContents::Markup(markup),
200 range: Some(range),
201 }));
202 }
203 }
204 Ok(None)
205 }
206
207 /// The code actions for a selection in `uri` (`lens.compose`): the
208 /// "Leave an ents comment" action, whose command opens the compose
209 /// template anchored to exactly the selected lines against the working
210 /// tree. Empty when the URI is not a file in the working tree.
211 ///
212 /// # Errors
213 ///
214 /// Never fails today; returns [`Result`] for symmetry with the other
215 /// request handlers.
216 // @relation(lens.compose, scope=function)
217 pub fn code_actions(&self, uri: &Url, range: Range) -> Result<Vec<CodeActionOrCommand>> {
218 let Some(rel) = document::relative_path(&self.path, uri) else {
219 return Ok(Vec::new());
220 };
221 let lines = selection_lines(range);
222 let command = Command {
223 title: "Leave an ents comment".to_owned(),
224 command: render::CMD_COMPOSE.to_owned(),
225 arguments: Some(vec![json!({ "path": rel, "lines": lines })]),
226 };
227 Ok(vec![CodeActionOrCommand::CodeAction(CodeAction {
228 title: "Leave an ents comment".to_owned(),
229 kind: Some(CodeActionKind::EMPTY),
230 diagnostics: None,
231 edit: None,
232 command: Some(command),
233 is_preferred: None,
234 disabled: None,
235 data: None,
236 })])
237 }
238
239 /// Run a `workspace/executeCommand` the lens registered
240 /// (`lens.lenses`, `lens.compose`): View returns the thread, Resolve
241 /// records the state mutation through the shared library call, and
242 /// Reply/Compose open the compose template.
243 ///
244 /// # Errors
245 ///
246 /// [`Error::BadArguments`] for a missing or malformed argument;
247 /// otherwise propagates the underlying comment library or template
248 /// failure.
249 // @relation(lens.lenses, lens.compose, lens.parity, scope=function)
250 pub fn execute_command(&self, command: &str, arguments: &[Value]) -> Result<Outcome> {
251 match command {
252 render::CMD_VIEW => {
253 let id = arg_id(arguments)?;
254 let markup = self.thread_markup(&id)?;
255 Ok(Outcome {
256 response: Some(json!(markup.value)),
257 ..Outcome::default()
258 })
259 }
260 render::CMD_RESOLVE => {
261 let id = arg_id(arguments)?;
262 let signer = &self.signing;
263 let sign = |payload: &[u8]| signer.sign(payload);
264 let identity = Identity {
265 actor: signer.actor(),
266 author: None,
267 sign: &sign,
268 };
269 comment::resolve(
270 self.refs.as_ref(),
271 &self.objects,
272 self.events.as_ref(),
273 &id,
274 &identity,
275 self.mode,
276 Some(signer.public_openssh()),
277 )?;
278 Ok(Outcome {
279 refresh: true,
280 ..Outcome::default()
281 })
282 }
283 render::CMD_REPLY => {
284 let id = arg_id(arguments)?;
285 let target = Target {
286 parent: Some(id),
287 ..Target::default()
288 };
289 let template = self.write_template(&target)?;
290 Ok(Outcome {
291 show_document: Some(template),
292 ..Outcome::default()
293 })
294 }
295 render::CMD_COMPOSE => {
296 let target = compose_target(arguments)?;
297 let template = self.write_template(&target)?;
298 Ok(Outcome {
299 show_document: Some(template),
300 ..Outcome::default()
301 })
302 }
303 other => Err(Error::BadArguments(format!("unknown command {other}"))),
304 }
305 }
306
307 /// Handle a `textDocument/didSave`: if the saved file is the compose
308 /// template, finalize the comment (`lens.compose`); otherwise recompute
309 /// diagnostics, since the saved buffer now matches disk.
310 ///
311 /// # Errors
312 ///
313 /// Propagates a template read or comment-creation failure.
314 // @relation(lens.compose, scope=function)
315 pub fn did_save(&self, uri: &Url) -> Result<Outcome> {
316 if self.is_template(uri) {
317 return self.finalize_compose();
318 }
319 Ok(Outcome {
320 refresh: true,
321 ..Outcome::default()
322 })
323 }
324
325 /// Every open document's URI — the server republishes diagnostics for
326 /// these after a mutation ([`Outcome::refresh`]).
327 #[must_use]
328 pub fn open_documents(&self) -> Vec<Url> {
329 self.documents.open_uris()
330 }
331
332 /// Diagnostics for `uri` even when the document is not open — the server
333 /// uses this to clear or refresh a specific document.
334 ///
335 /// # Errors
336 ///
337 /// See [`Lens::diagnostics`].
338 pub fn diagnostics_for(&self, uri: &Url) -> Result<Vec<Diagnostic>> {
339 self.diagnostics(uri)
340 }
341
342 /// The compose template's absolute path, `<git-dir>/ENTS_COMMENT_EDITMSG`
343 /// (`lens.compose`).
344 fn template_path(&self) -> Result<PathBuf> {
345 let repo = gix::open(&self.path)?;
346 Ok(repo.git_dir().join("ENTS_COMMENT_EDITMSG"))
347 }
348
349 /// Whether `uri` names the compose template (a saved-template event).
350 fn is_template(&self, uri: &Url) -> bool {
351 let Ok(template) = self.template_path() else {
352 return false;
353 };
354 let Ok(saved) = uri.to_file_path() else {
355 return false;
356 };
357 let template = template.canonicalize().unwrap_or(template);
358 let saved = saved.canonicalize().unwrap_or(saved);
359 saved == template
360 }
361
362 /// Write the compose template for `target` and return its path
363 /// (`lens.compose`).
364 fn write_template(&self, target: &Target) -> Result<PathBuf> {
365 let template = self.template_path()?;
366 let text = compose::template_text(target);
367 std::fs::write(&template, text).map_err(|source| Error::Template {
368 path: template.clone(),
369 source,
370 })?;
371 Ok(template)
372 }
373
374 /// Read the saved template and create the comment it describes through
375 /// the shared library call (`lens.parity`), anchoring to the working
376 /// tree (`lens.working-tree`); an empty body aborts (`lens.compose`).
377 /// The template is always removed afterward so a stale one is never
378 /// reused.
379 fn finalize_compose(&self) -> Result<Outcome> {
380 let template = self.template_path()?;
381 let content = std::fs::read_to_string(&template).map_err(|source| Error::Template {
382 path: template.clone(),
383 source,
384 })?;
385 let composed = compose::parse(&content);
386 // Best effort: a leftover template is harmless — the next compose
387 // overwrites it — so a removal failure never aborts a comment that
388 // was otherwise created successfully.
389 if let Err(_error) = std::fs::remove_file(&template) {}
390 if composed.is_abort() {
391 return Ok(Outcome::default());
392 }
393
394 let signer = &self.signing;
395 let sign = |payload: &[u8]| signer.sign(payload);
396 let identity = Identity {
397 actor: signer.actor(),
398 author: None,
399 sign: &sign,
400 };
401 if let Some(parent) = composed.target.parent {
402 comment::reply(
403 self.refs.as_ref(),
404 &self.objects,
405 self.events.as_ref(),
406 &parent,
407 composed.body,
408 &identity,
409 self.mode,
410 )?;
411 } else {
412 let new = NewComment {
413 body: composed.body,
414 path: composed.target.path,
415 lines: composed.target.lines,
416 rev: "HEAD".to_owned(),
417 worktree: true,
418 context: None,
419 parent: None,
420 };
421 comment::add(
422 self.refs.as_ref(),
423 &self.objects,
424 self.events.as_ref(),
425 &self.path,
426 new,
427 &identity,
428 self.mode,
429 )?;
430 }
431 Ok(Outcome {
432 refresh: true,
433 ..Outcome::default()
434 })
435 }
436
437 /// The whole thread rooted at `root_id` as hover Markdown (`lens.hover`)
438 /// — root plus replies (`thread_of`), each stamped with the author and
439 /// time read from its ref's tip mutation commit (`meta-ref.identity-binding`).
440 fn thread_markup(&self, root_id: &str) -> Result<lsp_types::MarkupContent> {
441 let rows = comment::thread_of(self.refs.as_ref(), &self.objects, root_id)?;
442 let mut with_authors = Vec::with_capacity(rows.len());
443 for (id, comment) in rows {
444 let (author, when) = self
445 .authorship(&id)
446 .unwrap_or_else(|| ("unknown".to_owned(), String::new()));
447 with_authors.push((id, comment, author, when));
448 }
449 Ok(render::hover_markup(&with_authors))
450 }
451
452 /// The author display name and a short date for the comment at `id`,
453 /// read from its ref's tip mutation commit (`model.comment`: authorship
454 /// lives in the commit chain, never a stored field). `None` when the
455 /// ref or its commit cannot be read.
456 fn authorship(&self, id: &str) -> Option<(String, String)> {
457 let ref_name = ents_model::namespace::comment_ref(id).ok()?;
458 let tip = self.refs.get(ref_name.as_ref()).ok().flatten()?;
459 let mut buf = Vec::new();
460 let data = Find::try_find(&self.objects, &tip, &mut buf).ok()??;
461 let commit = CommitRef::from_bytes(data.data, tip.kind()).ok()?;
462 let author = commit.author().ok()?;
463 let name = author.name.to_string();
464 let when = author
465 .time()
466 .ok()
467 .and_then(|time| time.format(gix::date::time::format::SHORT).ok())
468 .unwrap_or_default();
469 Some((name, when))
470 }
471}
472
473/// The `(range, outdated)` a listed comment lands at, or `None` when it does
474/// not project onto the document (deleted, or unanchored).
475fn landed(row: &Listed) -> Option<(Range, bool)> {
476 let anchor = row.anchor.as_ref()?;
477 let projection = row.projection.as_ref()?;
478 render::landed_range(projection, anchor)
479}
480
481/// Whether `position`'s line falls within `range` — hovering anywhere on an
482/// anchored line reveals its thread.
483fn position_in(position: Position, range: Range) -> bool {
484 position.line >= range.start.line && position.line <= range.end.line
485}
486
487/// The 1-based inclusive `<start>:<end>` line span a selection covers,
488/// collapsing a trailing full-line boundary (`end` at column 0 of the next
489/// line) back onto the last selected line.
490fn selection_lines(range: Range) -> String {
491 let start = range.start.line.saturating_add(1);
492 let end = if range.end.character == 0 && range.end.line > range.start.line {
493 range.end.line
494 } else {
495 range.end.line.saturating_add(1)
496 };
497 format!("{start}:{end}")
498}
499
500/// Extract a single comment-id string argument.
501fn arg_id(arguments: &[Value]) -> Result<String> {
502 arguments
503 .first()
504 .and_then(Value::as_str)
505 .map(str::to_owned)
506 .ok_or_else(|| Error::BadArguments("expected a comment id argument".to_owned()))
507}
508
509/// Extract a [`Target`] from the `ents.compose` command's `{path,
510/// lines}` object argument.
511fn compose_target(arguments: &[Value]) -> Result<Target> {
512 let object = arguments
513 .first()
514 .ok_or_else(|| Error::BadArguments("compose needs a target".to_owned()))?;
515 Ok(Target {
516 path: object
517 .get("path")
518 .and_then(Value::as_str)
519 .map(str::to_owned),
520 lines: object
521 .get("lines")
522 .and_then(Value::as_str)
523 .map(str::to_owned),
524 parent: object
525 .get("parent")
526 .and_then(Value::as_str)
527 .map(str::to_owned),
528 })
529}