strop_engine/editor/changes/
review.rs1use strop_core::id::DocumentId;
15use strop_core::Buffer;
16
17use super::{ChangePlan, ChangeReceipt};
18use crate::editor::transact::ChangeSet;
19use crate::editor::Editor;
20pub(crate) mod prepare;
21mod render;
22mod save;
23use render::ReviewBuffer;
24
25#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
26pub enum ReviewRow {
27 Heading,
28 File,
29 Hunk,
30 Context,
31 Removed,
32 Added,
33 Warning,
34}
35
36pub(crate) const APPLY_COMMAND: &str = ":apply-change";
39pub(crate) const CANCEL_COMMAND: &str = ":cancel-change";
40
41const CONTEXT: usize = 3;
43
44#[derive(Default)]
47pub(crate) struct ReviewState {
48 seq: usize,
50 pending: Option<ChangeProposal>,
52 pub(crate) preparing: Option<prepare::Preparation>,
53 rows: std::collections::HashMap<DocumentId, Vec<ReviewRow>>,
54 saves: std::collections::HashMap<DocumentId, save::PendingChangeSave>,
55}
56
57impl ReviewState {
58 pub(crate) fn forget(&mut self, document: DocumentId) {
59 self.rows.remove(&document);
60 self.saves.retain(|_, pending| pending.report != document);
61 if self
62 .pending
63 .as_ref()
64 .is_some_and(|proposal| proposal.buffer == document)
65 {
66 self.pending = None;
67 }
68 }
69}
70
71#[derive(Debug)]
75pub(crate) struct ChangeProposal {
76 pub id: usize,
78 pub plan: ChangePlan,
81 pub buffer: DocumentId,
84 pub view_revision: strop_core::id::BufferRevision,
85 pub search: Option<crate::editor::picker::search::SearchStamp>,
86}
87
88impl Editor {
89 pub(crate) fn present_change_plan(&mut self, plan: ChangePlan) {
93 if plan.documents.len() <= 1 && plan.refused.is_empty() {
94 self.apply_change_plan(plan);
95 return;
96 }
97 self.review_change_plan(plan);
98 }
99
100 pub(crate) fn review_apply_pub(&mut self) {
107 let Some(proposal) = self.review.pending.take() else {
108 self.message = "no change proposal awaiting review".into();
109 return;
110 };
111 if self
112 .docs
113 .get(proposal.buffer)
114 .is_none_or(|doc| doc.buf.revision() != proposal.view_revision)
115 {
116 self.review.pending = Some(proposal);
117 self.message = "review changed; prepare a new proposal before applying".into();
118 return;
119 }
120 if proposal
121 .search
122 .is_some_and(|stamp| self.search_stamp(stamp.session) != Some(stamp))
123 {
124 self.review.pending = Some(proposal);
125 self.message = "Search changed; prepare a new review".into();
126 return;
127 }
128 let producer = proposal.plan.producer.label().to_string();
129 let mut receipt = ChangeReceipt {
130 producer: producer.clone(),
131 applied: Vec::new(),
132 applied_positions: Vec::new(),
133 refused: proposal.plan.refused.clone(),
134 redo_positions: None,
135 };
136 let mut lines: Vec<String> = proposal
137 .plan
138 .refused
139 .iter()
140 .map(|(location, reason)| format!("refused: {} — {reason}", location.label()))
141 .collect();
142 for target in proposal.plan.documents {
143 let label = target.location.label();
144 let current = self.docs.get(target.document).map(|doc| doc.buf.revision());
145 match current {
146 None => {
147 let reason = "document closed since the proposal".to_string();
148 receipt.refused.push((target.location, reason.clone()));
149 lines.push(format!("refused: {label} — {reason}"));
150 }
151 Some(_)
152 if proposal.search.is_some()
153 && !self.doc(target.document).matches_target(
154 &crate::files::FileTarget::Local(target.location.path.clone()),
155 ) =>
156 {
157 let reason = "source binding changed since the proposal".to_string();
158 receipt.refused.push((target.location, reason.clone()));
159 lines.push(format!("refused: {label} — {reason}"));
160 }
161 Some(revision) if revision != target.base => {
162 let reason = format!(
163 "edited since the proposal (base revision {}, now {revision}) — re-run the {producer}",
164 target.base
165 );
166 receipt.refused.push((target.location, reason.clone()));
167 lines.push(format!("refused: {label} — {reason}"));
168 }
169 Some(_) => {
170 let changes = ChangeSet {
171 edits: target.edits,
172 undo_open: false,
173 };
174 match self.apply(target.document, target.base, changes) {
175 Ok(committed) => {
176 receipt.applied.push((
177 target.document,
178 target.base,
179 committed.revision,
180 ));
181 if let Some(position) = self
182 .docs
183 .get(target.document)
184 .and_then(|doc| doc.buf.history().committed_position())
185 {
186 receipt.applied_positions.push(position);
187 }
188 lines.push(format!(
189 "applied: {label} (revision {} -> {})",
190 target.base, committed.revision
191 ));
192 }
193 Err(error) => {
194 let reason = format!("changed since plan: {error}");
195 receipt.refused.push((target.location, reason.clone()));
196 lines.push(format!("refused: {label} — {reason}"));
197 }
198 }
199 }
200 }
201 }
202 let status = if receipt.refused.is_empty() {
203 "APPLIED"
204 } else if receipt.applied.is_empty() {
205 "REFUSED"
206 } else {
207 "PARTIAL"
208 };
209 let mut text = format!(
210 "strop change proposal {}: {producer} — {status}\n{} buffer(s) applied, {} target(s) refused\n:undo-change reverts the applied group; :save-change saves changed files\n\n",
211 proposal.id,
212 receipt.applied.len(),
213 receipt.refused.len()
214 );
215 for line in &lines {
216 text.push_str(line);
217 text.push('\n');
218 }
219 let publish = self.replace_system(proposal.buffer, &text);
220 self.review.rows.insert(
221 proposal.buffer,
222 text.lines()
223 .enumerate()
224 .map(|(line, _)| {
225 if line == 0 && !receipt.refused.is_empty() {
226 ReviewRow::Warning
227 } else if line == 0 {
228 ReviewRow::Heading
229 } else {
230 ReviewRow::Context
231 }
232 })
233 .collect(),
234 );
235 if self.current() == proposal.buffer {
236 self.set_head(0);
237 self.view_mut().view_top = 0;
238 }
239 self.message = match (receipt.applied.len(), receipt.refused.len()) {
240 (applied, 0) => format!("{producer}: applied to {applied} buffer(s)"),
241 (applied, refused) => {
242 format!("{producer}: {applied} buffer(s) applied, {refused} target(s) refused")
243 }
244 };
245 if let Err(error) = publish {
246 self.message = format!("change receipt publish failed: {error}");
247 }
248 self.changes.record(receipt);
249 }
250
251 pub(crate) fn review_cancel_pub(&mut self) {
254 if let Some(stamp) = self
255 .review
256 .preparing
257 .as_ref()
258 .map(|pending| pending.ticket.key.stamp)
259 {
260 self.cancel_review_preparation();
261 self.resume_search_after_review(stamp);
262 self.message = "replacement review cancelled; nothing applied".into();
263 return;
264 }
265 let Some(proposal) = self.review.pending.take() else {
266 self.message = "no change proposal awaiting review".into();
267 return;
268 };
269 let search = proposal.search;
270 let producer = proposal.plan.producer.label();
271 let mut text = format!(
272 "strop change proposal {}: {producer} — CANCELLED\nnothing applied; {} file(s) had been proposed\n",
273 proposal.id,
274 proposal.plan.documents.len()
275 );
276 if !proposal.plan.refused.is_empty() {
277 text.push_str("\nrefused targets (would have been skipped):\n");
278 for (location, reason) in &proposal.plan.refused {
279 text.push_str(&format!(" {} — {reason}\n", location.label()));
280 }
281 }
282 let publish = self.replace_system(proposal.buffer, &text);
283 self.review.rows.insert(
284 proposal.buffer,
285 text.lines()
286 .enumerate()
287 .map(|(line, _)| {
288 if line == 0 {
289 ReviewRow::Heading
290 } else {
291 ReviewRow::Context
292 }
293 })
294 .collect(),
295 );
296 if self.current() == proposal.buffer {
297 self.set_head(0);
298 self.view_mut().view_top = 0;
299 }
300 self.message = format!(
301 "{producer}: proposal {} cancelled — nothing applied",
302 proposal.id
303 );
304 if let Err(error) = publish {
305 self.message = format!("change receipt publish failed: {error}");
306 }
307 if let Some(stamp) = search {
308 self.resume_search_after_review(stamp);
309 }
310 }
311
312 fn render_proposal(&self, id: usize, plan: &ChangePlan) -> ReviewBuffer {
316 let mut view = Self::review_heading(id, plan);
317 for target in &plan.documents {
318 view.line("", ReviewRow::Context);
319 let label = match target.location.filesystem {
320 strop_workspace::Filesystem::Local => target
321 .location
322 .path
323 .strip_prefix(&self.cwd)
324 .unwrap_or(&target.location.path)
325 .display()
326 .to_string(),
327 _ => target.location.label(),
328 };
329 match self.docs.get(target.document) {
330 Some(document) => {
331 match render::file_diff(&label, &document.buf, target.base, &target.edits) {
332 Ok(diff) => view.append(diff),
333 Err(error) => {
334 view.line(&format!("refused: {label} — {error}"), ReviewRow::Warning)
335 }
336 }
337 }
338 None => view.line(
339 &format!("refused: {label} — document closed"),
340 ReviewRow::Warning,
341 ),
342 }
343 }
344 Self::review_refusals(&mut view, plan);
345 view
346 }
347}
348impl Editor {
349 pub(crate) fn review_change_plan(&mut self, plan: ChangePlan) {
355 let Some(id) = self.next_review_id() else {
356 return;
357 };
358 let text = self.render_proposal(id, &plan);
359 self.publish_review(id, plan, text, None, true);
360 }
361
362 fn next_review_id(&mut self) -> Option<usize> {
363 match self.review.seq.checked_add(1) {
364 Some(id) => Some(id),
365 None => {
366 self.message = "review identity exhausted".into();
367 None
368 }
369 }
370 }
371
372 fn review_heading(id: usize, plan: &ChangePlan) -> ReviewBuffer {
373 let mut view = ReviewBuffer::default();
374 view.line(
375 &format!("strop change proposal {id}: {}", plan.producer.label()),
376 ReviewRow::Heading,
377 );
378 view.line(
379 &format!(
380 "{} file(s) to change, {} target(s) refused",
381 plan.documents.len(),
382 plan.refused.len()
383 ),
384 ReviewRow::Context,
385 );
386 view.line(
387 &format!("{APPLY_COMMAND} applies exactly what is shown; {CANCEL_COMMAND} discards it"),
388 ReviewRow::Context,
389 );
390 view.line(
391 "bases are pinned — editing a source invalidates that file at apply",
392 ReviewRow::Context,
393 );
394 view
395 }
396
397 fn review_refusals(view: &mut ReviewBuffer, plan: &ChangePlan) {
398 if !plan.refused.is_empty() {
399 view.line("", ReviewRow::Context);
400 view.line("refused targets:", ReviewRow::Warning);
401 for (location, reason) in &plan.refused {
402 view.line(
403 &format!(" {} — {reason}", location.label()),
404 ReviewRow::Warning,
405 );
406 }
407 }
408 }
409
410 fn present_prepared_search_review(
411 &mut self,
412 plan: ChangePlan,
413 body: ReviewBuffer,
414 stamp: crate::editor::picker::search::SearchStamp,
415 focus: bool,
416 ) {
417 let Some(id) = self.next_review_id() else {
418 return;
419 };
420 let mut text = Self::review_heading(id, &plan);
421 text.append(body);
422 Self::review_refusals(&mut text, &plan);
423 self.publish_review(id, plan, text, Some(stamp), focus);
424 }
425
426 fn publish_review(
427 &mut self,
428 id: usize,
429 plan: ChangePlan,
430 text: ReviewBuffer,
431 search: Option<crate::editor::picker::search::SearchStamp>,
432 focus: bool,
433 ) {
434 if let Some(old) = self.review.pending.take() {
435 let note = format!(
436 "strop change proposal {}: {} — SUPERSEDED by a newer proposal\n",
437 old.id,
438 old.plan.producer.label()
439 );
440 if let Err(error) = self.replace_system(old.buffer, ¬e) {
441 self.message = format!("could not retire proposal {}: {error}", old.id);
442 }
443 self.review
444 .rows
445 .insert(old.buffer, vec![ReviewRow::Heading]);
446 }
447 self.review.seq = id;
448 let producer = plan.producer.label().to_owned();
449 let files = plan.documents.len();
450 let refused = plan.refused.len();
451 let mut buf = Buffer::from_text(&text.text);
452 buf.name = Some(format!("change proposal {id}"));
453 let view_revision = buf.revision();
454 let buffer = if focus {
455 self.open_temporary_output(buf)
456 } else {
457 let mut document = crate::editor::Document::output(buf);
458 if let Some(origin) = self
459 .retained_search
460 .as_ref()
461 .and_then(|glue| glue.search.as_ref())
462 .map(|context| context.origin.clone())
463 {
464 document.set_return_point(origin);
465 }
466 let id = self.docs.insert(document);
467 self.mru.push(id);
468 id
469 };
470 self.review.rows.insert(buffer, text.rows);
471 self.review.pending = Some(ChangeProposal {
472 id,
473 plan,
474 buffer,
475 view_revision,
476 search,
477 });
478 self.message = format!(
479 "{producer}: proposal {id} reviews {files} file(s), {refused} refused — {}",
480 if focus {
481 ":apply-change or :cancel-change"
482 } else {
483 "ready in buffers; focus unchanged"
484 }
485 );
486 }
487
488 pub(crate) fn invalidate_search_review(&mut self, session: strop_core::worker::WorkerId) {
489 if self
490 .review
491 .preparing
492 .as_ref()
493 .is_some_and(|pending| pending.ticket.key.stamp.session == session)
494 {
495 self.cancel_review_preparation();
496 }
497 if !self.review.pending.as_ref().is_some_and(|proposal| {
498 proposal
499 .search
500 .is_some_and(|stamp| stamp.session == session)
501 }) {
502 return;
503 }
504 if let Some(proposal) = self.review.pending.take() {
505 let note = format!("strop change proposal {} — STALE\nSearch changed; prepare a new review. Nothing applied.\n", proposal.id);
506 if let Err(error) = self.replace_system(proposal.buffer, ¬e) {
507 self.message = format!("could not retire stale review: {error}");
508 }
509 self.review.rows.insert(
510 proposal.buffer,
511 vec![ReviewRow::Warning, ReviewRow::Context],
512 );
513 }
514 }
515}
516
517#[cfg(test)]
518mod tests;
519
520impl Editor {
521 pub fn review_row(&self, document: DocumentId, row: usize) -> Option<ReviewRow> {
522 self.review.rows.get(&document)?.get(row).copied()
523 }
524}