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 if self.apply_filesystem_review() {
108 return;
109 }
110 if self.is_filesystem_report(self.current()) {
111 self.message = "this filesystem report has no pending proposal to apply".into();
112 return;
113 }
114 let Some(proposal) = self.review.pending.take() else {
115 self.message = "no change proposal awaiting review".into();
116 return;
117 };
118 if self
119 .docs
120 .get(proposal.buffer)
121 .is_none_or(|doc| doc.buf.revision() != proposal.view_revision)
122 {
123 self.review.pending = Some(proposal);
124 self.message = "review changed; prepare a new proposal before applying".into();
125 return;
126 }
127 if proposal
128 .search
129 .is_some_and(|stamp| self.search_stamp(stamp.session) != Some(stamp))
130 {
131 self.review.pending = Some(proposal);
132 self.message = "Search changed; prepare a new review".into();
133 return;
134 }
135 let producer = proposal.plan.producer.label().to_string();
136 let mut receipt = ChangeReceipt {
137 producer: producer.clone(),
138 applied: Vec::new(),
139 applied_positions: Vec::new(),
140 refused: proposal.plan.refused.clone(),
141 redo_positions: None,
142 };
143 let mut lines: Vec<String> = proposal
144 .plan
145 .refused
146 .iter()
147 .map(|(location, reason)| format!("refused: {} — {reason}", location.label()))
148 .collect();
149 for target in proposal.plan.documents {
150 let label = target.location.label();
151 let current = self.docs.get(target.document).map(|doc| doc.buf.revision());
152 match current {
153 None => {
154 let reason = "document closed since the proposal".to_string();
155 receipt.refused.push((target.location, reason.clone()));
156 lines.push(format!("refused: {label} — {reason}"));
157 }
158 Some(_)
159 if proposal.search.is_some()
160 && !self.doc(target.document).matches_target(
161 &crate::files::FileTarget::Local(target.location.path.clone()),
162 ) =>
163 {
164 let reason = "source binding changed since the proposal".to_string();
165 receipt.refused.push((target.location, reason.clone()));
166 lines.push(format!("refused: {label} — {reason}"));
167 }
168 Some(revision) if revision != target.base => {
169 let reason = format!(
170 "edited since the proposal (base revision {}, now {revision}) — re-run the {producer}",
171 target.base
172 );
173 receipt.refused.push((target.location, reason.clone()));
174 lines.push(format!("refused: {label} — {reason}"));
175 }
176 Some(_) => {
177 let changes = ChangeSet {
178 edits: target.edits,
179 undo_open: false,
180 };
181 match self.apply(target.document, target.base, changes) {
182 Ok(committed) => {
183 receipt.applied.push((
184 target.document,
185 target.base,
186 committed.revision,
187 ));
188 if let Some(position) = self
189 .docs
190 .get(target.document)
191 .and_then(|doc| doc.buf.history().committed_position())
192 {
193 receipt.applied_positions.push(position);
194 }
195 lines.push(format!(
196 "applied: {label} (revision {} -> {})",
197 target.base, committed.revision
198 ));
199 }
200 Err(error) => {
201 let reason = format!("changed since plan: {error}");
202 receipt.refused.push((target.location, reason.clone()));
203 lines.push(format!("refused: {label} — {reason}"));
204 }
205 }
206 }
207 }
208 }
209 let status = if receipt.refused.is_empty() {
210 "APPLIED"
211 } else if receipt.applied.is_empty() {
212 "REFUSED"
213 } else {
214 "PARTIAL"
215 };
216 let mut text = format!(
217 "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",
218 proposal.id,
219 receipt.applied.len(),
220 receipt.refused.len()
221 );
222 for line in &lines {
223 text.push_str(line);
224 text.push('\n');
225 }
226 let publish = self.replace_system(proposal.buffer, &text);
227 self.review.rows.insert(
228 proposal.buffer,
229 text.lines()
230 .enumerate()
231 .map(|(line, _)| {
232 if line == 0 && !receipt.refused.is_empty() {
233 ReviewRow::Warning
234 } else if line == 0 {
235 ReviewRow::Heading
236 } else {
237 ReviewRow::Context
238 }
239 })
240 .collect(),
241 );
242 if self.current() == proposal.buffer {
243 self.set_head(0);
244 self.view_mut().view_top = 0;
245 }
246 self.message = match (receipt.applied.len(), receipt.refused.len()) {
247 (applied, 0) => format!("{producer}: applied to {applied} buffer(s)"),
248 (applied, refused) => {
249 format!("{producer}: {applied} buffer(s) applied, {refused} target(s) refused")
250 }
251 };
252 if let Err(error) = publish {
253 self.message = format!("change receipt publish failed: {error}");
254 }
255 self.changes.record(receipt);
256 }
257
258 pub(crate) fn review_cancel_pub(&mut self) {
261 if self.cancel_filesystem_review() {
262 return;
263 }
264 if self.is_filesystem_report(self.current()) {
265 self.message = "this filesystem report has no pending proposal to cancel".into();
266 return;
267 }
268 if let Some(stamp) = self
269 .review
270 .preparing
271 .as_ref()
272 .map(|pending| pending.ticket.key.stamp)
273 {
274 self.cancel_review_preparation();
275 self.resume_search_after_review(stamp);
276 self.message = "replacement review cancelled; nothing applied".into();
277 return;
278 }
279 let Some(proposal) = self.review.pending.take() else {
280 self.message = "no change proposal awaiting review".into();
281 return;
282 };
283 let search = proposal.search;
284 let producer = proposal.plan.producer.label();
285 let mut text = format!(
286 "strop change proposal {}: {producer} — CANCELLED\nnothing applied; {} file(s) had been proposed\n",
287 proposal.id,
288 proposal.plan.documents.len()
289 );
290 if !proposal.plan.refused.is_empty() {
291 text.push_str("\nrefused targets (would have been skipped):\n");
292 for (location, reason) in &proposal.plan.refused {
293 text.push_str(&format!(" {} — {reason}\n", location.label()));
294 }
295 }
296 let publish = self.replace_system(proposal.buffer, &text);
297 self.review.rows.insert(
298 proposal.buffer,
299 text.lines()
300 .enumerate()
301 .map(|(line, _)| {
302 if line == 0 {
303 ReviewRow::Heading
304 } else {
305 ReviewRow::Context
306 }
307 })
308 .collect(),
309 );
310 if self.current() == proposal.buffer {
311 self.set_head(0);
312 self.view_mut().view_top = 0;
313 }
314 self.message = format!(
315 "{producer}: proposal {} cancelled — nothing applied",
316 proposal.id
317 );
318 if let Err(error) = publish {
319 self.message = format!("change receipt publish failed: {error}");
320 }
321 if let Some(stamp) = search {
322 self.resume_search_after_review(stamp);
323 }
324 }
325
326 fn render_proposal(&self, id: usize, plan: &ChangePlan) -> ReviewBuffer {
330 let mut view = Self::review_heading(id, plan);
331 for target in &plan.documents {
332 view.line("", ReviewRow::Context);
333 let label = match target.location.filesystem {
334 strop_workspace::Filesystem::Local => target
335 .location
336 .path
337 .strip_prefix(&self.cwd)
338 .unwrap_or(&target.location.path)
339 .display()
340 .to_string(),
341 _ => target.location.label(),
342 };
343 match self.docs.get(target.document) {
344 Some(document) => {
345 match render::file_diff(&label, &document.buf, target.base, &target.edits) {
346 Ok(diff) => view.append(diff),
347 Err(error) => {
348 view.line(&format!("refused: {label} — {error}"), ReviewRow::Warning)
349 }
350 }
351 }
352 None => view.line(
353 &format!("refused: {label} — document closed"),
354 ReviewRow::Warning,
355 ),
356 }
357 }
358 Self::review_refusals(&mut view, plan);
359 view
360 }
361}
362impl Editor {
363 pub(crate) fn review_change_plan(&mut self, plan: ChangePlan) {
369 let Some(id) = self.next_review_id() else {
370 return;
371 };
372 let text = self.render_proposal(id, &plan);
373 self.publish_review(id, plan, text, None, true);
374 }
375
376 fn next_review_id(&mut self) -> Option<usize> {
377 match self.review.seq.checked_add(1) {
378 Some(id) => Some(id),
379 None => {
380 self.message = "review identity exhausted".into();
381 None
382 }
383 }
384 }
385
386 fn review_heading(id: usize, plan: &ChangePlan) -> ReviewBuffer {
387 let mut view = ReviewBuffer::default();
388 view.line(
389 &format!("strop change proposal {id}: {}", plan.producer.label()),
390 ReviewRow::Heading,
391 );
392 view.line(
393 &format!(
394 "{} file(s) to change, {} target(s) refused",
395 plan.documents.len(),
396 plan.refused.len()
397 ),
398 ReviewRow::Context,
399 );
400 view.line(
401 &format!("{APPLY_COMMAND} applies exactly what is shown; {CANCEL_COMMAND} discards it"),
402 ReviewRow::Context,
403 );
404 view.line(
405 "bases are pinned — editing a source invalidates that file at apply",
406 ReviewRow::Context,
407 );
408 view
409 }
410
411 fn review_refusals(view: &mut ReviewBuffer, plan: &ChangePlan) {
412 if !plan.refused.is_empty() {
413 view.line("", ReviewRow::Context);
414 view.line("refused targets:", ReviewRow::Warning);
415 for (location, reason) in &plan.refused {
416 view.line(
417 &format!(" {} — {reason}", location.label()),
418 ReviewRow::Warning,
419 );
420 }
421 }
422 }
423
424 fn present_prepared_search_review(
425 &mut self,
426 plan: ChangePlan,
427 body: ReviewBuffer,
428 stamp: crate::editor::picker::search::SearchStamp,
429 focus: bool,
430 ) {
431 let Some(id) = self.next_review_id() else {
432 return;
433 };
434 let mut text = Self::review_heading(id, &plan);
435 text.append(body);
436 Self::review_refusals(&mut text, &plan);
437 self.publish_review(id, plan, text, Some(stamp), focus);
438 }
439
440 fn publish_review(
441 &mut self,
442 id: usize,
443 plan: ChangePlan,
444 text: ReviewBuffer,
445 search: Option<crate::editor::picker::search::SearchStamp>,
446 focus: bool,
447 ) {
448 self.retire_filesystem_review("superseded by a text review");
449 if let Some(old) = self.review.pending.take() {
450 let note = format!(
451 "strop change proposal {}: {} — SUPERSEDED by a newer proposal\n",
452 old.id,
453 old.plan.producer.label()
454 );
455 if let Err(error) = self.replace_system(old.buffer, ¬e) {
456 self.message = format!("could not retire proposal {}: {error}", old.id);
457 }
458 self.review
459 .rows
460 .insert(old.buffer, vec![ReviewRow::Heading]);
461 }
462 self.review.seq = id;
463 let producer = plan.producer.label().to_owned();
464 let files = plan.documents.len();
465 let refused = plan.refused.len();
466 let mut buf = Buffer::from_text(&text.text);
467 buf.name = Some(format!("change proposal {id}"));
468 let view_revision = buf.revision();
469 let buffer = if focus {
470 self.open_temporary_output(buf)
471 } else {
472 let mut document = crate::editor::Document::output(buf);
473 if let Some(origin) = self
474 .retained_search
475 .as_ref()
476 .and_then(|glue| glue.search.as_ref())
477 .map(|context| context.origin.clone())
478 {
479 document.set_return_point(origin);
480 }
481 let id = self.docs.insert(document);
482 self.mru.push(id);
483 id
484 };
485 self.review.rows.insert(buffer, text.rows);
486 self.review.pending = Some(ChangeProposal {
487 id,
488 plan,
489 buffer,
490 view_revision,
491 search,
492 });
493 self.message = format!(
494 "{producer}: proposal {id} reviews {files} file(s), {refused} refused — {}",
495 if focus {
496 ":apply-change or :cancel-change"
497 } else {
498 "ready in buffers; focus unchanged"
499 }
500 );
501 }
502
503 pub(crate) fn invalidate_search_review(&mut self, session: strop_core::worker::WorkerId) {
504 if self
505 .review
506 .preparing
507 .as_ref()
508 .is_some_and(|pending| pending.ticket.key.stamp.session == session)
509 {
510 self.cancel_review_preparation();
511 }
512 if !self.review.pending.as_ref().is_some_and(|proposal| {
513 proposal
514 .search
515 .is_some_and(|stamp| stamp.session == session)
516 }) {
517 return;
518 }
519 if let Some(proposal) = self.review.pending.take() {
520 let note = format!("strop change proposal {} — STALE\nSearch changed; prepare a new review. Nothing applied.\n", proposal.id);
521 if let Err(error) = self.replace_system(proposal.buffer, ¬e) {
522 self.message = format!("could not retire stale review: {error}");
523 }
524 self.review.rows.insert(
525 proposal.buffer,
526 vec![ReviewRow::Warning, ReviewRow::Context],
527 );
528 }
529 }
530}
531
532#[cfg(test)]
533mod tests;
534
535impl Editor {
536 pub(crate) fn set_filesystem_review_rows(
537 &mut self,
538 document: DocumentId,
539 rows: Vec<ReviewRow>,
540 ) {
541 self.filesystem_report_opened(document);
542 self.review.rows.insert(document, rows);
543 }
544
545 pub(crate) fn retire_text_review_for_filesystem(&mut self) {
546 if let Some(proposal) = self.review.pending.take() {
547 if self.docs.get(proposal.buffer).is_some() {
548 if let Err(error) = self.replace_system(
549 proposal.buffer,
550 "text review superseded by filesystem review; nothing applied\n",
551 ) {
552 self.message = error.to_string();
553 }
554 }
555 }
556 }
557
558 pub(crate) fn invalidate_filesystem_text_review(&mut self, documents: &[DocumentId]) {
559 self.cancel_review_preparation();
560 if self.review.pending.as_ref().is_some_and(|proposal| {
561 proposal
562 .plan
563 .documents
564 .iter()
565 .any(|target| documents.contains(&target.document))
566 }) {
567 self.retire_text_review_for_filesystem();
568 }
569 }
570 pub fn review_row(&self, document: DocumentId, row: usize) -> Option<ReviewRow> {
571 self.review.rows.get(&document)?.get(row).copied()
572 }
573}