661 lines
24 KiB
Rust
661 lines
24 KiB
Rust
//! Per-file drafts and navigation. Accepted model/source history lives in Document.
|
|
use iced::widget::text_editor::Cursor;
|
|
use oa_core::{
|
|
Id, language,
|
|
source_project::{self, CompiledSourceProject, ProjectLimits, SourceBundle, SourceDiagnostic},
|
|
};
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
|
|
pub const LIMITS: ProjectLimits = ProjectLimits {
|
|
max_files: 256,
|
|
max_include_depth: 32,
|
|
max_include_edges: 1024,
|
|
model: language::ParseLimits {
|
|
max_source_bytes: 64 * 1024 * 1024,
|
|
max_graph_objects: 100_000,
|
|
max_records: 250_000,
|
|
max_step_references: 500_000,
|
|
},
|
|
};
|
|
|
|
const DRAFT_HISTORY_BYTES: usize = 32 * 1024 * 1024;
|
|
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct RecoveryWorkspace {
|
|
entry: String,
|
|
active: String,
|
|
files: BTreeMap<String, (String, String)>,
|
|
reachable: BTreeSet<String>,
|
|
applied: bool,
|
|
new_path: String,
|
|
}
|
|
|
|
impl RecoveryWorkspace {
|
|
/// Refresh only accepted clean buffers. Dirty baselines and invalid/unapplied
|
|
/// workspaces are authored recovery data, not migration input.
|
|
pub(crate) fn upgrade_clean_buffers(&mut self, old: &SourceBundle, new: &SourceBundle) {
|
|
if !self.applied || self.entry != old.entry {
|
|
return;
|
|
}
|
|
for (path, (text, baseline)) in &mut self.files {
|
|
if text == baseline
|
|
&& old.files.get(path) == Some(text)
|
|
&& let Some(upgraded) = new.files.get(path)
|
|
{
|
|
*text = upgraded.clone();
|
|
*baseline = upgraded.clone();
|
|
}
|
|
}
|
|
}
|
|
/// Check the same bounds before replacing a checkpoint and before restoring it.
|
|
pub(crate) fn validate(&self) -> Result<(), String> {
|
|
if self.files.len() > LIMITS.max_files
|
|
|| !self.files.contains_key(&self.entry)
|
|
|| !self.files.contains_key(&self.active)
|
|
|| !self.reachable.contains(&self.entry)
|
|
|| self.reachable.len() > LIMITS.max_include_edges + 1
|
|
{
|
|
return Err("Recovery has an invalid file count, entry or active file.".into());
|
|
}
|
|
if self.files.values().fold(0usize, |bytes, (text, base)| {
|
|
bytes.saturating_add(text.len()).saturating_add(base.len())
|
|
}) > LIMITS.model.max_source_bytes.saturating_mul(2)
|
|
{
|
|
return Err("Recovery source buffers exceed the size limit.".into());
|
|
}
|
|
for path in self.files.keys().chain(self.reachable.iter()) {
|
|
// A recovery buffer is never a filesystem path to open implicitly.
|
|
if path.starts_with('/')
|
|
|| path.contains('\\')
|
|
|| path.contains(':')
|
|
|| path
|
|
.split('/')
|
|
.any(|part| part == ".." || part == "." || part.is_empty())
|
|
{
|
|
return Err("Recovery contains an unsafe source path.".into());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub struct Buffer {
|
|
pub text: String,
|
|
baseline: String,
|
|
pub cursor: Cursor,
|
|
past: Vec<String>,
|
|
future: Vec<String>,
|
|
generation: u64,
|
|
}
|
|
|
|
impl Buffer {
|
|
fn new(text: String) -> Self {
|
|
Self {
|
|
baseline: text.clone(),
|
|
text,
|
|
cursor: Cursor {
|
|
position: iced::widget::text_editor::Position { line: 0, column: 0 },
|
|
selection: None,
|
|
},
|
|
past: Vec::new(),
|
|
future: Vec::new(),
|
|
generation: 0,
|
|
}
|
|
}
|
|
pub fn dirty(&self) -> bool {
|
|
self.text != self.baseline
|
|
}
|
|
pub fn generation(&self) -> u64 {
|
|
self.generation
|
|
}
|
|
}
|
|
|
|
pub struct Workspace {
|
|
session: Id,
|
|
revision: u64,
|
|
pub entry: String,
|
|
pub active: String,
|
|
pub files: BTreeMap<String, Buffer>,
|
|
pub reachable: BTreeSet<String>,
|
|
pub new_path: String,
|
|
/// An invalid Open retains the accepted model but has no applied source yet.
|
|
pub applied: bool,
|
|
}
|
|
|
|
impl Workspace {
|
|
/// Ephemeral editor identity, never a model identity or recovery payload.
|
|
pub fn session(&self) -> Id {
|
|
self.session
|
|
}
|
|
pub fn revision(&self) -> u64 {
|
|
self.revision
|
|
}
|
|
fn advance(&mut self) -> u64 {
|
|
if let Some(revision) = self.revision.checked_add(1) {
|
|
self.revision = revision;
|
|
} else {
|
|
self.reset_navigation_session();
|
|
}
|
|
self.revision
|
|
}
|
|
/// A changed filesystem root must not reuse queued navigation from Save As.
|
|
pub fn reset_navigation_session(&mut self) {
|
|
self.session = Id::new_v4();
|
|
self.revision = 0;
|
|
for buffer in self.files.values_mut() {
|
|
buffer.generation = 0;
|
|
}
|
|
}
|
|
pub fn set_reachable(&mut self, reachable: BTreeSet<String>) {
|
|
if self.reachable != reachable {
|
|
self.advance();
|
|
self.reachable = reachable;
|
|
}
|
|
}
|
|
pub fn recovery_snapshot(&self) -> RecoveryWorkspace {
|
|
RecoveryWorkspace {
|
|
entry: self.entry.clone(),
|
|
active: self.active.clone(),
|
|
files: self
|
|
.files
|
|
.iter()
|
|
.map(|(path, buffer)| {
|
|
(path.clone(), (buffer.text.clone(), buffer.baseline.clone()))
|
|
})
|
|
.collect(),
|
|
reachable: self.reachable.clone(),
|
|
applied: self.applied,
|
|
new_path: self.new_path.clone(),
|
|
}
|
|
}
|
|
pub fn from_recovery(saved: RecoveryWorkspace) -> Result<Self, String> {
|
|
saved.validate()?;
|
|
Ok(Self {
|
|
session: Id::new_v4(),
|
|
revision: 0,
|
|
entry: saved.entry,
|
|
active: saved.active,
|
|
files: saved
|
|
.files
|
|
.into_iter()
|
|
.map(|(path, (text, baseline))| {
|
|
let mut buffer = Buffer::new(text);
|
|
buffer.baseline = baseline;
|
|
(path, buffer)
|
|
})
|
|
.collect(),
|
|
reachable: saved.reachable,
|
|
applied: saved.applied,
|
|
new_path: saved.new_path,
|
|
})
|
|
}
|
|
pub fn new(bundle: &SourceBundle, applied: bool) -> Self {
|
|
Self {
|
|
session: Id::new_v4(),
|
|
revision: 0,
|
|
entry: bundle.entry.clone(),
|
|
active: bundle.entry.clone(),
|
|
files: bundle
|
|
.files
|
|
.iter()
|
|
.map(|(p, t)| (p.clone(), Buffer::new(t.clone())))
|
|
.collect(),
|
|
reachable: bundle.files.keys().cloned().collect(),
|
|
new_path: String::new(),
|
|
applied,
|
|
}
|
|
}
|
|
pub fn text(&self) -> &str {
|
|
self.files.get(&self.active).map_or("", |b| b.text.as_str())
|
|
}
|
|
pub fn dirty(&self) -> bool {
|
|
!self.applied || self.has_edits()
|
|
}
|
|
pub fn has_edits(&self) -> bool {
|
|
self.files.values().any(Buffer::dirty)
|
|
}
|
|
pub fn detached_dirty(&self) -> bool {
|
|
self.files
|
|
.iter()
|
|
.any(|(p, b)| !self.reachable.contains(p) && b.dirty())
|
|
}
|
|
pub fn bundle(&self) -> SourceBundle {
|
|
SourceBundle {
|
|
entry: self.entry.clone(),
|
|
files: self
|
|
.files
|
|
.iter()
|
|
.map(|(p, b)| (p.clone(), b.text.clone()))
|
|
.collect(),
|
|
}
|
|
}
|
|
pub fn edit(&mut self, text: String, cursor: Cursor) {
|
|
let changed = self.files.get(&self.active).is_none_or(|b| b.text != text);
|
|
let generation = if changed {
|
|
self.advance()
|
|
} else {
|
|
self.revision
|
|
};
|
|
let buffer = self
|
|
.files
|
|
.entry(self.active.clone())
|
|
.or_insert_with(|| Buffer::new(String::new()));
|
|
buffer.cursor = cursor;
|
|
if changed {
|
|
buffer.generation = generation;
|
|
}
|
|
if text != buffer.text {
|
|
buffer.past.push(std::mem::replace(&mut buffer.text, text));
|
|
if buffer.past.len() > 100 {
|
|
buffer.past.remove(0);
|
|
}
|
|
buffer.future.clear();
|
|
self.trim_history(DRAFT_HISTORY_BYTES);
|
|
}
|
|
}
|
|
// Bound all file histories together. Evict the oldest/furthest snapshot
|
|
// from the largest history first; current text and baselines are retained.
|
|
fn trim_history(&mut self, budget: usize) {
|
|
loop {
|
|
let histories: Vec<_> = self
|
|
.files
|
|
.iter()
|
|
.flat_map(|(path, buffer)| {
|
|
[(&buffer.past, false), (&buffer.future, true)]
|
|
.into_iter()
|
|
.filter(|(history, _)| !history.is_empty())
|
|
.map(move |(history, future)| {
|
|
(
|
|
path.clone(),
|
|
future,
|
|
history.iter().map(String::len).sum::<usize>(),
|
|
)
|
|
})
|
|
})
|
|
.collect();
|
|
if histories.iter().map(|(_, _, bytes)| bytes).sum::<usize>() <= budget {
|
|
break;
|
|
}
|
|
let Some((path, future, _)) = histories.into_iter().max_by_key(|(_, _, bytes)| *bytes)
|
|
else {
|
|
break;
|
|
};
|
|
let buffer = self.files.get_mut(&path).unwrap();
|
|
if future {
|
|
buffer.future.remove(0);
|
|
} else {
|
|
buffer.past.remove(0);
|
|
}
|
|
}
|
|
}
|
|
pub fn switch(&mut self, path: &str, cursor: Cursor) -> bool {
|
|
if path == self.active || !self.files.contains_key(path) {
|
|
return false;
|
|
}
|
|
if let Some(buffer) = self.files.get_mut(&self.active) {
|
|
buffer.cursor = cursor;
|
|
}
|
|
self.active = path.into();
|
|
true
|
|
}
|
|
pub fn undo(&mut self, redo: bool) -> bool {
|
|
let Some(buffer) = self.files.get_mut(&self.active) else {
|
|
return false;
|
|
};
|
|
let value = if redo {
|
|
buffer.future.pop()
|
|
} else {
|
|
buffer.past.pop()
|
|
};
|
|
let Some(value) = value else {
|
|
return false;
|
|
};
|
|
let previous = std::mem::replace(&mut buffer.text, value);
|
|
if redo {
|
|
buffer.past.push(previous);
|
|
} else {
|
|
buffer.future.push(previous);
|
|
}
|
|
let generation = self.advance();
|
|
self.files.get_mut(&self.active).unwrap().generation = generation;
|
|
self.trim_history(DRAFT_HISTORY_BYTES);
|
|
true
|
|
}
|
|
pub fn revert_active(&mut self) {
|
|
if let Some((baseline, cursor)) = self
|
|
.files
|
|
.get(&self.active)
|
|
.map(|b| (b.baseline.clone(), b.cursor))
|
|
{
|
|
self.edit(baseline, cursor);
|
|
}
|
|
}
|
|
pub fn accept(&mut self, bundle: &SourceBundle) {
|
|
let generation = self.advance();
|
|
if self.entry != bundle.entry
|
|
&& !bundle.files.contains_key(&self.entry)
|
|
&& self.files.get(&self.entry).is_some_and(|b| !b.dirty())
|
|
{
|
|
self.files.remove(&self.entry);
|
|
}
|
|
self.entry = bundle.entry.clone();
|
|
self.reachable = bundle.files.keys().cloned().collect();
|
|
for (path, text) in &bundle.files {
|
|
let cursor = self.files.get(path).map(|b| b.cursor);
|
|
let previous_generation = self
|
|
.files
|
|
.get(path)
|
|
.filter(|b| b.text == *text)
|
|
.map(|b| b.generation);
|
|
let mut buffer = Buffer::new(text.clone());
|
|
buffer.generation = previous_generation.unwrap_or(generation);
|
|
if let Some(cursor) = cursor {
|
|
buffer.cursor = cursor;
|
|
}
|
|
self.files.insert(path.clone(), buffer);
|
|
}
|
|
if !self.files.contains_key(&self.active) {
|
|
self.active = self.entry.clone();
|
|
}
|
|
self.applied = true;
|
|
}
|
|
pub fn discovered(&mut self, bundle: &SourceBundle, reachable: BTreeSet<String>) {
|
|
let changed = self.reachable != reachable
|
|
|| bundle
|
|
.files
|
|
.keys()
|
|
.any(|path| !self.files.contains_key(path));
|
|
let generation = if changed {
|
|
self.advance()
|
|
} else {
|
|
self.revision
|
|
};
|
|
for (path, text) in &bundle.files {
|
|
self.files.entry(path.clone()).or_insert_with(|| {
|
|
let mut buffer = Buffer::new(text.clone());
|
|
buffer.generation = generation;
|
|
buffer
|
|
});
|
|
}
|
|
self.reachable = reachable;
|
|
}
|
|
pub fn discard_detached(&mut self, path: &str) -> bool {
|
|
if self.reachable.contains(path) || self.files.get(path).is_none_or(Buffer::dirty) {
|
|
return false;
|
|
}
|
|
self.advance();
|
|
self.files.remove(path);
|
|
if self.active == path {
|
|
self.active = self.entry.clone();
|
|
}
|
|
true
|
|
}
|
|
}
|
|
|
|
/// Follow only explicitly reachable documents. Disconnected buffers remain in
|
|
/// Workspace, while the compiler deliberately rejects extras in its input.
|
|
pub fn reachable(bundle: &SourceBundle) -> Result<SourceBundle, Vec<SourceDiagnostic>> {
|
|
let mut included = BTreeSet::new();
|
|
let mut todo = vec![(bundle.entry.clone(), 0usize)];
|
|
let mut edges = 0usize;
|
|
while let Some((path, depth)) = todo.pop() {
|
|
if !included.insert(path.clone()) {
|
|
continue;
|
|
}
|
|
if depth > LIMITS.max_include_depth || included.len() > LIMITS.max_files {
|
|
// The compiler supplies its normative admission diagnostic.
|
|
return source_project::compile_bundle(bundle, LIMITS).map(|c| c.bundle);
|
|
}
|
|
let Some(text) = bundle.files.get(&path) else {
|
|
return source_project::compile_bundle(bundle, LIMITS).map(|c| c.bundle);
|
|
};
|
|
let includes = source_project::inspect_includes(&path, text, path == bundle.entry, LIMITS)?;
|
|
edges = edges.saturating_add(includes.len());
|
|
if edges > LIMITS.max_include_edges {
|
|
return source_project::compile_bundle(bundle, LIMITS).map(|c| c.bundle);
|
|
}
|
|
todo.extend(
|
|
includes
|
|
.into_iter()
|
|
.map(|include| (include.target, depth + 1)),
|
|
);
|
|
}
|
|
Ok(SourceBundle {
|
|
entry: bundle.entry.clone(),
|
|
files: bundle
|
|
.files
|
|
.iter()
|
|
.filter(|(p, _)| included.contains(*p))
|
|
.map(|(p, t)| (p.clone(), t.clone()))
|
|
.collect(),
|
|
})
|
|
}
|
|
|
|
pub fn compile(bundle: &SourceBundle) -> Result<CompiledSourceProject, Vec<SourceDiagnostic>> {
|
|
source_project::compile_bundle(&reachable(bundle)?, LIMITS)
|
|
}
|
|
|
|
pub fn lint(bundle: &SourceBundle) -> Vec<SourceDiagnostic> {
|
|
match reachable(bundle) {
|
|
Ok(bundle) => source_project::lint_bundle(&bundle, LIMITS),
|
|
Err(diagnostics) => diagnostics,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use oa_core::Project;
|
|
|
|
#[test]
|
|
fn fresh_session_cannot_reuse_a_retained_buffer_generation() {
|
|
let bundle = SourceBundle {
|
|
entry: "main.arcadia".into(),
|
|
files: BTreeMap::from([
|
|
("main.arcadia".into(), "first".into()),
|
|
("other.arcadia".into(), "other".into()),
|
|
]),
|
|
};
|
|
let mut workspace = Workspace::new(&bundle, true);
|
|
let cursor = workspace.files["main.arcadia"].cursor;
|
|
workspace.edit("second".into(), cursor);
|
|
workspace.reset_navigation_session();
|
|
let before = workspace.files["main.arcadia"].generation();
|
|
workspace.edit("third".into(), cursor);
|
|
assert_ne!(workspace.files["main.arcadia"].generation(), before);
|
|
workspace.switch("other.arcadia", cursor);
|
|
workspace.revision = u64::MAX;
|
|
workspace.edit("changed other".into(), cursor);
|
|
let after_wrap = workspace.files["main.arcadia"].generation();
|
|
workspace.switch("main.arcadia", cursor);
|
|
workspace.edit("fourth".into(), cursor);
|
|
assert_ne!(workspace.files["main.arcadia"].generation(), after_wrap);
|
|
assert!(workspace.undo(false));
|
|
assert_eq!(workspace.text(), "third");
|
|
}
|
|
|
|
#[test]
|
|
fn navigation_versions_distinguish_reopened_recovered_and_changed_drafts() {
|
|
let bundle = SourceBundle {
|
|
entry: "project.arcadia".into(),
|
|
files: BTreeMap::from([
|
|
("project.arcadia".into(), "entry\r\n".into()),
|
|
("part.arcadia".into(), "雨 draft\r\n".into()),
|
|
]),
|
|
};
|
|
let mut workspace = Workspace::new(&bundle, true);
|
|
let reopened = Workspace::new(&bundle, true);
|
|
let recovered = Workspace::from_recovery(workspace.recovery_snapshot()).unwrap();
|
|
assert_ne!(workspace.session(), reopened.session());
|
|
assert_ne!(workspace.session(), recovered.session());
|
|
assert_eq!(workspace.bundle(), recovered.bundle());
|
|
let saved = serde_json::to_value(workspace.recovery_snapshot()).unwrap();
|
|
assert!(saved.get("session").is_none() && saved.get("revision").is_none());
|
|
|
|
let cursor = Cursor {
|
|
position: iced::widget::text_editor::Position { line: 0, column: 3 },
|
|
selection: None,
|
|
};
|
|
let revision = workspace.revision();
|
|
workspace.edit(workspace.text().into(), cursor);
|
|
assert_eq!(
|
|
workspace.revision(),
|
|
revision,
|
|
"selection is not a text change"
|
|
);
|
|
workspace.switch("part.arcadia", cursor);
|
|
assert_eq!(
|
|
workspace.revision(),
|
|
revision,
|
|
"switching retains the snapshot"
|
|
);
|
|
let untouched = workspace.files["project.arcadia"].generation();
|
|
workspace.edit("雨 unfinished !\r\n".into(), cursor);
|
|
let edited = workspace.files["part.arcadia"].generation();
|
|
assert!(workspace.revision() > revision);
|
|
assert_eq!(workspace.files["project.arcadia"].generation(), untouched);
|
|
assert!(workspace.undo(false));
|
|
assert_eq!(workspace.bundle(), bundle);
|
|
assert!(workspace.files["part.arcadia"].generation() > edited);
|
|
assert!(workspace.undo(true));
|
|
assert_eq!(workspace.text(), "雨 unfinished !\r\n");
|
|
let previous_session = workspace.session();
|
|
let previous_text = workspace.bundle();
|
|
workspace.reset_navigation_session();
|
|
assert_ne!(workspace.session(), previous_session);
|
|
assert_eq!(workspace.bundle(), previous_text);
|
|
assert!(
|
|
workspace.undo(false),
|
|
"session reset preserves text history"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn navigation_scope_changes_invalidate_without_changing_unchanged_positions() {
|
|
let bundle = SourceBundle {
|
|
entry: "project.arcadia".into(),
|
|
files: BTreeMap::from([
|
|
("project.arcadia".into(), "entry".into()),
|
|
("part.arcadia".into(), "part".into()),
|
|
]),
|
|
};
|
|
let mut workspace = Workspace::new(&bundle, true);
|
|
let entry_generation = workspace.files[&bundle.entry].generation();
|
|
let initial_revision = workspace.revision();
|
|
workspace.set_reachable(BTreeSet::from([bundle.entry.clone()]));
|
|
assert!(workspace.revision() > initial_revision);
|
|
let membership_revision = workspace.revision();
|
|
workspace.set_reachable(workspace.reachable.clone());
|
|
assert_eq!(workspace.revision(), membership_revision);
|
|
assert!(workspace.discard_detached("part.arcadia"));
|
|
assert!(workspace.revision() > membership_revision);
|
|
let closed_revision = workspace.revision();
|
|
workspace.discovered(&bundle, BTreeSet::from([bundle.entry.clone()]));
|
|
assert!(workspace.revision() > closed_revision);
|
|
assert_eq!(
|
|
workspace.files[&bundle.entry].generation(),
|
|
entry_generation
|
|
);
|
|
assert!(workspace.files["part.arcadia"].generation() > closed_revision);
|
|
let discovered_revision = workspace.revision();
|
|
workspace.discovered(&bundle, workspace.reachable.clone());
|
|
assert_eq!(workspace.revision(), discovered_revision);
|
|
workspace.accept(&bundle);
|
|
assert!(
|
|
workspace.revision() > discovered_revision,
|
|
"validation evidence changed"
|
|
);
|
|
assert_eq!(
|
|
workspace.files[&bundle.entry].generation(),
|
|
entry_generation
|
|
);
|
|
assert_eq!(workspace.bundle(), bundle);
|
|
}
|
|
|
|
#[test]
|
|
fn navigation_revision_wrap_cannot_reuse_an_old_workspace_key() {
|
|
let bundle = SourceBundle {
|
|
entry: "project.arcadia".into(),
|
|
files: BTreeMap::from([("project.arcadia".into(), "original".into())]),
|
|
};
|
|
let mut workspace = Workspace::new(&bundle, true);
|
|
workspace.revision = u64::MAX;
|
|
let before = workspace.session();
|
|
let cursor = workspace.files[&bundle.entry].cursor;
|
|
workspace.edit("changed".into(), cursor);
|
|
assert_ne!(workspace.session(), before);
|
|
assert_eq!(workspace.revision(), 0);
|
|
assert!(workspace.undo(false));
|
|
assert_eq!(workspace.bundle(), bundle);
|
|
}
|
|
#[test]
|
|
fn draft_history_budget_covers_inactive_files_without_discarding_drafts() {
|
|
let bundle = SourceBundle {
|
|
entry: "project.arcadia".into(),
|
|
files: BTreeMap::from([
|
|
("project.arcadia".into(), "entry baseline".into()),
|
|
("part.arcadia".into(), "part baseline".into()),
|
|
]),
|
|
};
|
|
let mut workspace = Workspace::new(&bundle, true);
|
|
for buffer in workspace.files.values_mut() {
|
|
buffer.past = vec!["old".repeat(20), "recent".repeat(10)];
|
|
buffer.future = vec!["future".repeat(10)];
|
|
buffer.text = "unfinished draft".into();
|
|
}
|
|
workspace.trim_history(130);
|
|
assert!(
|
|
workspace
|
|
.files
|
|
.values()
|
|
.map(|b| b
|
|
.past
|
|
.iter()
|
|
.chain(&b.future)
|
|
.map(String::len)
|
|
.sum::<usize>())
|
|
.sum::<usize>()
|
|
<= 130
|
|
);
|
|
for (path, buffer) in &workspace.files {
|
|
assert_eq!(buffer.text, "unfinished draft");
|
|
assert_eq!(buffer.baseline, bundle.files[path]);
|
|
assert!(buffer.dirty());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn switching_retains_invalid_drafts_and_independent_history() {
|
|
let mut bundle = source_project::format_bundle(&Project::demo());
|
|
let fragment = bundle
|
|
.files
|
|
.keys()
|
|
.find(|p| **p != bundle.entry)
|
|
.unwrap()
|
|
.clone();
|
|
let original = bundle.files[&fragment].clone();
|
|
let mut workspace = Workspace::new(&bundle, true);
|
|
let cursor = Cursor {
|
|
position: iced::widget::text_editor::Position { line: 0, column: 0 },
|
|
selection: None,
|
|
};
|
|
workspace.switch(&fragment, cursor);
|
|
workspace.edit("? invalid".into(), cursor);
|
|
workspace.switch(&bundle.entry, cursor);
|
|
assert!(workspace.dirty());
|
|
assert!(compile(&workspace.bundle()).is_err());
|
|
workspace.switch(&fragment, cursor);
|
|
assert_eq!(workspace.text(), "? invalid");
|
|
assert!(workspace.undo(false));
|
|
assert_eq!(workspace.text(), original);
|
|
assert!(!workspace.dirty());
|
|
bundle
|
|
.files
|
|
.insert("detached.arcadia".into(), "? parked".into());
|
|
workspace.discovered(&bundle, workspace.reachable.clone());
|
|
assert!(compile(&workspace.bundle()).is_ok());
|
|
assert!(workspace.discard_detached("detached.arcadia"));
|
|
}
|
|
}
|