git-ents.gitmain
⌘K
foforge
commit 6672b76
lens: add per-document projection and root-thread seams to ents-forge

The editor lens reads and projects comments through the same library mechanism the CLI porcelain uses (lens.parity), but two shapes it needs were exposed only for the CLI’s whole-working-tree listing.

Add list_for_document — the per-document, buffer-aware companion to list_projected, projecting each comment anchored to one path onto the editor’s in-memory buffer when present, on-disk bytes otherwise, so the lens tracks unsaved edits (lens.working-tree, lens.parity).

Add thread_of — the anchored-comment counterpart to thread, seeding the same parent-closure walk from a root comment id rather than a context, so the lens can render the whole conversation on hover for a code comment that carries no context (lens.hover, model.comment-thread).

Assisted-by: Claude:claude-opus-4-8

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

crates/forge/ents-forge/src/comment/command.rs @@ -476,6 +476,124 @@ .collect()) } +/// Every open (per `filter`) comment whose anchor lands on `target_path` +/// in the working tree, each projected onto `buffer` — the editor's +/// in-memory bytes for that document — when one is given, or the on-disk +/// file otherwise (`anchor.working-tree`, `lens.working-tree`). +/// +/// This is the per-document, buffer-aware companion to [`list_projected`]: +/// [`list_projected`] projects every comment onto the plain on-disk working +/// tree at once, but the editor lens holds one open document at a time and +/// must project onto that document's unsaved buffer content when it differs +/// from disk, so ranges track edits the user has not saved yet. Projection +/// on the working tree never follows a rename ([`project_worktree`] consults +/// only [`Anchor::path`]), so a comment lands on exactly its own anchored +/// path: filtering to `target_path` here is the whole of "which comments +/// belong to this document". A [`Projection::Deleted`] row is still +/// returned; the caller decides whether a deleted anchor surfaces (the lens +/// omits it, since a deleted anchor no longer projects onto the buffer). +/// +/// The lens calls this rather than reimplementing the read-and-project loop +/// itself (`lens.parity`): listing and projection are one mechanism shared +/// by the CLI, the web UI, and the editor. +/// +/// # Errors +/// +/// Propagates a ref-store, object read, repository open, or projection +/// failure. +// @relation(lens.parity, lens.working-tree, model.comment-state, scope=function) +pub fn list_for_document( + refs: &dyn RefStoreRead, + objects: &impl Find, + repo_path: &std::path::Path, + target_path: &str, + buffer: Option<&[u8]>, + filter: &ListFilter, +) -> Result<Vec<Listed>> { + let repo = gix::open(repo_path)?; + let mut out = Vec::new(); + for (id, comment) in list(refs, objects)? { + if let Some(state) = &filter.state + && comment.state != *state + { + continue; + } + if let Some(context) = &filter.context + && comment.context.as_ref() != Some(context) + { + continue; + } + let Some(raw) = &comment.anchor else { + continue; + }; + let anchor = facet_git_tree::deserialize::<Anchor>(&raw.oid(), objects)?; + if anchor.path != target_path { + continue; + } + let projection = project_worktree(&repo, &anchor, buffer)?; + out.push(Listed { + id, + comment, + anchor: Some(anchor), + projection: Some(projection), + }); + } + Ok(out) +} + +/// The thread rooted at `root_id` (`model.comment-thread`): the comment +/// with that id, plus every reply whose parent chain reaches it — the +/// anchored-comment counterpart to [`thread`], which aggregates by +/// `context` instead. Rows come back sorted by id, root and replies alike; +/// the `parent` field reconstructs the tree. +/// +/// The lens shows a code lens per anchored root comment and, on hover, +/// the whole conversation attached to it (`lens.hover`); an anchored root +/// need carry no `context`, so [`thread`]'s context aggregation cannot find +/// its replies — this seeds the same parent-closure walk from the root id +/// directly. Reads back through the legacy-shape fallback like every other +/// read here (`meta-ref.migration`). +/// +/// # Errors +/// +/// Propagates a ref-store or object read failure. +// @relation(model.comment-thread, lens.hover, scope=function) +pub fn thread_of( + refs: &dyn RefStoreRead, + objects: &impl Find, + root_id: &str, +) -> Result<Vec<(String, Comment)>> { + let all = list(refs, objects)?; + let mut included: std::collections::BTreeMap<&str, &Comment> = all + .iter() + .filter(|(id, _comment)| id == root_id) + .map(|(id, comment)| (id.as_str(), comment)) + .collect(); + // Close over parent links exactly as `thread` does, but seeded with the + // root id rather than a context: a reply names a comment already in the + // thread, transitively. + loop { + let mut grew = false; + for (id, comment) in &all { + if included.contains_key(id.as_str()) { + continue; + } + if let Some(parent) = &comment.parent + && included.contains_key(parent.as_str()) + { + included.insert(id.as_str(), comment); + grew = true; + } + } + if !grew { + break; + } + } + Ok(included + .into_iter() + .map(|(id, comment)| (id.to_owned(), comment.clone())) + .collect()) +} /// Validate a `model.comment-context` value: the canonical ref path below /// `refs/meta/` of the entity the comment belongs to, such as /// `issues/<id>` — checked by building the full refname it names.
crates/forge/ents-forge/src/comment/mod.rs @@ -10,6 +10,7 @@ pub use cli::CommentAction; pub use command::{ - ListFilter, Listed, NewComment, add, list, list_projected, reopen, reply, resolve, show, thread, + ListFilter, Listed, NewComment, add, list, list_for_document, list_projected, reopen, reply, + resolve, show, thread, thread_of, }; pub use entity::Comment;