1262 lines
46 KiB
Rust
1262 lines
46 KiB
Rust
//! Editor-local, explicitly approved alias transactions. Workers own immutable
|
||
//! snapshots; only the live form can approve adoption into the current document.
|
||
use super::*;
|
||
use crate::{
|
||
document::PreparedAliasRename,
|
||
source_navigation::{self as nav, query},
|
||
source_rename::{origin, preview},
|
||
};
|
||
mod alias_input;
|
||
mod editor_patch;
|
||
const LINES_PER_PAGE: usize = 8;
|
||
|
||
// A transient display hint from the same approved candidate. Document authority,
|
||
// model synchronization and source ownership remain with their existing paths.
|
||
pub(super) struct EditorRefresh<'a> {
|
||
path: &'a str,
|
||
before: &'a str,
|
||
after: &'a str,
|
||
}
|
||
|
||
impl EditorRefresh<'_> {
|
||
pub(super) fn apply(self, editor: &mut text_editor::Content, path: &str, source: &str) -> bool {
|
||
self.path == path
|
||
&& self.after == source
|
||
&& editor_patch::replace(editor, self.before, self.after)
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct Begin {
|
||
pub document: Id,
|
||
pub revision: u64,
|
||
pub source: query::SnapshotKey,
|
||
pub path: String,
|
||
pub cursor: text_editor::Cursor,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub enum Event {
|
||
Begin(Begin),
|
||
Input(Id, String),
|
||
PasteRejected(Id),
|
||
Preview(Id, Id),
|
||
Confirm(Id, Id, Id),
|
||
Close(Id),
|
||
Search(Id, String),
|
||
File(Id, Id, bool),
|
||
Hunk(Id, Id, bool),
|
||
Line(Id, Id, bool),
|
||
Segment(Id, Id, bool),
|
||
Details(Id, bool),
|
||
PreviewScrolled(Id, Id, Id, f32),
|
||
DetailSegment(Id, bool),
|
||
ReadOnly(Id, text_editor::Action),
|
||
Undo(Id),
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
struct Context {
|
||
begin: Begin,
|
||
accepted: Arc<CompiledSourceProject>,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
struct Job {
|
||
id: Id,
|
||
invocation: Id,
|
||
generation: Id,
|
||
context: Context,
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
struct Target {
|
||
id: Id,
|
||
alias: String,
|
||
caption: String,
|
||
origin: query::Origin,
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
struct Approval {
|
||
id: Id,
|
||
prepared: PreparedAliasRename,
|
||
preview: preview::Preview,
|
||
cursor: text_editor::Cursor,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
enum Payload {
|
||
Target(Result<Arc<Target>, String>),
|
||
Preview(Result<Arc<Approval>, String>),
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub struct Completion {
|
||
job: Job,
|
||
payload: Payload,
|
||
}
|
||
|
||
struct Form {
|
||
invocation: Id,
|
||
generation: Id,
|
||
context: Context,
|
||
target: Option<Arc<Target>>,
|
||
replacement: String,
|
||
notice: String,
|
||
stale: bool,
|
||
pending: Option<Id>,
|
||
approval: Option<Arc<Approval>>,
|
||
search: String,
|
||
file: usize,
|
||
hunk: usize,
|
||
line: usize,
|
||
segment: usize,
|
||
preview_destination: Id,
|
||
preview_offset: f32,
|
||
details: bool,
|
||
detail_segment: usize,
|
||
detail_editor: text_editor::Content,
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
struct Undo {
|
||
id: Id,
|
||
context: Context,
|
||
}
|
||
|
||
#[derive(Default)]
|
||
pub struct State {
|
||
form: Option<Form>,
|
||
pub(super) in_flight: Option<Id>,
|
||
undo: Option<Undo>,
|
||
}
|
||
|
||
impl State {
|
||
pub fn open(&self) -> bool {
|
||
self.form.is_some()
|
||
}
|
||
}
|
||
|
||
impl App {
|
||
pub fn source_rename_begin(&self) -> Begin {
|
||
Begin {
|
||
document: self.canvas_document,
|
||
revision: self.document.revision(),
|
||
source: self.source_key(),
|
||
path: self.source_workspace.active.clone(),
|
||
cursor: self.source_editor.cursor(),
|
||
}
|
||
}
|
||
|
||
pub fn source_rename_message(&self) -> Message {
|
||
Message::SourceRename(Event::Begin(self.source_rename_begin()))
|
||
}
|
||
|
||
// Called before source_interaction or any form mutation. In particular an
|
||
// old Close/Confirm cannot disturb a newer form with the same source path.
|
||
pub(super) fn source_rename_envelope(&self, event: &Event) -> bool {
|
||
if let Event::Begin(begin) = event {
|
||
return self.page == Page::Text
|
||
&& !self.source_rename.open()
|
||
&& *begin == self.source_rename_begin();
|
||
}
|
||
if let Event::Undo(id) = event {
|
||
return self.source_rename.undo.as_ref().is_some_and(|undo| {
|
||
undo.id == *id
|
||
&& !self.source_rename.open()
|
||
&& self.rename_undo_matches(&undo.context)
|
||
});
|
||
}
|
||
let Some(form) = &self.source_rename.form else {
|
||
return false;
|
||
};
|
||
match event {
|
||
Event::PreviewScrolled(invocation, approval, destination, offset) => {
|
||
*invocation == form.invocation
|
||
&& !form.details
|
||
&& form
|
||
.approval
|
||
.as_ref()
|
||
.is_some_and(|ready| ready.id == *approval)
|
||
&& *destination == form.preview_destination
|
||
&& offset.is_finite()
|
||
&& *offset >= 0.
|
||
}
|
||
Event::Preview(invocation, generation) => {
|
||
*invocation == form.invocation && *generation == form.generation
|
||
}
|
||
Event::Confirm(invocation, generation, preview) => {
|
||
*invocation == form.invocation
|
||
&& *generation == form.generation
|
||
&& form
|
||
.approval
|
||
.as_ref()
|
||
.is_some_and(|ready| ready.id == *preview)
|
||
}
|
||
Event::File(invocation, preview, _)
|
||
| Event::Hunk(invocation, preview, _)
|
||
| Event::Line(invocation, preview, _)
|
||
| Event::Segment(invocation, preview, _) => {
|
||
*invocation == form.invocation
|
||
&& form
|
||
.approval
|
||
.as_ref()
|
||
.is_some_and(|ready| ready.id == *preview)
|
||
}
|
||
Event::Input(invocation, _)
|
||
| Event::PasteRejected(invocation)
|
||
| Event::Close(invocation)
|
||
| Event::Search(invocation, _)
|
||
| Event::Details(invocation, _)
|
||
| Event::DetailSegment(invocation, _)
|
||
| Event::ReadOnly(invocation, _) => *invocation == form.invocation,
|
||
Event::Begin(_) | Event::Undo(_) => false,
|
||
}
|
||
}
|
||
|
||
pub(super) fn rename_admission(&self) -> Result<(), String> {
|
||
if self.page != Page::Text
|
||
|| self.busy
|
||
|| self.pending.is_some()
|
||
|| self.file_prompt.is_some()
|
||
|| self.delete_pending
|
||
|| self.workflow.is_some()
|
||
|| self.hosted.visible
|
||
|| self.model_menu.is_some()
|
||
|| self.architecture.is_some()
|
||
|| self.diagram_usages.is_some()
|
||
|| self.reference_input_active()
|
||
|| self.behavior.has_pending()
|
||
|| self.draft_dirty()
|
||
{
|
||
return Err("Finish or cancel the current property, behavior or file operation before renaming. No drafts were applied.".into());
|
||
}
|
||
let workspace = &self.source_workspace;
|
||
let accepted = self.document.bundle();
|
||
if !workspace.applied || workspace.has_edits() {
|
||
return Err(
|
||
"Apply or revert source drafts before renaming an alias. Your drafts are retained."
|
||
.into(),
|
||
);
|
||
}
|
||
if workspace.entry != accepted.entry
|
||
|| workspace.files.len() != accepted.files.len()
|
||
|| !workspace
|
||
.files
|
||
.iter()
|
||
.zip(&accepted.files)
|
||
.all(|((path, buffer), (other, text))| path == other && buffer.text == *text)
|
||
|| workspace.reachable.len() != accepted.files.len()
|
||
|| !workspace.reachable.iter().eq(accepted.files.keys())
|
||
|| self.source_editor.text() != workspace.text()
|
||
{
|
||
return Err("Rename requires exactly the accepted included files. Close a clean detached buffer, or reattach/revert its draft first.".into());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn rename_context(&self) -> Context {
|
||
Context {
|
||
begin: self.source_rename_begin(),
|
||
accepted: self.document.snapshot(),
|
||
}
|
||
}
|
||
|
||
fn rename_context_matches(&self, context: &Context) -> bool {
|
||
context.begin == self.source_rename_begin()
|
||
&& Arc::ptr_eq(&context.accepted, &self.document.snapshot())
|
||
&& self.rename_admission().is_ok()
|
||
}
|
||
|
||
fn rename_undo_matches(&self, context: &Context) -> bool {
|
||
let mut current = self.source_rename_begin();
|
||
current.cursor = context.begin.cursor;
|
||
context.begin == current
|
||
&& Arc::ptr_eq(&context.accepted, &self.document.snapshot())
|
||
&& self.rename_admission().is_ok()
|
||
}
|
||
|
||
pub(super) fn invalidate_source_rename(&mut self, message: &str) {
|
||
if let Some(form) = &mut self.source_rename.form {
|
||
form.stale = true;
|
||
form.pending = None;
|
||
form.approval = None;
|
||
form.notice = message.into();
|
||
}
|
||
self.source_rename.undo = None;
|
||
}
|
||
|
||
pub(super) fn refresh_source_rename_context(&mut self) {
|
||
let stale = self
|
||
.source_rename
|
||
.form
|
||
.as_ref()
|
||
.is_some_and(|form| !form.stale && !self.rename_context_matches(&form.context));
|
||
if stale {
|
||
self.invalidate_source_rename("Source context changed. Your replacement is retained; close and invoke Rename again on the current source.");
|
||
}
|
||
if self
|
||
.source_rename
|
||
.undo
|
||
.as_ref()
|
||
.is_some_and(|undo| !self.rename_undo_matches(&undo.context))
|
||
{
|
||
self.source_rename.undo = None;
|
||
}
|
||
if let Some(form) = &mut self.source_rename.form
|
||
&& form.details
|
||
{
|
||
refresh_details(form);
|
||
}
|
||
}
|
||
|
||
pub(super) fn close_source_rename(&mut self) {
|
||
if let Some(form) = self.source_rename.form.take() {
|
||
// Do not release the occupied worker. Its matching completion does.
|
||
if self.rename_context_matches(&form.context) {
|
||
self.queue_source_reveal(form.context.begin.cursor, false);
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn source_rename_undo_message(&self) -> Option<Message> {
|
||
self.source_rename
|
||
.undo
|
||
.as_ref()
|
||
.filter(|undo| !self.source_rename.open() && self.rename_undo_matches(&undo.context))
|
||
.map(|undo| Message::SourceRename(Event::Undo(undo.id)))
|
||
}
|
||
|
||
pub(super) fn source_rename_event(&mut self, event: Event) -> Task<Message> {
|
||
let mut reset_preview_scroll = false;
|
||
match event {
|
||
Event::Begin(_) => return self.begin_source_rename(),
|
||
Event::Close(_) => self.close_source_rename(),
|
||
Event::Input(_, value) => {
|
||
let Some(form) = &mut self.source_rename.form else {
|
||
return Task::none();
|
||
};
|
||
if form.replacement != value {
|
||
form.replacement = value;
|
||
form.generation = Id::new_v4();
|
||
form.approval = None;
|
||
form.pending = None;
|
||
if !form.stale {
|
||
form.notice = replacement_notice(form.target.as_deref(), &form.replacement)
|
||
.unwrap_or_else(|| {
|
||
"Choose Preview to review the complete change.".into()
|
||
});
|
||
}
|
||
}
|
||
}
|
||
Event::PasteRejected(_) => {
|
||
let form = self.source_rename.form.as_mut().unwrap();
|
||
form.generation = Id::new_v4();
|
||
form.approval = None;
|
||
form.pending = None;
|
||
form.notice = "Paste rejected: aliases cannot contain line breaks, tabs or other control characters. The field is unchanged; paste a single-line alias and choose Preview.".into();
|
||
if form.stale {
|
||
form.notice
|
||
.push_str(" The source context changed; close and invoke Rename again.");
|
||
}
|
||
}
|
||
Event::Preview(_, _) => return self.preview_source_rename(),
|
||
Event::Confirm(_, _, _) => self.confirm_source_rename(),
|
||
Event::Undo(_) => {
|
||
let Some(undo) = self.source_rename.undo.take() else {
|
||
return Task::none();
|
||
};
|
||
if !self.source_rename.open()
|
||
&& self.rename_undo_matches(&undo.context)
|
||
&& self.document.undo()
|
||
{
|
||
self.sync();
|
||
self.status =
|
||
"Alias rename undone as one model change. Redo model restores it.".into();
|
||
}
|
||
}
|
||
Event::Search(_, value) => {
|
||
let form = self.source_rename.form.as_mut().unwrap();
|
||
if form.search == value {
|
||
return Task::none();
|
||
}
|
||
form.search = value;
|
||
reset_preview_scroll = form.approval.is_some();
|
||
if let Some(approval) = &form.approval
|
||
&& let Some(index) = approval
|
||
.preview
|
||
.files()
|
||
.iter()
|
||
.position(|file| file.path().contains(&form.search))
|
||
{
|
||
form.file = index;
|
||
form.hunk = 0;
|
||
form.line = 0;
|
||
form.segment = 0;
|
||
}
|
||
}
|
||
Event::File(_, _, next)
|
||
| Event::Hunk(_, _, next)
|
||
| Event::Line(_, _, next)
|
||
| Event::Segment(_, _, next) => {
|
||
let form = self.source_rename.form.as_mut().unwrap();
|
||
let ready = form.approval.as_ref().unwrap();
|
||
let previous_page = (form.file, form.hunk, form.line, form.segment);
|
||
match event {
|
||
Event::File(..) => {
|
||
let matching: Vec<_> = ready
|
||
.preview
|
||
.files()
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(i, file)| file.path().contains(&form.search).then_some(i))
|
||
.collect();
|
||
if let Some(position) = matching.iter().position(|i| *i == form.file) {
|
||
form.file = matching[step(position, matching.len(), next)];
|
||
}
|
||
form.hunk = 0;
|
||
form.line = 0;
|
||
form.segment = 0;
|
||
}
|
||
Event::Hunk(..) => {
|
||
form.hunk = step(
|
||
form.hunk,
|
||
ready.preview.files()[form.file].hunks().len(),
|
||
next,
|
||
);
|
||
form.line = 0;
|
||
form.segment = 0;
|
||
}
|
||
Event::Line(..) => {
|
||
let count = ready.preview.files()[form.file].hunks()[form.hunk]
|
||
.lines()
|
||
.len()
|
||
.div_ceil(LINES_PER_PAGE);
|
||
form.line = step(form.line / LINES_PER_PAGE, count, next) * LINES_PER_PAGE;
|
||
form.segment = 0;
|
||
}
|
||
_ => form.segment = step(form.segment, segment_total(form), next),
|
||
}
|
||
reset_preview_scroll =
|
||
previous_page != (form.file, form.hunk, form.line, form.segment);
|
||
}
|
||
Event::Details(_, value) => {
|
||
let form = self.source_rename.form.as_mut().unwrap();
|
||
if form.details == value {
|
||
return Task::none();
|
||
}
|
||
form.details = value;
|
||
// Leaving or returning to the preview ends the old visit. Keep
|
||
// its selected page and scroll; the Details widget replaces the
|
||
// preview subtree, so restore the reported offset on return.
|
||
form.preview_destination = Id::new_v4();
|
||
form.detail_segment = 0;
|
||
refresh_details(form);
|
||
if !value && let Some(ready) = &form.approval {
|
||
return iced::widget::operation::scroll_to(
|
||
preview_scroll_id(form, ready.id),
|
||
iced::widget::scrollable::AbsoluteOffset {
|
||
x: None,
|
||
y: Some(form.preview_offset),
|
||
},
|
||
);
|
||
}
|
||
}
|
||
Event::PreviewScrolled(_, _, _, offset) => {
|
||
self.source_rename.form.as_mut().unwrap().preview_offset = offset;
|
||
}
|
||
Event::DetailSegment(_, next) => {
|
||
let form = self.source_rename.form.as_mut().unwrap();
|
||
form.detail_segment = step(
|
||
form.detail_segment,
|
||
preview::segment_count(&details(form)),
|
||
next,
|
||
);
|
||
refresh_details(form);
|
||
}
|
||
Event::ReadOnly(_, action) => {
|
||
if !action.is_edit() {
|
||
self.source_rename
|
||
.form
|
||
.as_mut()
|
||
.unwrap()
|
||
.detail_editor
|
||
.perform(action);
|
||
}
|
||
}
|
||
}
|
||
if reset_preview_scroll && let Some(form) = &mut self.source_rename.form {
|
||
return reset_preview_position(form);
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
fn begin_source_rename(&mut self) -> Task<Message> {
|
||
if let Err(error) = self.rename_admission() {
|
||
self.status = error;
|
||
return Task::none();
|
||
}
|
||
if self.source_rename.in_flight.is_some() {
|
||
self.status =
|
||
"The previous rename task is finishing. Try Rename again when it completes.".into();
|
||
return Task::none();
|
||
}
|
||
let context = self.rename_context();
|
||
let Some(offset) = nav::cursor_offset(self.source_workspace.text(), context.begin.cursor)
|
||
else {
|
||
self.status = "Place the cursor on a source alias or its @ reference.".into();
|
||
return Task::none();
|
||
};
|
||
let input = match self
|
||
.with_source_input(|input| query::admit_owned(input, query::Limits::default()))
|
||
{
|
||
Ok(input) => input,
|
||
Err(error) => {
|
||
self.status = format!("Rename source admission failed: {error:?}");
|
||
return Task::none();
|
||
}
|
||
};
|
||
let job = Job {
|
||
id: Id::new_v4(),
|
||
invocation: Id::new_v4(),
|
||
generation: Id::new_v4(),
|
||
context,
|
||
};
|
||
self.source_rename.undo = None;
|
||
self.source_rename.in_flight = Some(job.id);
|
||
self.source_rename.form = Some(Form {
|
||
invocation: job.invocation,
|
||
generation: job.generation,
|
||
context: job.context.clone(),
|
||
target: None,
|
||
replacement: String::new(),
|
||
notice: "Checking the selected source alias…".into(),
|
||
stale: false,
|
||
pending: Some(job.id),
|
||
approval: None,
|
||
search: String::new(),
|
||
file: 0,
|
||
hunk: 0,
|
||
line: 0,
|
||
segment: 0,
|
||
preview_destination: Id::new_v4(),
|
||
preview_offset: 0.,
|
||
details: false,
|
||
detail_segment: 0,
|
||
detail_editor: text_editor::Content::new(),
|
||
});
|
||
Task::perform(
|
||
async move {
|
||
let worker = job.clone();
|
||
let result = tokio::task::spawn_blocking(move || {
|
||
let snapshot = input
|
||
.capture()
|
||
.map_err(|error| format!("Rename source admission failed: {error:?}"))?;
|
||
resolve_target(&snapshot, &worker.context, offset).map(Arc::new)
|
||
})
|
||
.await
|
||
.unwrap_or_else(|error| Err(format!("Rename target task failed: {error}")));
|
||
Completion {
|
||
job,
|
||
payload: Payload::Target(result),
|
||
}
|
||
},
|
||
Message::SourceRenameReady,
|
||
)
|
||
}
|
||
|
||
fn preview_source_rename(&mut self) -> Task<Message> {
|
||
let Some(form) = &self.source_rename.form else {
|
||
return Task::none();
|
||
};
|
||
if form.stale || !self.rename_context_matches(&form.context) {
|
||
self.invalidate_source_rename("Source context changed. Close and invoke Rename again; your replacement is retained.");
|
||
return Task::none();
|
||
}
|
||
if self.source_rename.in_flight.is_some() {
|
||
return Task::none();
|
||
}
|
||
let Some(target) = form.target.clone() else {
|
||
return Task::none();
|
||
};
|
||
if let Some(notice) = replacement_notice(Some(&target), &form.replacement) {
|
||
self.source_rename.form.as_mut().unwrap().notice = notice;
|
||
return Task::none();
|
||
}
|
||
let request = match self
|
||
.document
|
||
.alias_rename_request(target.id, form.replacement.clone())
|
||
{
|
||
Ok(request) => request,
|
||
Err(error) => {
|
||
self.source_rename.form.as_mut().unwrap().notice = error.to_string();
|
||
return Task::none();
|
||
}
|
||
};
|
||
let job = Job {
|
||
id: Id::new_v4(),
|
||
invocation: form.invocation,
|
||
generation: form.generation,
|
||
context: form.context.clone(),
|
||
};
|
||
self.source_rename.in_flight = Some(job.id);
|
||
let form = self.source_rename.form.as_mut().unwrap();
|
||
form.pending = Some(job.id);
|
||
form.approval = None;
|
||
form.notice = "Preparing the complete rename preview…".into();
|
||
Task::perform(
|
||
async move {
|
||
let path = job.context.begin.path.clone();
|
||
let result = tokio::task::spawn_blocking(move || {
|
||
let prepared = request.prepare().map_err(|error| error.to_string())?;
|
||
prepare_approval(prepared, &target, &path).map(Arc::new)
|
||
})
|
||
.await
|
||
.unwrap_or_else(|error| Err(format!("Rename preview task failed: {error}")));
|
||
Completion {
|
||
job,
|
||
payload: Payload::Preview(result),
|
||
}
|
||
},
|
||
Message::SourceRenameReady,
|
||
)
|
||
}
|
||
|
||
pub(super) fn source_rename_ready(&mut self, completion: Completion) -> Task<Message> {
|
||
if self.source_rename.in_flight != Some(completion.job.id) {
|
||
return Task::none();
|
||
}
|
||
self.source_rename.in_flight = None;
|
||
let job = &completion.job;
|
||
let wanted = self.source_rename.form.as_ref().is_some_and(|form| {
|
||
form.invocation == job.invocation
|
||
&& form.generation == job.generation
|
||
&& form.pending == Some(job.id)
|
||
&& !form.stale
|
||
}) && self.rename_context_matches(&job.context);
|
||
if !wanted {
|
||
self.refresh_source_rename_context();
|
||
return Task::none();
|
||
}
|
||
let form = self.source_rename.form.as_mut().unwrap();
|
||
form.pending = None;
|
||
let mut reset_preview_scroll = false;
|
||
match completion.payload {
|
||
Payload::Target(Ok(target)) => {
|
||
form.replacement = target.alias.clone();
|
||
form.target = Some(target);
|
||
form.notice = "No change. Enter a different source alias, then Preview.".into();
|
||
if form.details {
|
||
refresh_details(form);
|
||
return Task::none();
|
||
}
|
||
return nav::interaction::guarded_focus(
|
||
input_id(form.invocation),
|
||
self.source_symbols.raw_interaction.clone(),
|
||
self.source_symbols
|
||
.raw_interaction
|
||
.load(std::sync::atomic::Ordering::SeqCst),
|
||
);
|
||
}
|
||
Payload::Preview(Ok(ready)) => {
|
||
if !ready.prepared.changed() {
|
||
form.notice = "No change. Nothing was applied.".into();
|
||
} else {
|
||
form.notice = "Review every changed file. Rename alias adopts this preview as one model change; Save remains separate.".into();
|
||
form.approval = Some(ready);
|
||
form.file = 0;
|
||
form.hunk = 0;
|
||
form.line = 0;
|
||
form.segment = 0;
|
||
form.search.clear();
|
||
reset_preview_scroll = true;
|
||
}
|
||
}
|
||
Payload::Target(Err(error)) | Payload::Preview(Err(error)) => form.notice = error,
|
||
}
|
||
if form.details {
|
||
refresh_details(form);
|
||
}
|
||
if reset_preview_scroll {
|
||
return reset_preview_position(form);
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
fn confirm_source_rename(&mut self) {
|
||
let Some(form) = &self.source_rename.form else {
|
||
return;
|
||
};
|
||
if form.stale || !self.rename_context_matches(&form.context) {
|
||
self.invalidate_source_rename(
|
||
"This preview is stale. Close and invoke Rename on the current source.",
|
||
);
|
||
return;
|
||
}
|
||
let Some(target) = &form.target else { return };
|
||
let Some(approval) = &form.approval else {
|
||
return;
|
||
};
|
||
if approval.prepared.target() != target.id
|
||
|| approval.prepared.old_alias() != target.alias
|
||
|| approval.prepared.new_alias() != form.replacement
|
||
|| approval.prepared.revision() != form.context.begin.revision
|
||
{
|
||
return;
|
||
}
|
||
let approval = self
|
||
.source_rename
|
||
.form
|
||
.as_mut()
|
||
.unwrap()
|
||
.approval
|
||
.take()
|
||
.unwrap();
|
||
match self.document.commit_alias_rename(&approval.prepared) {
|
||
Ok(true) => {
|
||
let path = self.source_workspace.active.clone();
|
||
let hint = approval
|
||
.prepared
|
||
.before()
|
||
.bundle
|
||
.files
|
||
.get(&path)
|
||
.zip(approval.prepared.after().bundle.files.get(&path))
|
||
.map(|(before, after)| EditorRefresh {
|
||
path: &path,
|
||
before,
|
||
after,
|
||
});
|
||
self.source_rename.form = None;
|
||
self.sync_with_alias_editor(hint);
|
||
self.source_editor.move_to(approval.cursor);
|
||
self.queue_source_reveal(approval.cursor, false);
|
||
self.source_rename.undo = Some(Undo {
|
||
id: Id::new_v4(),
|
||
context: self.rename_context(),
|
||
});
|
||
self.status = "Alias renamed. Engineering name unchanged. Undo rename / Undo model restores the whole change; Save writes accepted changes.".into();
|
||
}
|
||
Ok(false) => {
|
||
self.source_rename.form.as_mut().unwrap().notice =
|
||
"No change. Nothing was applied.".into()
|
||
}
|
||
Err(error) => self.source_rename.form.as_mut().unwrap().notice = error.to_string(),
|
||
}
|
||
}
|
||
}
|
||
|
||
fn replacement_notice(target: Option<&Target>, value: &str) -> Option<String> {
|
||
let target = target?;
|
||
if value == target.alias {
|
||
Some("No change. Enter a different source alias.".into())
|
||
} else if !valid_alias(value) {
|
||
Some("Use a letter or underscore, then ASCII letters, digits or underscores; reserved aliases are unavailable. Input is not trimmed.".into())
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
fn resolve_target(
|
||
snapshot: &query::Snapshot,
|
||
context: &Context,
|
||
offset: usize,
|
||
) -> Result<Target, String> {
|
||
let result = snapshot
|
||
.query(query::Query {
|
||
stamp: query::QueryStamp {
|
||
snapshot: context.begin.source,
|
||
serial: 0,
|
||
},
|
||
path: &context.begin.path,
|
||
byte_offset: offset,
|
||
action: query::Action::Definition,
|
||
})
|
||
.map_err(|error| format!("Cannot resolve this alias: {error:?}"))?;
|
||
let scope = &result.scope;
|
||
if !scope.closure_complete
|
||
|| !scope.applied
|
||
|| !scope.validated
|
||
|| scope.detached_files != 0
|
||
|| scope.unverified_files != 0
|
||
|| result.total_definitions != 1
|
||
|| result.definitions.len() != 1
|
||
|| result.omitted_definitions != 0
|
||
{
|
||
return Err("Rename needs one complete, validated definition in the included source. Resolve ambiguous or incomplete source first.".into());
|
||
}
|
||
let row = &result.definitions[0];
|
||
let query::Identity::Unique(anchor) = row.declaration.identity else {
|
||
return Err("Rename requires one unique authored UUID for this alias.".into());
|
||
};
|
||
let id = anchor.id();
|
||
let location = context
|
||
.accepted
|
||
.index
|
||
.declarations
|
||
.get(&id)
|
||
.ok_or("The authored declaration does not belong to the accepted model.")?;
|
||
let accepted_alias = context
|
||
.accepted
|
||
.decoded
|
||
.project
|
||
.identities()
|
||
.into_iter()
|
||
.find_map(|(key, alias)| (key == id).then_some(alias));
|
||
if row.membership != query::Membership::Included
|
||
|| row.evidence != query::Evidence::Validated
|
||
|| row.declaration.authored_id != Some(id)
|
||
|| accepted_alias != Some(row.declaration.alias.as_str())
|
||
|| location.path != row.path
|
||
|| row.span.start < location.span.start
|
||
|| row.span.end > location.span.end
|
||
{
|
||
return Err("The source alias does not exactly match its accepted declaration.".into());
|
||
}
|
||
let origin = result
|
||
.origin
|
||
.ok_or("Place the cursor on an alias or its @ reference.")?;
|
||
let source = context
|
||
.accepted
|
||
.bundle
|
||
.files
|
||
.get(&context.begin.path)
|
||
.ok_or("Origin file is unavailable.")?;
|
||
if !nav::exact_cursor(source, context.begin.cursor) {
|
||
return Err("The source selection changed.".into());
|
||
}
|
||
if let Some(selection) = context.begin.cursor.selection {
|
||
let a =
|
||
nav::byte_offset(source, context.begin.cursor.position).ok_or("Invalid selection.")?;
|
||
let b = nav::byte_offset(source, selection).ok_or("Invalid selection.")?;
|
||
if a != b {
|
||
let start = a.min(b);
|
||
let end = a.max(b);
|
||
let alias_only = start >= origin.span.start && end <= origin.span.end;
|
||
let reference = origin
|
||
.marker
|
||
.is_some_and(|marker| start == marker.start && end == origin.span.end);
|
||
if !alias_only && !reference {
|
||
return Err("Select only the source alias or its complete @ reference.".into());
|
||
}
|
||
}
|
||
}
|
||
let project = &context.accepted.decoded.project;
|
||
let placement = project
|
||
.diagrams
|
||
.values()
|
||
.find_map(|diagram| diagram.nodes.values().find(|node| node.id == id));
|
||
let caption = if let Some(node) = placement {
|
||
format!("Placement of {}", project.named(node.element))
|
||
} else {
|
||
format!(
|
||
"{} · {}",
|
||
row.declaration.kind,
|
||
row.declaration.name.as_deref().unwrap_or("Unnamed")
|
||
)
|
||
};
|
||
Ok(Target {
|
||
id,
|
||
alias: row.declaration.alias.clone(),
|
||
caption,
|
||
origin,
|
||
})
|
||
}
|
||
|
||
fn prepare_approval(
|
||
prepared: PreparedAliasRename,
|
||
target: &Target,
|
||
path: &str,
|
||
) -> Result<Approval, String> {
|
||
let preview = preview::Preview::build(&prepared.before().bundle, &prepared.after().bundle)
|
||
.map_err(|error| error.to_string())?;
|
||
let old = &prepared.before().bundle.files[path];
|
||
let new = &prepared.after().bundle.files[path];
|
||
let span = origin::map(
|
||
old,
|
||
new,
|
||
prepared.old_alias(),
|
||
prepared.new_alias(),
|
||
target.origin.span,
|
||
target.origin.marker,
|
||
)
|
||
.map_err(|error| error.to_string())?;
|
||
let position = |offset| {
|
||
let prefix = &new[..offset];
|
||
text_editor::Position {
|
||
line: prefix.bytes().filter(|b| *b == b'\n').count(),
|
||
column: prefix
|
||
.rsplit_once('\n')
|
||
.map_or(prefix.len(), |(_, tail)| tail.len()),
|
||
}
|
||
};
|
||
let cursor = text_editor::Cursor {
|
||
position: position(span.start),
|
||
selection: Some(position(span.end)),
|
||
};
|
||
if !nav::exact_cursor(new, cursor) {
|
||
return Err("Cannot preserve the exact renamed occurrence.".into());
|
||
}
|
||
Ok(Approval {
|
||
id: Id::new_v4(),
|
||
prepared,
|
||
preview,
|
||
cursor,
|
||
})
|
||
}
|
||
|
||
fn step(index: usize, count: usize, next: bool) -> usize {
|
||
if next {
|
||
index.saturating_add(1).min(count.saturating_sub(1))
|
||
} else {
|
||
index.saturating_sub(1)
|
||
}
|
||
}
|
||
|
||
fn segment_total(form: &Form) -> usize {
|
||
let Some(ready) = &form.approval else {
|
||
return 0;
|
||
};
|
||
let file = &ready.preview.files()[form.file];
|
||
file.hunks()[form.hunk]
|
||
.lines()
|
||
.iter()
|
||
.skip(form.line)
|
||
.take(LINES_PER_PAGE)
|
||
.map(|line| {
|
||
preview::segment_count(
|
||
line.before_text(&ready.prepared.before().bundle.files[file.path()])
|
||
.unwrap_or(""),
|
||
)
|
||
.max(preview::segment_count(
|
||
line.after_text(&ready.prepared.after().bundle.files[file.path()])
|
||
.unwrap_or(""),
|
||
))
|
||
})
|
||
.max()
|
||
.unwrap_or(0)
|
||
}
|
||
|
||
fn details(form: &Form) -> String {
|
||
let target = form
|
||
.target
|
||
.as_ref()
|
||
.map_or("Resolving alias", |target| target.caption.as_str());
|
||
let path = form
|
||
.approval
|
||
.as_ref()
|
||
.map_or(form.context.begin.path.as_str(), |ready| {
|
||
ready.preview.files()[form.file].path()
|
||
});
|
||
format!(
|
||
"{target}\nOrigin: {}\nDisplayed file: {path}\nSource alias: {}\nReplacement: {}\n{}",
|
||
form.context.begin.path,
|
||
form.target
|
||
.as_ref()
|
||
.map_or("", |target| target.alias.as_str()),
|
||
form.replacement,
|
||
form.notice
|
||
)
|
||
}
|
||
|
||
fn refresh_details(form: &mut Form) {
|
||
let full = details(form);
|
||
form.detail_segment = form
|
||
.detail_segment
|
||
.min(preview::segment_count(&full).saturating_sub(1));
|
||
let segment = preview::text_segment(&full, form.detail_segment).unwrap_or("");
|
||
if form.detail_editor.text() != segment {
|
||
form.detail_editor = text_editor::Content::with_text(segment);
|
||
}
|
||
}
|
||
|
||
fn input_id(invocation: Id) -> iced::widget::Id {
|
||
iced::widget::Id::from(format!("source-rename-{invocation}"))
|
||
}
|
||
|
||
// Each navigation gets a fresh destination even when returning to the same page
|
||
// or changing a filter that leaves the page tuple unchanged (the ABA case).
|
||
fn reset_preview_position(form: &mut Form) -> Task<Message> {
|
||
form.preview_destination = Id::new_v4();
|
||
form.preview_offset = 0.;
|
||
if !form.details
|
||
&& let Some(ready) = &form.approval
|
||
{
|
||
iced::widget::operation::snap_to(
|
||
preview_scroll_id(form, ready.id),
|
||
iced::widget::scrollable::RelativeOffset::START,
|
||
)
|
||
} else {
|
||
Task::none()
|
||
}
|
||
}
|
||
|
||
// The operation targets this visit, not a later page, visit or approval.
|
||
// A new widget id alone does not reset Iced's retained Scrollable state.
|
||
fn preview_scroll_id(form: &Form, approval: Id) -> iced::widget::Id {
|
||
iced::widget::Id::from(format!(
|
||
"source-rename-diff-{}-{approval}-{}-{}-{}-{}-{}",
|
||
form.invocation, form.preview_destination, form.file, form.hunk, form.line, form.segment
|
||
))
|
||
}
|
||
|
||
/// Complete indexed preview with bounded rendering: eight admitted source rows
|
||
/// and one UTF-8 segment per side at a time. No candidate content is discarded.
|
||
pub(crate) fn dialog(app: &App) -> Element<'_, Message> {
|
||
dialog_content(
|
||
app,
|
||
&app.source_rename
|
||
.form
|
||
.as_ref()
|
||
.expect("open rename form")
|
||
.detail_editor,
|
||
)
|
||
}
|
||
|
||
fn dialog_content<'a, R>(
|
||
app: &'a App,
|
||
detail_editor: &'a text_editor::Content<R>,
|
||
) -> Element<'a, Message, iced::Theme, R>
|
||
where
|
||
R: iced::advanced::text::Renderer<Font = iced::Font> + 'static,
|
||
{
|
||
use crate::views::short;
|
||
use iced::{
|
||
Fill, Font,
|
||
widget::{button, column, container, row, scrollable, text, text_input},
|
||
};
|
||
let form = app.source_rename.form.as_ref().expect("open rename form");
|
||
let invocation = form.invocation;
|
||
let message = |event| Message::SourceRename(event);
|
||
let can_preview = !form.stale
|
||
&& app.source_rename.in_flight.is_none()
|
||
&& form.target.is_some()
|
||
&& replacement_notice(form.target.as_deref(), &form.replacement).is_none();
|
||
let caption = form
|
||
.target
|
||
.as_ref()
|
||
.map_or("Checking source target…".into(), |target| {
|
||
short(&target.caption, 76)
|
||
});
|
||
let mut alias = text_input("Source alias", &form.replacement)
|
||
.id(input_id(invocation))
|
||
.padding(8)
|
||
.size(14);
|
||
if form.target.is_some() {
|
||
alias = alias
|
||
.on_input(move |value| message(Event::Input(invocation, value)))
|
||
.on_submit(message(Event::Preview(invocation, form.generation)));
|
||
}
|
||
let alias = alias_input::guard(
|
||
alias.into(),
|
||
form.target
|
||
.is_some()
|
||
.then(|| message(Event::PasteRejected(invocation))),
|
||
);
|
||
let content: Element<'_, Message, iced::Theme, R> = if form.details {
|
||
let count = preview::segment_count(&details(form));
|
||
column![
|
||
label("Selectable target, full paths and feedback · all segments available"),
|
||
pager(
|
||
format!("Details {}/{}", form.detail_segment + 1, count.max(1)),
|
||
(form.detail_segment > 0).then(|| message(Event::DetailSegment(invocation, false))),
|
||
(form.detail_segment + 1 < count)
|
||
.then(|| message(Event::DetailSegment(invocation, true)))
|
||
),
|
||
iced::widget::text_editor(detail_editor)
|
||
.font(Font::MONOSPACE)
|
||
.size(12)
|
||
.height(Fill)
|
||
.on_action(move |event| message(Event::ReadOnly(invocation, event))),
|
||
]
|
||
.spacing(8)
|
||
.height(Fill)
|
||
.into()
|
||
} else if let Some(ready) = &form.approval {
|
||
let files = ready.preview.files();
|
||
let file = &files[form.file];
|
||
let hunks = file.hunks();
|
||
let lines = hunks[form.hunk].lines();
|
||
let segments = segment_total(form);
|
||
let matched: Vec<_> = files
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(i, file)| file.path().contains(&form.search).then_some(i))
|
||
.collect();
|
||
let selected = matched.iter().position(|i| *i == form.file);
|
||
let preview_id = ready.id;
|
||
let mut displayed = column![].spacing(8).width(Fill);
|
||
for line in lines.iter().skip(form.line).take(LINES_PER_PAGE) {
|
||
let before = line
|
||
.before_text(&ready.prepared.before().bundle.files[file.path()])
|
||
.unwrap_or("");
|
||
let after = line
|
||
.after_text(&ready.prepared.after().bundle.files[file.path()])
|
||
.unwrap_or("");
|
||
displayed = displayed
|
||
.push(
|
||
text(format!(
|
||
"Line {} · {}",
|
||
line.number(),
|
||
if line.changed() { "changed" } else { "context" }
|
||
))
|
||
.size(12),
|
||
)
|
||
.push(text("− Before").size(11))
|
||
.push(
|
||
text(
|
||
preview::text_segment(before, form.segment)
|
||
.unwrap_or("[No further segment on this side]"),
|
||
)
|
||
.font(Font::MONOSPACE)
|
||
.size(12)
|
||
.width(Fill)
|
||
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
|
||
)
|
||
.push(text("+ After").size(11))
|
||
.push(
|
||
text(
|
||
preview::text_segment(after, form.segment)
|
||
.unwrap_or("[No further segment on this side]"),
|
||
)
|
||
.font(Font::MONOSPACE)
|
||
.size(12)
|
||
.width(Fill)
|
||
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
|
||
);
|
||
}
|
||
column![
|
||
label(format!("{} changed files · {} changed lines · {} preview bytes. This operation only; earlier unsaved changes are separate.", files.len(), ready.preview.changed_line_count(), ready.preview.display_bytes())),
|
||
text_input("Find changed file by path", &form.search).padding(6).size(12)
|
||
.on_input(move |value| message(Event::Search(invocation, value))),
|
||
label(if matched.is_empty() { "No paths match. The previous file remains visible; clear search to browse all changed files." } else { "All matching files are reachable with Previous / Next." }),
|
||
pager(format!("File {}/{} · {}", form.file + 1, files.len(), short(file.path(), 45)),
|
||
selected.filter(|i| *i > 0).map(|_| message(Event::File(invocation, preview_id, false))),
|
||
selected.filter(|i| i + 1 < matched.len()).map(|_| message(Event::File(invocation, preview_id, true)))),
|
||
row![
|
||
pager(format!("Hunk {}/{}", form.hunk + 1, hunks.len()),
|
||
(form.hunk > 0).then(|| message(Event::Hunk(invocation, preview_id, false))),
|
||
(form.hunk + 1 < hunks.len()).then(|| message(Event::Hunk(invocation, preview_id, true)))),
|
||
pager(format!("Rows {}–{}/{}", form.line + 1, (form.line + LINES_PER_PAGE).min(lines.len()), lines.len()),
|
||
(form.line > 0).then(|| message(Event::Line(invocation, preview_id, false))),
|
||
(form.line + LINES_PER_PAGE < lines.len()).then(|| message(Event::Line(invocation, preview_id, true)))),
|
||
].spacing(8),
|
||
pager(format!("Row segments {}/{} · both sides", form.segment + 1, segments.max(1)),
|
||
(form.segment > 0).then(|| message(Event::Segment(invocation, preview_id, false))),
|
||
(form.segment + 1 < segments).then(|| message(Event::Segment(invocation, preview_id, true)))),
|
||
scrollable(displayed)
|
||
.id(preview_scroll_id(form, preview_id))
|
||
.on_scroll({
|
||
let destination = form.preview_destination;
|
||
move |viewport| message(Event::PreviewScrolled(
|
||
invocation, preview_id, destination, viewport.absolute_offset().y,
|
||
))
|
||
})
|
||
.height(Fill).width(Fill).spacing(8),
|
||
].spacing(6).height(Fill).into()
|
||
} else {
|
||
scrollable(column![
|
||
text("Preview changes the source alias and its typed uses across all included files.").size(13),
|
||
text("Engineering names, identities and other model fields stay unchanged. Physical files outside the loaded project are untouched.").size(13),
|
||
text("After Rename alias, use Undo rename or Undo model for the whole transaction. Ctrl+Z in Text remains Undo text. Save writes all accepted unsaved changes.").size(13),
|
||
].spacing(12)).height(Fill).into()
|
||
};
|
||
let confirm = form
|
||
.approval
|
||
.as_ref()
|
||
.filter(|_| !form.stale)
|
||
.map(|ready| message(Event::Confirm(invocation, form.generation, ready.id)));
|
||
container(
|
||
column![
|
||
row![
|
||
text("Rename alias").size(20),
|
||
container(text(caption).size(12)).width(Fill),
|
||
action(
|
||
if form.details {
|
||
"Preview pane"
|
||
} else {
|
||
"Full details"
|
||
},
|
||
message(Event::Details(invocation, !form.details))
|
||
)
|
||
]
|
||
.spacing(10),
|
||
label("Source alias · Engineering name stays unchanged"),
|
||
container(alias).id("source-rename-input"),
|
||
text(short(&form.notice, 190)).size(12),
|
||
container(content).id("source-rename-preview").height(Fill),
|
||
container(
|
||
row![
|
||
action("Cancel", message(Event::Close(invocation))),
|
||
button(text("Preview").size(13)).padding(10).on_press_maybe(
|
||
can_preview.then(|| message(Event::Preview(invocation, form.generation)))
|
||
),
|
||
button(text("Rename alias").size(13))
|
||
.padding(10)
|
||
.on_press_maybe(confirm),
|
||
]
|
||
.spacing(10)
|
||
)
|
||
.id("source-rename-footer"),
|
||
]
|
||
.spacing(8)
|
||
.height((app.window_size.height - 120.).clamp(200., 680.)),
|
||
)
|
||
.width(Fill)
|
||
.into()
|
||
}
|
||
|
||
fn pager<R>(
|
||
label: String,
|
||
previous: Option<Message>,
|
||
next: Option<Message>,
|
||
) -> Element<'static, Message, iced::Theme, R>
|
||
where
|
||
R: iced::advanced::text::Renderer<Font = iced::Font> + 'static,
|
||
{
|
||
use iced::{
|
||
Fill,
|
||
widget::{button, container, row, text},
|
||
};
|
||
row![
|
||
button(text("Previous").size(11))
|
||
.padding(5)
|
||
.on_press_maybe(previous),
|
||
container(text(label).size(11)).width(Fill),
|
||
button(text("Next").size(11))
|
||
.padding(5)
|
||
.on_press_maybe(next),
|
||
]
|
||
.spacing(6)
|
||
.align_y(iced::Center)
|
||
.width(Fill)
|
||
.into()
|
||
}
|
||
|
||
fn action<'a, R>(label: &str, message: Message) -> iced::widget::Button<'a, Message, iced::Theme, R>
|
||
where
|
||
R: iced::advanced::text::Renderer<Font = iced::Font> + 'a,
|
||
{
|
||
iced::widget::button(iced::widget::text(label.to_owned()).size(12))
|
||
.padding([8, 10])
|
||
.on_press(message)
|
||
.style(crate::style::nav)
|
||
}
|
||
|
||
fn label<'a, R>(value: impl Into<String>) -> iced::widget::Text<'a, iced::Theme, R>
|
||
where
|
||
R: iced::advanced::text::Renderer<Font = iced::Font> + 'a,
|
||
{
|
||
iced::widget::text(value.into())
|
||
.size(11)
|
||
.color(crate::style::MUTED)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|
||
|
||
#[cfg(all(test, windows, debug_assertions))]
|
||
#[path = "source_rename_timing_tests.rs"]
|
||
mod timing_tests;
|