strop_engine/editor/changes/
review.rs1use strop_core::id::DocumentId;
15use strop_core::Buffer;
16
17use super::{ChangePlan, ChangeReceipt, PlannedDocument};
18use crate::editor::transact::ChangeSet;
19use crate::editor::Editor;
20mod render;
21mod save;
22use render::ReviewBuffer;
23
24#[derive(Debug, Clone, Copy)]
25pub enum ReviewRow {
26 Heading,
27 File,
28 Hunk,
29 Context,
30 Removed,
31 Added,
32 Warning,
33}
34
35pub(crate) const APPLY_COMMAND: &str = ":apply-change";
38pub(crate) const CANCEL_COMMAND: &str = ":cancel-change";
39
40const CONTEXT: usize = 3;
42
43#[derive(Default)]
46pub(crate) struct ReviewState {
47 seq: usize,
49 pending: Option<ChangeProposal>,
51 pub(crate) replace: Option<PendingReplace>,
54 replace_seq: usize,
57 pub(crate) replace_context: Option<ReplaceContext>,
58 rows: std::collections::HashMap<DocumentId, Vec<ReviewRow>>,
59 saves: std::collections::HashMap<DocumentId, save::PendingChangeSave>,
60}
61
62impl ReviewState {
63 pub(crate) fn forget(&mut self, document: DocumentId) {
64 self.rows.remove(&document);
65 self.saves.retain(|_, pending| pending.report != document);
66 if self
67 .pending
68 .as_ref()
69 .is_some_and(|proposal| proposal.buffer == document)
70 {
71 self.pending = None;
72 if self.replace.is_none() {
73 self.replace_context = None;
74 }
75 }
76 }
77}
78
79pub(crate) struct ReplaceContext {
80 pub picker: strop_picker::Picker,
81 pub origin: crate::editor::jumps::JumpRecord,
82}
83
84#[derive(Debug)]
88pub(crate) struct PendingReplace {
89 pub id: usize,
92 pub documents: Vec<PlannedDocument>,
94 pub refused: Vec<(strop_workspace::ResourceLocation, String)>,
97 pub pending_opens: usize,
99}
100
101#[derive(Debug)]
105pub(crate) struct ChangeProposal {
106 pub id: usize,
108 pub plan: ChangePlan,
111 pub buffer: DocumentId,
114 pub view_revision: strop_core::id::BufferRevision,
115}
116
117impl Editor {
118 pub(crate) fn present_change_plan(&mut self, plan: ChangePlan) {
122 if plan.documents.len() <= 1 && plan.refused.is_empty() {
123 self.apply_change_plan(plan);
124 return;
125 }
126 self.review_change_plan(plan);
127 }
128
129 pub(crate) fn review_apply_pub(&mut self) {
136 let Some(proposal) = self.review.pending.take() else {
137 self.message = "no change proposal awaiting review".into();
138 return;
139 };
140 if self
141 .docs
142 .get(proposal.buffer)
143 .is_none_or(|doc| doc.buf.revision() != proposal.view_revision)
144 {
145 self.review.pending = Some(proposal);
146 self.message = "review changed; prepare a new proposal before applying".into();
147 return;
148 }
149 self.review.replace_context = None;
150 let producer = proposal.plan.producer.label().to_string();
151 let mut receipt = ChangeReceipt {
152 producer: producer.clone(),
153 applied: Vec::new(),
154 applied_positions: Vec::new(),
155 refused: proposal.plan.refused.clone(),
156 redo_positions: None,
157 };
158 let mut lines: Vec<String> = proposal
159 .plan
160 .refused
161 .iter()
162 .map(|(location, reason)| format!("refused: {} — {reason}", location.label()))
163 .collect();
164 for target in proposal.plan.documents {
165 let label = target.location.label();
166 let current = self.docs.get(target.document).map(|doc| doc.buf.revision());
167 match current {
168 None => {
169 let reason = "document closed since the proposal".to_string();
170 receipt.refused.push((target.location, reason.clone()));
171 lines.push(format!("refused: {label} — {reason}"));
172 }
173 Some(revision) if revision != target.base => {
174 let reason = format!(
175 "edited since the proposal (base revision {}, now {revision}) — re-run the {producer}",
176 target.base
177 );
178 receipt.refused.push((target.location, reason.clone()));
179 lines.push(format!("refused: {label} — {reason}"));
180 }
181 Some(_) => {
182 let changes = ChangeSet {
183 edits: target.edits,
184 undo_open: false,
185 };
186 match self.apply(target.document, target.base, changes) {
187 Ok(committed) => {
188 receipt.applied.push((
189 target.document,
190 target.base,
191 committed.revision,
192 ));
193 if let Some(position) = self
194 .docs
195 .get(target.document)
196 .and_then(|doc| doc.buf.history().committed_position())
197 {
198 receipt.applied_positions.push(position);
199 }
200 lines.push(format!(
201 "applied: {label} (revision {} -> {})",
202 target.base, committed.revision
203 ));
204 }
205 Err(error) => {
206 let reason = format!("changed since plan: {error}");
207 receipt.refused.push((target.location, reason.clone()));
208 lines.push(format!("refused: {label} — {reason}"));
209 }
210 }
211 }
212 }
213 }
214 let status = if receipt.refused.is_empty() {
215 "APPLIED"
216 } else if receipt.applied.is_empty() {
217 "REFUSED"
218 } else {
219 "PARTIAL"
220 };
221 let mut text = format!(
222 "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",
223 proposal.id,
224 receipt.applied.len(),
225 receipt.refused.len()
226 );
227 for line in &lines {
228 text.push_str(line);
229 text.push('\n');
230 }
231 let publish = self.replace_system(proposal.buffer, &text);
232 self.review.rows.insert(
233 proposal.buffer,
234 text.lines()
235 .enumerate()
236 .map(|(line, _)| {
237 if line == 0 && !receipt.refused.is_empty() {
238 ReviewRow::Warning
239 } else if line == 0 {
240 ReviewRow::Heading
241 } else {
242 ReviewRow::Context
243 }
244 })
245 .collect(),
246 );
247 if self.current() == proposal.buffer {
248 self.set_head(0);
249 self.view_mut().view_top = 0;
250 }
251 self.message = match (receipt.applied.len(), receipt.refused.len()) {
252 (applied, 0) => format!("{producer}: applied to {applied} buffer(s)"),
253 (applied, refused) => {
254 format!("{producer}: {applied} buffer(s) applied, {refused} target(s) refused")
255 }
256 };
257 if let Err(error) = publish {
258 self.message = format!("change receipt publish failed: {error}");
259 }
260 self.changes.record(receipt);
261 }
262
263 pub(crate) fn review_cancel_pub(&mut self) {
266 if self.review.replace.take().is_some() {
267 self.restore_replace_context();
268 self.message = "replace cancelled while loading; nothing applied".into();
269 return;
270 }
271 let Some(proposal) = self.review.pending.take() else {
272 self.message = "no change proposal awaiting review".into();
273 return;
274 };
275 let producer = proposal.plan.producer.label();
276 let mut text = format!(
277 "strop change proposal {}: {producer} — CANCELLED\nnothing applied; {} file(s) had been proposed\n",
278 proposal.id,
279 proposal.plan.documents.len()
280 );
281 if !proposal.plan.refused.is_empty() {
282 text.push_str("\nrefused targets (would have been skipped):\n");
283 for (location, reason) in &proposal.plan.refused {
284 text.push_str(&format!(" {} — {reason}\n", location.label()));
285 }
286 }
287 let publish = self.replace_system(proposal.buffer, &text);
288 self.review.rows.insert(
289 proposal.buffer,
290 text.lines()
291 .enumerate()
292 .map(|(line, _)| {
293 if line == 0 {
294 ReviewRow::Heading
295 } else {
296 ReviewRow::Context
297 }
298 })
299 .collect(),
300 );
301 if self.current() == proposal.buffer {
302 self.set_head(0);
303 self.view_mut().view_top = 0;
304 }
305 self.message = format!(
306 "{producer}: proposal {} cancelled — nothing applied",
307 proposal.id
308 );
309 if let Err(error) = publish {
310 self.message = format!("change receipt publish failed: {error}");
311 }
312 self.restore_replace_context();
313 }
314
315 fn render_proposal(&self, id: usize, plan: &ChangePlan) -> ReviewBuffer {
319 let producer = plan.producer.label();
320 let mut view = ReviewBuffer::default();
321 view.line(
322 &format!("strop change proposal {id}: {producer}"),
323 ReviewRow::Heading,
324 );
325 view.line(
326 &format!(
327 "{} file(s) to change, {} target(s) refused",
328 plan.documents.len(),
329 plan.refused.len()
330 ),
331 ReviewRow::Context,
332 );
333 view.line(
334 &format!("{APPLY_COMMAND} applies exactly what is shown; {CANCEL_COMMAND} discards it"),
335 ReviewRow::Context,
336 );
337 view.line(
338 "bases are pinned — editing a source invalidates that file at apply",
339 ReviewRow::Context,
340 );
341 for target in &plan.documents {
342 view.line("", ReviewRow::Context);
343 let label = match target.location.filesystem {
344 strop_workspace::Filesystem::Local => target
345 .location
346 .path
347 .strip_prefix(&self.cwd)
348 .unwrap_or(&target.location.path)
349 .display()
350 .to_string(),
351 _ => target.location.label(),
352 };
353 match self.docs.get(target.document) {
354 Some(document) => {
355 match render::file_diff(&label, &document.buf, target.base, &target.edits) {
356 Ok(diff) => view.append(diff),
357 Err(error) => {
358 view.line(&format!("refused: {label} — {error}"), ReviewRow::Warning)
359 }
360 }
361 }
362 None => view.line(
363 &format!("refused: {label} — document closed"),
364 ReviewRow::Warning,
365 ),
366 }
367 }
368 if !plan.refused.is_empty() {
369 view.line("", ReviewRow::Context);
370 view.line("refused targets:", ReviewRow::Warning);
371 for (location, reason) in &plan.refused {
372 view.line(
373 &format!(" {} — {reason}", location.label()),
374 ReviewRow::Warning,
375 );
376 }
377 }
378 view
379 }
380}
381impl Editor {
382 pub(crate) fn begin_replace(
386 &mut self,
387 documents: Vec<PlannedDocument>,
388 refused: Vec<(strop_workspace::ResourceLocation, String)>,
389 pending_opens: usize,
390 ) -> usize {
391 self.review.replace_seq += 1;
392 let id = self.review.replace_seq;
393 self.review.replace = Some(PendingReplace {
394 id,
395 documents,
396 refused,
397 pending_opens,
398 });
399 id
400 }
401
402 pub(crate) fn review_change_plan(&mut self, plan: ChangePlan) {
408 if let Some(old) = self.review.pending.take() {
409 let note = format!(
410 "strop change proposal {}: {} — SUPERSEDED by a newer proposal\n",
411 old.id,
412 old.plan.producer.label()
413 );
414 if let Err(error) = self.replace_system(old.buffer, ¬e) {
415 self.message = format!("could not retire proposal {}: {error}", old.id);
416 }
417 self.review
418 .rows
419 .insert(old.buffer, vec![ReviewRow::Heading]);
420 }
421 self.review.seq += 1;
422 let id = self.review.seq;
423 let text = self.render_proposal(id, &plan);
424 let producer = plan.producer.label().to_string();
425 let files = plan.documents.len();
426 let refused = plan.refused.len();
427 let mut buf = Buffer::from_text(&text.text);
428 buf.name = Some(format!("change proposal {id}"));
429 let view_revision = buf.revision();
430 let buffer = self.open_temporary_output(buf);
431 self.review.rows.insert(buffer, text.rows);
432 self.review.pending = Some(ChangeProposal {
433 id,
434 plan,
435 buffer,
436 view_revision,
437 });
438 self.message = match refused {
439 0 => format!(
440 "{producer}: proposal {id} reviews {files} file(s) — {APPLY_COMMAND} or {CANCEL_COMMAND}"
441 ),
442 _ => format!(
443 "{producer}: proposal {id} reviews {files} file(s), {refused} refused — {APPLY_COMMAND} or {CANCEL_COMMAND}"
444 ),
445 };
446 }
447}
448
449#[cfg(test)]
450mod tests;
451
452impl Editor {
453 pub fn review_row(&self, document: DocumentId, row: usize) -> Option<ReviewRow> {
454 self.review.rows.get(&document)?.get(row).copied()
455 }
456}