1use forge_primitives::review::ReviewThread;
4use forge_primitives::{ReviewSet, SessionSlot};
5use forge_workspace::env::git_diff::resolver::{
6 AnchorPlacement, place_anchor_in_lines, place_document_anchor,
7};
8
9use super::ViewSurface;
10
11pub struct ReviewsView {
18 pub threads: Result<Vec<ReviewThread>, String>,
19 pub reviews: Result<Vec<ReviewSet>, String>,
20}
21
22pub struct PlacedThread {
27 pub thread: ReviewThread,
28 pub placement: Option<AnchorPlacement>,
29}
30
31pub struct TreeReviewsView {
37 pub scope: String,
38 pub threads: Result<Vec<PlacedThread>, String>,
39 pub reviews: Result<Vec<ReviewSet>, String>,
40}
41
42impl ViewSurface {
43 pub fn reviews(&self, project: &str, branch: &str) -> ReviewsView {
44 ReviewsView {
45 threads: self.workspace.load_review_threads(project, branch),
46 reviews: self.workspace.load_reviews(project, branch),
47 }
48 }
49
50 pub fn tree_reviews(&self, seat: &SessionSlot) -> TreeReviewsView {
54 let scope = forge_primitives::review::tree_scope_ref(seat);
55 let view = self.reviews(seat.project(), &scope);
56 let threads = view.threads.map(|threads| self.place_tree_threads(seat, threads));
57 TreeReviewsView { scope, threads, reviews: view.reviews }
58 }
59
60 fn place_tree_threads(
70 &self,
71 seat: &SessionSlot,
72 threads: Vec<ReviewThread>,
73 ) -> Vec<PlacedThread> {
74 let cwd = self.roster().cwd_for(seat);
75 let roots = self.roster().document_roots_for(seat);
76 let ceiling = self.workspace.document_max_bytes();
77 let mut files: std::collections::HashMap<String, Option<Vec<String>>> =
78 std::collections::HashMap::new();
79 threads
80 .into_iter()
81 .map(|thread| {
82 let placement = cwd.as_ref().and_then(|cwd| {
83 let lines = files.entry(thread.anchor.path.clone()).or_insert_with(|| {
84 forge_workspace::env::documents::read(
85 cwd,
86 &roots,
87 &thread.anchor.path,
88 false,
89 ceiling,
90 )
91 .ok()
92 .and_then(|read| String::from_utf8(read.bytes).ok())
93 .map(|text| text.lines().map(str::to_owned).collect())
94 });
95 lines.as_ref().map(|lines| place_anchor_in_lines(&thread.anchor, lines))
96 });
97 PlacedThread { thread, placement }
98 })
99 .collect()
100 }
101
102 pub fn place_document_threads(
109 &self,
110 threads: Vec<ReviewThread>,
111 lines: Option<&[String]>,
112 ) -> Vec<PlacedThread> {
113 threads
114 .into_iter()
115 .map(|thread| {
116 let placement = lines.map(|lines| place_document_anchor(&thread.anchor, lines));
117 PlacedThread { thread, placement }
118 })
119 .collect()
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use std::sync::Arc;
126
127 use forge_primitives::review::{
128 ReviewAnchor, ReviewAuthor, ReviewComment, ReviewSide, ReviewStatus,
129 };
130 use forge_workspace::{SessionSlot, Workspace};
131
132 use super::*;
133
134 fn thread(id: &str, line: u32) -> ReviewThread {
135 ReviewThread {
136 id: id.to_owned(),
137 anchor: ReviewAnchor {
138 path: "src/lib.rs".to_owned(),
139 side: ReviewSide::New,
140 line,
141 content_hash: u64::from(line),
142 context: vec!["ctx".to_owned()],
143 base_ref: "main".to_owned(),
144 },
145 comments: vec![ReviewComment {
146 author: ReviewAuthor::User,
147 text: format!("c{id}"),
148 at: "2026-07-19T10:00:00Z".to_owned(),
149 review_id: None,
150 }],
151 status: ReviewStatus::Open,
152 created_at: "2026-07-19T10:00:00Z".to_owned(),
153 updated_at: "2026-07-19T10:00:00Z".to_owned(),
154 commit: None,
155 }
156 }
157
158 #[test]
162 fn reviews_agrees_with_the_calls_it_replaces() {
163 let (workspace, _dir) = crate::surface::testing::workspace();
164 assert!(
165 workspace.upsert_review_thread("forge", "feat", thread("a", 10)),
166 "the seeded thread was stored",
167 );
168 workspace
169 .submit_review(
170 "forge",
171 "feat",
172 Some("first pass".to_owned()),
173 &["a".to_owned()],
174 SessionSlot::from_str_for_test("reviewer"),
175 )
176 .expect("seeded review set");
177
178 let view = ViewSurface::new(Arc::clone(&workspace)).reviews("forge", "feat");
179
180 assert_eq!(
181 view.threads,
182 workspace.load_review_threads("forge", "feat"),
183 "the threads half is the call's own",
184 );
185 assert_eq!(
186 view.reviews,
187 workspace.load_reviews("forge", "feat"),
188 "the reviews half is the call's own",
189 );
190 assert_eq!(
191 view.threads.as_ref().map(Vec::len),
192 Ok(1),
193 "the seeded thread is what the threads half returns",
194 );
195 assert_eq!(
196 view.reviews.as_ref().map(Vec::len),
197 Ok(1),
198 "the seeded review set is what the reviews half returns",
199 );
200 }
201
202 #[test]
207 fn tree_reviews_places_threads_against_the_seats_working_files() {
208 use forge_primitives::review::tree_scope_ref;
209 use forge_workspace::env::git_diff::resolver::{AnchorPlacement, anchor_hash};
210
211 let dir = tempfile::tempdir().expect("tempdir");
212 let (workspace, _updates) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
213 let root = dir.path().join("tree");
214 std::fs::create_dir_all(&root).expect("project dir");
215 workspace.seed_test_project("forge", &root.to_string_lossy());
216 workspace.install_db_for_test(
217 forge_workspace::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
218 );
219
220 let worker = SessionSlot::worker("TestOrg", "forge", "reader");
221 let project = ViewSurface::new(Arc::clone(&workspace))
222 .roster()
223 .project_named("forge")
224 .expect("seeded project")
225 .key
226 .clone();
227 workspace.insert_live_worker(
228 &project,
229 forge_workspace::WorkerEntry {
230 label: "reader".to_owned(),
231 charter: "charter".to_owned(),
232 slot: worker.clone(),
233 session_id: None,
234 status: forge_primitives::WorkerLiveness::Running,
235 spawned_at: std::time::SystemTime::UNIX_EPOCH,
236 spawned_by: SessionSlot::lead("TestOrg", "forge"),
237 needs_tag: false,
238 is_git_repo_at_spawn: false,
239 cwd: None,
240 diagnostic: None,
241 kick: None,
242 },
243 );
244
245 let src = root.join("src");
246 std::fs::create_dir_all(&src).expect("src dir");
247 let file = src.join("x.rs");
248 std::fs::write(&file, "fn f() {\n let a = 1;\n let b = 2;\n}\n").expect("write");
249
250 let scope = tree_scope_ref(&worker);
251 let mut seeded = thread("t1", 2);
252 seeded.anchor.path = "src/x.rs".to_owned();
253 seeded.anchor.content_hash = anchor_hash(" let a = 1;");
254 seeded.anchor.context = vec!["fn f() {".to_owned(), " let b = 2;".to_owned()];
255 assert!(workspace.upsert_review_thread("forge", &scope, seeded), "seeded thread");
256
257 let view = ViewSurface::new(Arc::clone(&workspace)).tree_reviews(&worker);
258 assert_eq!(view.scope, scope, "the read's scope is the seat's tree");
259 let threads = view.threads.expect("the threads read answered");
260 assert_eq!(threads.len(), 1);
261 assert_eq!(
262 threads[0].placement,
263 Some(AnchorPlacement::Placed { line: 2, moved_from: None }),
264 "in place against the working file",
265 );
266
267 std::fs::write(&file, "// header\nfn f() {\n let a = 1;\n let b = 2;\n}\n")
269 .expect("rewrite");
270 let view = ViewSurface::new(Arc::clone(&workspace)).tree_reviews(&worker);
271 let threads = view.threads.expect("the threads read answered");
272 assert_eq!(
273 threads[0].placement,
274 Some(AnchorPlacement::Placed { line: 3, moved_from: Some(2) }),
275 "the shifted line is re-found by content and neighbours",
276 );
277
278 std::fs::remove_file(&file).expect("remove");
280 let view = ViewSurface::new(Arc::clone(&workspace)).tree_reviews(&worker);
281 let threads = view.threads.expect("the threads read answered");
282 assert_eq!(threads.len(), 1, "the thread survives an unreadable file");
283 assert_eq!(threads[0].placement, None, "no landing rather than a guess");
284 }
285
286 #[test]
291 fn tree_reviews_places_a_thread_on_a_document_under_a_root() {
292 use forge_primitives::review::tree_scope_ref;
293 use forge_workspace::env::git_diff::resolver::{AnchorPlacement, anchor_hash};
294
295 let tree = tempfile::tempdir().expect("tempdir");
296 let documents = tempfile::tempdir().expect("tempdir");
297 let file = documents.path().join("plan.md");
298 std::fs::write(&file, "line one\nline two\n").expect("the document");
299 let (workspace, _dir) =
300 crate::surface::testing::workspace_with_document_roots(tree.path(), documents.path());
301
302 let worker = SessionSlot::worker("TestOrg", "forge", "reader");
303 let project = ViewSurface::new(Arc::clone(&workspace))
304 .roster()
305 .project_named("forge")
306 .expect("declared project")
307 .key
308 .clone();
309 workspace.insert_live_worker(
310 &project,
311 forge_workspace::WorkerEntry {
312 label: "reader".to_owned(),
313 charter: "charter".to_owned(),
314 slot: worker.clone(),
315 session_id: None,
316 status: forge_primitives::WorkerLiveness::Running,
317 spawned_at: std::time::SystemTime::UNIX_EPOCH,
318 spawned_by: SessionSlot::lead("TestOrg", "forge"),
319 needs_tag: false,
320 is_git_repo_at_spawn: false,
321 cwd: None,
322 diagnostic: None,
323 kick: None,
324 },
325 );
326
327 let scope = tree_scope_ref(&worker);
328 let mut seeded = thread("v1", 2);
329 seeded.anchor.path = file.to_string_lossy().into_owned();
330 seeded.anchor.content_hash = anchor_hash("line two");
331 seeded.anchor.context = vec!["line one".to_owned()];
332 assert!(workspace.upsert_review_thread("forge", &scope, seeded), "seeded thread");
333
334 let view = ViewSurface::new(Arc::clone(&workspace)).tree_reviews(&worker);
335 let threads = view.threads.expect("the threads read answered");
336 assert_eq!(threads.len(), 1);
337 assert_eq!(
338 threads[0].placement,
339 Some(AnchorPlacement::Placed { line: 2, moved_from: None }),
340 "in place against the document",
341 );
342 }
343}