mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-03 00:42:46 -06:00
There was no OOPS. It now brings back what the last ERASE removed (the Delete key too), or BLOCK deleting the objects it makes a block of, as AutoCAD 2024 does (accoreconsole, the drawing's entnext order read back): - Each object keeps its handle and goes back directly above the object that was under it, or under the one that was above it when that is gone. An erase records both neighbours (EntityStore::neighbours). - Only the last erase counts; entdel doesn't. OOPS says nothing and is one undo step. - An object stays out when it is already in the drawing, when an undo took the drawing back to before it was made, when its layer, block or layout is gone, and, during REFEDIT, when it isn't in the working set. REFCLOSE forgets the working copies; the Block Editor has an OOPS of its own. - The command's label is in the 17 interface catalogs. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
472 lines
20 KiB
Rust
472 lines
20 KiB
Rust
//! REFEDIT, REFSET and REFCLOSE: edit a block reference in place.
|
|
//!
|
|
//! REFEDIT hides the picked reference and puts the block's objects (all, or the ones named) into
|
|
//! the drawing where the reference shows them: the *working set*, ordinary objects that every
|
|
//! command edits. The rest of the block stands there as faded copies, and everything else in the
|
|
//! drawing is locked ([`crate::cmd::curves::is_locked`], enforced by [`after_command`]). Objects
|
|
//! drawn while editing join the working set. REFSET adds objects to or removes them from it.
|
|
//! REFCLOSE writes the working set back into the block definition, so every reference to the
|
|
//! block shows the change, or discards the edit.
|
|
//!
|
|
//! The state is [`Drawing::refedit`]: it travels with undo, is never written to a file (files
|
|
//! are written from [`Drawing::committed`]) and ends with the drawing it belongs to.
|
|
//!
|
|
//! Not yet: external references (their source drawing would have to be written back), nested
|
|
//! references picked at a depth, annotative scale contexts and attribute synchronisation.
|
|
|
|
use std::collections::HashSet;
|
|
use std::sync::Arc;
|
|
|
|
use cadcraft_doc::{Drawing, Entity, EntityStore, Handle, RefEdit, Transparency, WorkItem};
|
|
use cadcraft_geom::Mat3;
|
|
use serde_json::{Value, json};
|
|
|
|
use super::super::curves::handle_value;
|
|
use super::super::machines::{SelOutcome, SelectPhase, SelectRun};
|
|
use super::references_block;
|
|
use crate::cmd::{CommandSpec, bad, bool_or, has_doc, str_param, targets};
|
|
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
|
|
|
/// Transparency of the faded copies of the block's other objects.
|
|
const GHOST_FADE: u8 = 70;
|
|
|
|
pub(super) fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
CommandSpec::new("refedit", "Edit Reference In-Place", run_refedit)
|
|
.menu(&["Tools", "Xref and Block In-place Editing", "Edit Reference In-Place"])
|
|
.params("{handle? | handles? | name? (a block reference, default the selection), objects?: [block object handles] (default all)} → {block, insert, working: [handles], faded}")
|
|
.enabled(has_doc)
|
|
.interactive(|_| Ok(Box::new(SelectRun::single("refedit", "REFEDIT", "Select reference")))),
|
|
CommandSpec::new("refset", "Working Set", run_refset)
|
|
.menu(&["Tools", "Xref and Block In-place Editing", "Add to Working Set"])
|
|
.params("{add?: [handles], remove?: [handles]} (or {handles, mode?: \"add\"|\"remove\"}); with neither: lists the working set → {working: [handles], added, removed}")
|
|
.enabled(editing)
|
|
.interactive(|_| Ok(Box::new(RefsetM::default()))),
|
|
CommandSpec::new("refclose", "Save Reference Edits", run_refclose)
|
|
.menu(&["Tools", "Xref and Block In-place Editing", "Save Reference Edits"])
|
|
.params("{option?: \"save\"|\"discard\" | save?: bool (default true)} → {block, saved, objects}")
|
|
.enabled(editing)
|
|
.interactive(|_| Ok(Box::new(RefcloseM))),
|
|
]
|
|
}
|
|
|
|
fn editing(s: &Session) -> std::result::Result<(), String> {
|
|
has_doc(s)?;
|
|
if s.doc().is_ok_and(|d| d.refedit.is_some()) { Ok(()) } else { Err("no block reference is being edited (REFEDIT)".into()) }
|
|
}
|
|
|
|
fn active(s: &Session, cmd: &str) -> Result<RefEdit> {
|
|
s.doc()?.refedit.clone().ok_or_else(|| bad(cmd, "no block reference is being edited (REFEDIT)"))
|
|
}
|
|
|
|
/// A block object as the drawing shows it through the edited reference.
|
|
fn to_host(to_world: &Mat3, e: &Entity, h: Handle, ghost: bool) -> Entity {
|
|
let mut c = e.clone();
|
|
c.handle = h;
|
|
c.kind.transform(to_world);
|
|
if ghost {
|
|
c.common.transparency = Transparency::Percent(GHOST_FADE);
|
|
}
|
|
c
|
|
}
|
|
|
|
/// A drawing object as it belongs in the block, with the block object's handle `h`.
|
|
fn to_block(inv: &Mat3, e: &Entity, h: Handle) -> Entity {
|
|
let mut c = e.clone();
|
|
c.handle = h;
|
|
c.kind.transform(inv);
|
|
c
|
|
}
|
|
|
|
fn put(d: &mut Drawing, r: &RefEdit, e: Entity) -> Result<()> {
|
|
d.ensure_layer(&e.common.layer);
|
|
d.space_mut(&r.space).ok_or_else(|| bad("refedit", "the space of the reference is gone"))?.push(e);
|
|
Ok(())
|
|
}
|
|
|
|
fn hexes(hs: impl IntoIterator<Item = Handle>) -> Vec<String> {
|
|
hs.into_iter().map(|h| h.hex()).collect()
|
|
}
|
|
|
|
// ---------------- REFEDIT ----------------
|
|
|
|
fn run_refedit(s: &mut Session, p: &Value) -> Result<Value> {
|
|
if s.doc()?.refedit.is_some() {
|
|
return Err(bad("refedit", "a reference is already being edited; finish with REFCLOSE"));
|
|
}
|
|
let d = s.doc()?;
|
|
let picked = match str_param(p, "name") {
|
|
Some(n) => d
|
|
.model
|
|
.iter()
|
|
.chain(d.layouts.iter().flat_map(|l| l.entities.iter()))
|
|
.find(|e| matches!(&e.kind, cadcraft_doc::EntityKind::Insert(i) if i.block.eq_ignore_ascii_case(n)))
|
|
.map(|e| e.handle),
|
|
None => targets(s, p)?.into_iter().find(|h| d.entity(*h).is_some_and(|e| matches!(e.kind, cadcraft_doc::EntityKind::Insert(_)))),
|
|
};
|
|
let ih = picked.ok_or_else(|| bad("refedit", "select a block reference"))?;
|
|
let (ins, space) = match d.entity(ih).map(|e| &e.kind) {
|
|
Some(cadcraft_doc::EntityKind::Insert(i)) => (i.clone(), d.space_of(ih).ok_or_else(|| bad("refedit", "the reference is in no space"))?),
|
|
_ => return Err(bad("refedit", "select a block reference")),
|
|
};
|
|
let blk = d.block(&ins.block).cloned().ok_or_else(|| bad("refedit", format!("block \"{}\" not found", ins.block)))?;
|
|
if blk.xref_path.is_some() {
|
|
return Err(bad(
|
|
"refedit",
|
|
format!(
|
|
"\"{}\" is an external reference: editing it in place would write its source drawing, which is not available yet. Open it with XOPEN, edit and save it, then reload.",
|
|
blk.name
|
|
),
|
|
));
|
|
}
|
|
if blk.name.starts_with('*') || blk.array.is_some() {
|
|
return Err(bad("refedit", format!("\"{}\" is generated geometry and can't be edited in place", blk.name)));
|
|
}
|
|
let to_world = ins.transform(blk.base.xy());
|
|
let (sx, sy) = (ins.scale.x.abs(), ins.scale.y.abs());
|
|
let finite = [to_world.a, to_world.b, to_world.c, to_world.d, to_world.e, to_world.f].iter().all(|v| v.is_finite());
|
|
if !finite || to_world.det().abs() < 1e-12 || (sx - sy).abs() > 1e-9 * sx.max(sy) {
|
|
return Err(bad("refedit", "the reference must be scaled equally in X and Y (and not flat) to be edited in place"));
|
|
}
|
|
let chosen: Option<HashSet<Handle>> = match p.get("objects") {
|
|
None | Some(Value::Null) => None,
|
|
Some(Value::String(a)) if a.eq_ignore_ascii_case("all") => None,
|
|
Some(Value::Array(a)) => {
|
|
let mut set = HashSet::new();
|
|
for v in a {
|
|
let h = handle_value(v).ok_or_else(|| bad("refedit", format!("`objects`: {v} is not an object handle")))?;
|
|
if blk.entities.get(h).is_none() {
|
|
return Err(bad("refedit", format!("object {} is not in block \"{}\"", h.hex(), blk.name)));
|
|
}
|
|
set.insert(h);
|
|
}
|
|
if set.is_empty() {
|
|
return Err(bad("refedit", "`objects` is empty"));
|
|
}
|
|
Some(set)
|
|
}
|
|
Some(v) => return Err(bad("refedit", format!("`objects` must be \"all\" or an array of handles, got {v}"))),
|
|
};
|
|
let was_visible = d.entity(ih).is_some_and(|e| e.common.visible);
|
|
let mut r = RefEdit {
|
|
block: blk.name.clone(),
|
|
insert: ih,
|
|
insert_was_visible: was_visible,
|
|
space,
|
|
to_world,
|
|
items: Vec::new(),
|
|
ghosts: Vec::new(),
|
|
released: Vec::new(),
|
|
};
|
|
let doc = s.doc_mut()?;
|
|
for e in blk.entities.iter() {
|
|
let working = chosen.as_ref().is_none_or(|c| c.contains(&e.handle));
|
|
let h = doc.new_handle();
|
|
put(doc, &r, to_host(&to_world, e, h, !working))?;
|
|
if working {
|
|
r.items.push(WorkItem { copy: h, source: Some(e.handle) });
|
|
} else {
|
|
r.ghosts.push((h, e.handle));
|
|
}
|
|
}
|
|
doc.modify_entity(ih, |e| e.common.visible = false).map_err(|e| bad("refedit", e.to_string()))?;
|
|
let out = json!({ "block": r.block, "insert": ih.hex(), "working": hexes(r.items.iter().map(|i| i.copy)), "faded": r.ghosts.len() });
|
|
let sel: Vec<Handle> = r.items.iter().map(|i| i.copy).collect();
|
|
doc.refedit = Some(r);
|
|
s.set_selection(sel);
|
|
Ok(out)
|
|
}
|
|
|
|
// ---------------- REFSET ----------------
|
|
|
|
fn handle_list(p: &Value, key: &str) -> Result<Vec<Handle>> {
|
|
match p.get(key) {
|
|
None | Some(Value::Null) => Ok(Vec::new()),
|
|
Some(Value::Array(a)) => {
|
|
a.iter().map(|v| handle_value(v).ok_or_else(|| bad("refset", format!("`{key}`: {v} is not an object handle")))).collect()
|
|
}
|
|
Some(v) => Err(bad("refset", format!("`{key}` must be an array of object handles, got {v}"))),
|
|
}
|
|
}
|
|
|
|
fn run_refset(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let (mut add, mut remove) = (handle_list(p, "add")?, handle_list(p, "remove")?);
|
|
let hs = handle_list(p, "handles")?;
|
|
if str_param(p, "mode").is_some_and(|m| m.to_ascii_lowercase().starts_with('r')) {
|
|
remove.extend(hs)
|
|
} else {
|
|
add.extend(hs)
|
|
}
|
|
apply_refset(s, &add, &remove)
|
|
}
|
|
|
|
fn apply_refset(s: &mut Session, add: &[Handle], remove: &[Handle]) -> Result<Value> {
|
|
let mut r = active(s, "refset")?;
|
|
let inv = r.to_world.inverse().ok_or_else(|| bad("refset", "the reference can't be inverted"))?;
|
|
let d = s.doc()?;
|
|
for h in add.iter().chain(remove) {
|
|
if d.entity(*h).is_none() {
|
|
return Err(bad("refset", format!("no object {}", h.hex())));
|
|
}
|
|
}
|
|
if let Some(h) = remove.iter().find(|h| !r.is_working(**h)) {
|
|
return Err(bad("refset", format!("object {} is not in the working set", h.hex())));
|
|
}
|
|
let blk = d.block(&r.block).cloned().ok_or_else(|| bad("refset", format!("block \"{}\" is gone; REFCLOSE Discard", r.block)))?;
|
|
let (mut added, mut removed) = (0usize, 0usize);
|
|
let doc = s.doc_mut()?;
|
|
for h in add {
|
|
if *h == r.insert || r.is_working(*h) {
|
|
continue;
|
|
}
|
|
if let Some(pos) = r.ghosts.iter().position(|(g, _)| g == h) {
|
|
// A faded block object joins the working set (with the edits it had before).
|
|
let (g, src) = r.ghosts.remove(pos);
|
|
doc.remove_entity(g);
|
|
let cur = match r.released.iter().position(|e| e.handle == src) {
|
|
Some(i) => Some(r.released.remove(i)),
|
|
None => blk.entities.get(src).map(|e| (**e).clone()),
|
|
};
|
|
if let Some(cur) = cur {
|
|
let c = doc.new_handle();
|
|
put(doc, &r, to_host(&r.to_world, &cur, c, false))?;
|
|
r.items.push(WorkItem { copy: c, source: Some(src) });
|
|
added += 1;
|
|
}
|
|
} else {
|
|
r.items.push(WorkItem { copy: *h, source: None });
|
|
added += 1;
|
|
}
|
|
}
|
|
for h in remove {
|
|
let Some(pos) = r.items.iter().position(|i| i.copy == *h) else { continue };
|
|
let item = r.items.remove(pos);
|
|
if let Some(src) = item.source
|
|
&& let Some(e) = doc.entity(*h).map(|e| (**e).clone())
|
|
{
|
|
// Back to the block, edits kept, shown faded.
|
|
let kept = to_block(&inv, &e, src);
|
|
doc.remove_entity(*h);
|
|
let g = doc.new_handle();
|
|
put(doc, &r, to_host(&r.to_world, &kept, g, true))?;
|
|
r.ghosts.push((g, src));
|
|
r.released.push(kept);
|
|
}
|
|
removed += 1;
|
|
}
|
|
let working = hexes(r.items.iter().map(|i| i.copy));
|
|
doc.refedit = Some(r);
|
|
Ok(json!({ "working": working, "added": added, "removed": removed }))
|
|
}
|
|
|
|
// ---------------- REFCLOSE ----------------
|
|
|
|
fn run_refclose(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let save = match str_param(p, "option") {
|
|
Some(o) if o.to_ascii_lowercase().starts_with('d') => false,
|
|
Some(o) if o.to_ascii_lowercase().starts_with('s') => true,
|
|
Some(o) => return Err(bad("refclose", format!("unknown option \"{o}\" (save or discard)"))),
|
|
None => bool_or(p, "save", true),
|
|
};
|
|
close(s, save)
|
|
}
|
|
|
|
fn close(s: &mut Session, save: bool) -> Result<Value> {
|
|
let r = active(s, "refclose")?;
|
|
// Work on a copy: the drawing changes only when everything went through.
|
|
let mut nd: Drawing = s.doc()?.clone();
|
|
let mut objects = 0;
|
|
if save {
|
|
objects = commit_block(&mut nd, &r)?;
|
|
}
|
|
nd.end_refedit_discard(&r);
|
|
*s.doc_mut()? = nd;
|
|
// The objects the edit managed were its own: OOPS doesn't bring them back after it.
|
|
s.state_mut()?.erased.forget(|h| r.owns(h));
|
|
s.set_selection(Vec::new());
|
|
Ok(json!({ "block": r.block, "saved": save, "objects": objects }))
|
|
}
|
|
|
|
/// Replace the block's objects by the working set (and move the drawing objects added to it
|
|
/// into the block). Returns the number of objects the block has now.
|
|
fn commit_block(nd: &mut Drawing, r: &RefEdit) -> Result<usize> {
|
|
let blk = nd.block(&r.block).cloned().ok_or_else(|| bad("refclose", format!("block \"{}\" is gone; use REFCLOSE Discard", r.block)))?;
|
|
let inv = r.to_world.inverse().ok_or_else(|| bad("refclose", "the reference can't be inverted; use REFCLOSE Discard"))?;
|
|
let mut ents: Vec<Entity> = Vec::new();
|
|
for e in blk.entities.iter() {
|
|
match r.items.iter().find(|i| i.source == Some(e.handle)) {
|
|
// A working object the user erased leaves the block.
|
|
Some(i) => {
|
|
if let Some(c) = nd.entity(i.copy) {
|
|
ents.push(to_block(&inv, c, e.handle));
|
|
}
|
|
}
|
|
None => match r.released.iter().find(|x| x.handle == e.handle) {
|
|
Some(x) => ents.push(x.clone()),
|
|
None => ents.push((**e).clone()),
|
|
},
|
|
}
|
|
}
|
|
let mut moved = Vec::new();
|
|
for i in r.items.iter().filter(|i| i.source.is_none()) {
|
|
if let Some(c) = nd.entity(i.copy).map(|c| (**c).clone()) {
|
|
let h = nd.new_handle();
|
|
ents.push(to_block(&inv, &c, h));
|
|
moved.push(i.copy);
|
|
}
|
|
}
|
|
if references_block(nd, &ents, &r.block) {
|
|
return Err(bad("refclose", format!("Block \"{}\" would reference itself; remove that reference or use REFCLOSE Discard.", r.block)));
|
|
}
|
|
let n = ents.len();
|
|
let mut store = EntityStore::new();
|
|
for e in ents {
|
|
store.push(e);
|
|
}
|
|
let key = nd.blocks.keys().find(|k| k.eq_ignore_ascii_case(&r.block)).cloned().unwrap_or_else(|| r.block.clone());
|
|
let mut nb = (*blk).clone();
|
|
nb.entities = store;
|
|
nd.blocks.insert(key, Arc::new(nb));
|
|
for h in moved {
|
|
nd.remove_entity(h);
|
|
}
|
|
Ok(n)
|
|
}
|
|
|
|
// ---------------- while editing ----------------
|
|
|
|
/// After a command ran: objects it created join the working set, and a command that changed
|
|
/// anything outside the working set is undone (returns the reason).
|
|
pub(crate) fn after_command(s: &mut Session, before: &Arc<Drawing>, id: &str) -> Option<String> {
|
|
if matches!(id, "refedit" | "refset" | "refclose") || !crate::cmd::find_command(id).is_some_and(|c| c.undoable) {
|
|
return None;
|
|
}
|
|
let rb = before.refedit.as_ref()?;
|
|
let st = s.state_mut().ok()?;
|
|
if Arc::ptr_eq(before, &st.doc) {
|
|
return None;
|
|
}
|
|
let ra = st.doc.refedit.as_ref().filter(|ra| ra.insert == rb.insert)?;
|
|
let owned: HashSet<Handle> = rb.items.iter().map(|i| i.copy).chain(rb.ghosts.iter().map(|g| g.0)).chain([rb.insert]).collect();
|
|
let stores = |d: &Drawing| std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).cloned().collect::<Vec<_>>();
|
|
let touched = stores(before)
|
|
.iter()
|
|
.any(|store| store.iter().any(|e| !owned.contains(&e.handle) && !st.doc.entity(e.handle).is_some_and(|a| Arc::ptr_eq(a, e))));
|
|
if touched {
|
|
st.doc = before.clone();
|
|
return Some("Only the working set can be edited during REFEDIT (REFSET adds objects, REFCLOSE ends the edit).".into());
|
|
}
|
|
let seed = before.handseed;
|
|
let mut new: Vec<Handle> = Vec::new();
|
|
for store in stores(&st.doc) {
|
|
new.extend(store.iter().map(|e| e.handle).filter(|h| h.0 >= seed && !ra.owns(*h)));
|
|
}
|
|
if !new.is_empty()
|
|
&& let Some(r) = Arc::make_mut(&mut st.doc).refedit.as_mut()
|
|
{
|
|
r.items.extend(new.into_iter().map(|copy| WorkItem { copy, source: None }));
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Whether `h` may not be edited because a reference is being edited and it is not in the working set.
|
|
pub(crate) fn locked(d: &Drawing, h: Handle) -> bool {
|
|
d.refedit.as_ref().is_some_and(|r| !r.is_working(h))
|
|
}
|
|
|
|
// ---------------- interactive ----------------
|
|
|
|
#[derive(Default)]
|
|
enum RStage {
|
|
#[default]
|
|
Mode,
|
|
Pick(bool),
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct RefsetM {
|
|
stage: RStage,
|
|
sel: SelectPhase,
|
|
}
|
|
|
|
impl Interactive for RefsetM {
|
|
fn name(&self) -> &'static str {
|
|
"REFSET"
|
|
}
|
|
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
|
active(s, "refset").map(|_| Step::Continue)
|
|
}
|
|
fn prompt(&self, _s: &Session) -> Prompt {
|
|
match self.stage {
|
|
RStage::Mode => {
|
|
let mut p = Prompt::new("Enter an option [Add/Remove] <Add>", Accept::TEXT);
|
|
p.keywords = vec!["Add".into(), "Remove".into()];
|
|
p.default = Some("Add".into());
|
|
p
|
|
}
|
|
RStage::Pick(remove) => {
|
|
let mut p = self.sel.prompt();
|
|
p.message = format!("Select objects to {}", if remove { "remove" } else { "add" });
|
|
p
|
|
}
|
|
}
|
|
}
|
|
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
|
match self.stage {
|
|
RStage::Mode => {
|
|
let remove = match i {
|
|
Input::Keyword(k) | Input::Text(k) => k.to_ascii_lowercase().starts_with('r'),
|
|
Input::Enter => false,
|
|
_ => return Ok(Step::Cancel),
|
|
};
|
|
self.sel = SelectPhase::begin(s);
|
|
self.stage = RStage::Pick(remove);
|
|
if self.sel.done {
|
|
let hs = self.sel.picked.clone();
|
|
return self.finish(s, remove, &hs);
|
|
}
|
|
Ok(Step::Continue)
|
|
}
|
|
RStage::Pick(remove) => match self.sel.feed(s, &i)? {
|
|
SelOutcome::More => Ok(Step::Continue),
|
|
SelOutcome::Empty => Ok(Step::Done),
|
|
SelOutcome::Done(hs) => self.finish(s, remove, &hs),
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
impl RefsetM {
|
|
fn finish(&mut self, s: &mut Session, remove: bool, hs: &[Handle]) -> Result<Step> {
|
|
let r = if remove { apply_refset(s, &[], hs) } else { apply_refset(s, hs, &[]) }?;
|
|
s.echo(format!("{} object(s) in the working set.", r.get("working").and_then(Value::as_array).map_or(0, Vec::len)));
|
|
s.set_selection(Vec::new());
|
|
Ok(Step::Done)
|
|
}
|
|
}
|
|
|
|
struct RefcloseM;
|
|
|
|
impl Interactive for RefcloseM {
|
|
fn name(&self) -> &'static str {
|
|
"REFCLOSE"
|
|
}
|
|
fn prompt(&self, _s: &Session) -> Prompt {
|
|
let mut p = Prompt::new("Enter option [Save/Discard reference changes] <Save>", Accept::TEXT);
|
|
p.keywords = vec!["Save".into(), "Discard".into()];
|
|
p.default = Some("Save".into());
|
|
p
|
|
}
|
|
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
|
let save = match i {
|
|
Input::Keyword(k) | Input::Text(k) => !k.to_ascii_lowercase().starts_with('d'),
|
|
Input::Enter => true,
|
|
_ => return Ok(Step::Cancel),
|
|
};
|
|
let r = close(s, save)?;
|
|
s.echo(if save { "Reference changes saved." } else { "Reference changes discarded." });
|
|
let _ = r;
|
|
Ok(Step::Done)
|
|
}
|
|
}
|