Files
ARCADIA-Studio/crates/oa-desktop/src/app/source_rename.rs
T

1216 lines
44 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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),
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,
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.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);
}
let Some(form) = &self.source_rename.form else {
return false;
};
match event {
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,
}
}
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.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();
form.details = value;
form.detail_segment = 0;
refresh_details(form);
}
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(),
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();
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)).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;