feat: add local project library and safe document switching
This commit is contained in:
@@ -11,6 +11,8 @@ use crate::process_worker::SubmitError;
|
||||
use vernier_render::{OrbitCamera, PartBuffers, SceneDraw, SketchBuffers};
|
||||
#[path = "autosave.rs"]
|
||||
pub(crate) mod autosave;
|
||||
#[path = "project_library.rs"]
|
||||
mod project_library;
|
||||
#[path = "app_recovery.rs"]
|
||||
mod recovery;
|
||||
use vernier_ui::shell::{self, SceneView, ShellState};
|
||||
@@ -48,6 +50,7 @@ pub(crate) enum CloseChoice {
|
||||
}
|
||||
|
||||
pub(crate) struct VernierApp {
|
||||
projects: project_library::Projects,
|
||||
close_state: CloseState,
|
||||
retiring_gesture: bool,
|
||||
/// Responses still to drain before a file operation or extrusion is
|
||||
@@ -331,6 +334,7 @@ impl VernierApp {
|
||||
shell.theme.install(&egui_ctx);
|
||||
|
||||
Self {
|
||||
projects: project_library::Projects::default(),
|
||||
close_state: CloseState::Idle,
|
||||
retiring_gesture: false,
|
||||
document_barrier: 0,
|
||||
@@ -771,10 +775,15 @@ impl VernierApp {
|
||||
}
|
||||
|
||||
pub(crate) fn try_submit(&mut self, edit: Edit) -> bool {
|
||||
if self.projects.switching {
|
||||
self.shell.readout = "Wait for the project switch to finish".into();
|
||||
return false;
|
||||
}
|
||||
let needs_acknowledgement = matches!(
|
||||
edit.operation(),
|
||||
Edit::SaveDocument { .. }
|
||||
| Edit::OpenDocument { .. }
|
||||
| Edit::NewDocument { .. }
|
||||
| Edit::ExtrudeSketch { .. }
|
||||
| Edit::ImportStep { .. }
|
||||
);
|
||||
@@ -944,6 +953,7 @@ impl VernierApp {
|
||||
CloseState::Confirm
|
||||
};
|
||||
}
|
||||
self.complete_project_save();
|
||||
// A close requested during Save may be waiting to retire a gesture.
|
||||
// Ordinary Save itself preserves every gesture and geometry token.
|
||||
self.resume_gizmo_end();
|
||||
@@ -1013,6 +1023,7 @@ impl VernierApp {
|
||||
theme: self.shell.theme,
|
||||
file_path: self.shell.file_path.clone(),
|
||||
recovery_files: self.shell.recovery_files.clone(),
|
||||
projects: std::mem::take(&mut self.shell.projects),
|
||||
..ShellState::default()
|
||||
};
|
||||
self.preview = None;
|
||||
@@ -1261,6 +1272,7 @@ impl VernierApp {
|
||||
raw_input: egui::RawInput,
|
||||
viewport: [f32; 2],
|
||||
) -> egui::FullOutput {
|
||||
self.poll_projects();
|
||||
self.update_display_preferences();
|
||||
self.poll_file_dialog();
|
||||
self.poll_autosave(raw_input.events.iter().any(|event| {
|
||||
@@ -1408,7 +1420,9 @@ impl VernierApp {
|
||||
.map(|picked| (picked.sketch, picked.at));
|
||||
let theme = self.shell.theme;
|
||||
let mut dimension_click = None;
|
||||
let closing = self.close_state != CloseState::Idle || self.retiring_gesture;
|
||||
let closing = self.close_state != CloseState::Idle
|
||||
|| self.retiring_gesture
|
||||
|| self.projects.switching;
|
||||
let mut close_choice = None;
|
||||
let mut stop_worker = false;
|
||||
let mut restore_worker = false;
|
||||
@@ -1452,7 +1466,8 @@ impl VernierApp {
|
||||
close_choice = Some(CloseChoice::Cancel);
|
||||
}
|
||||
egui::Modal::new(egui::Id::new("vernier-close-document")).show(ui.ctx(), |ui| {
|
||||
ui.label("Save changes before closing?");
|
||||
let switching = self.projects.pending_switch.is_some();
|
||||
ui.label(if switching { "Save changes before switching projects?" } else { "Save changes before closing?" });
|
||||
ui.label("Native document path");
|
||||
ui.text_edit_singleline(&mut self.shell.file_path);
|
||||
if self.pending_jobs != 0 {
|
||||
@@ -1474,7 +1489,7 @@ impl VernierApp {
|
||||
self.pending_jobs == 0
|
||||
&& !self.worker_failed
|
||||
&& !self.shell.file_path.trim().is_empty(),
|
||||
egui::Button::new("Save and close"),
|
||||
egui::Button::new(if switching { "Save and switch" } else { "Save and close" }),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
@@ -1483,13 +1498,13 @@ impl VernierApp {
|
||||
if ui
|
||||
.add_enabled(
|
||||
self.pending_jobs == 0,
|
||||
egui::Button::new("Discard and close"),
|
||||
egui::Button::new(if switching { "Discard and switch" } else { "Discard and close" }),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
close_choice = Some(CloseChoice::Discard);
|
||||
}
|
||||
if ui.button("Cancel close").clicked() {
|
||||
if ui.button(if switching { "Cancel switch" } else { "Cancel close" }).clicked() {
|
||||
close_choice = Some(CloseChoice::Cancel);
|
||||
}
|
||||
});
|
||||
@@ -1647,6 +1662,7 @@ impl VernierApp {
|
||||
}
|
||||
return output;
|
||||
}
|
||||
self.handle_project_request();
|
||||
self.handle_recovery_file_request();
|
||||
if self.shell.save_view_requested {
|
||||
self.shell.save_view_requested = false;
|
||||
@@ -1775,6 +1791,10 @@ impl VernierApp {
|
||||
"Repair history or rebuild geometry before using this operation".into();
|
||||
return;
|
||||
}
|
||||
if action == Action::Projects {
|
||||
self.shell.projects.open = true;
|
||||
return;
|
||||
}
|
||||
if action == Action::Files {
|
||||
self.shell.recovery_files.open = true;
|
||||
return;
|
||||
@@ -2149,6 +2169,9 @@ impl VernierApp {
|
||||
}
|
||||
|
||||
pub(crate) fn answer_close(&mut self, choice: CloseChoice) {
|
||||
if self.answer_project_switch(choice) {
|
||||
return;
|
||||
}
|
||||
match choice {
|
||||
CloseChoice::Cancel => self.close_state = CloseState::Idle,
|
||||
CloseChoice::Discard => self.close_state = CloseState::Ready,
|
||||
|
||||
@@ -83,6 +83,8 @@ impl VernierApp {
|
||||
.or_else(|| std::env::var_os("HOME").map(|p| PathBuf::from(p).join(".local/state")));
|
||||
if let Some(base) = base {
|
||||
self.enable_recovery_at(base.join("vernier/recovery"));
|
||||
self.enable_projects_at(base.join("vernier/projects"));
|
||||
self.shell.projects.open = true;
|
||||
self.autosave.persist_appearance = true;
|
||||
} else {
|
||||
self.shell.recovery_files.status = "Recovery unavailable: no state directory".into();
|
||||
@@ -143,6 +145,7 @@ impl VernierApp {
|
||||
self.autosave.dirty_since = None;
|
||||
}
|
||||
fn remember_native_file(&mut self, path: &str) {
|
||||
self.project_opened(path);
|
||||
let recent = &mut self.shell.recovery_files.recent;
|
||||
recent.retain(|entry| entry != path);
|
||||
recent.insert(0, path.into());
|
||||
|
||||
@@ -17,6 +17,8 @@ use crate::sessions::{create_session_edit, slot_is_committable};
|
||||
/// shell's dimension fields or the push/pull gizmo drag.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub(crate) enum Edit {
|
||||
/// Create and enter a new empty native document at an app-owned destination.
|
||||
NewDocument { path: String },
|
||||
/// Undoable display metadata with no native evaluation.
|
||||
Organisation {
|
||||
request: vernier_doc::OrganisationEdit,
|
||||
@@ -1256,6 +1258,7 @@ pub(crate) fn edit_for(action: Action, shell: &ShellState, view: &SceneView) ->
|
||||
| Action::BodyInterference
|
||||
| Action::IsolateBodies
|
||||
| Action::ClearIsolation => None,
|
||||
Action::Projects => None,
|
||||
Action::EditOrganisation => shell
|
||||
.organisation_submission
|
||||
.clone()
|
||||
|
||||
@@ -741,7 +741,18 @@ impl Headless {
|
||||
outstanding: u32::try_from(self.app.pending_worker_events()).unwrap_or(u32::MAX),
|
||||
});
|
||||
}
|
||||
while self.app.pending_worker_events() > 0 {
|
||||
while self.app.pending_worker_events() > 0 || self.app.projects_busy() {
|
||||
self.app.poll_projects();
|
||||
if self.app.pending_worker_events() == 0 {
|
||||
if Instant::now() >= deadline {
|
||||
return Err(HeadlessError::WorkerTimeout {
|
||||
waited: started.elapsed(),
|
||||
outstanding: 0,
|
||||
});
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
continue;
|
||||
}
|
||||
let left = deadline.saturating_duration_since(Instant::now());
|
||||
let outstanding = u32::try_from(self.app.pending_worker_events()).unwrap_or(u32::MAX);
|
||||
match self.app.worker.bounded().recv_timeout(left) {
|
||||
@@ -1451,3 +1462,10 @@ mod autosave_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "headless_process_tests.rs"]
|
||||
mod process_tests;
|
||||
|
||||
impl Headless {
|
||||
/// Enable an isolated local project catalogue for a driven workflow.
|
||||
pub fn enable_project_library(&mut self, root: std::path::PathBuf) {
|
||||
self.app.enable_projects_at(root);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
|
||||
mod app;
|
||||
mod app_worker;
|
||||
mod project_catalogue;
|
||||
pub(crate) use app::autosave;
|
||||
mod body_tools;
|
||||
mod camera;
|
||||
|
||||
@@ -26,6 +26,7 @@ fn body(raw: u64) -> vernier_doc::BodyId {
|
||||
/// inventory to be revisited instead of silently falling through a wildcard.
|
||||
fn variant_name(edit: &Edit) -> &'static str {
|
||||
match edit {
|
||||
Edit::NewDocument { .. } => "NewDocument",
|
||||
Edit::Organisation { .. } => "Organisation",
|
||||
Edit::Variables { .. } => "Variables",
|
||||
Edit::WithExpressions { .. } => "WithExpressions",
|
||||
@@ -136,6 +137,9 @@ fn roundtrip(edit: Edit) -> Edit {
|
||||
fn scalar_and_id_edges_are_exact() {
|
||||
FIXTURES_SEEN.with(|seen| seen.borrow_mut().clear());
|
||||
roundtrip(Edit::Recompute);
|
||||
roundtrip(Edit::NewDocument {
|
||||
path: "/tmp/new.vernier".into(),
|
||||
});
|
||||
roundtrip(Edit::Organisation {
|
||||
request: vernier_doc::OrganisationEdit::CreateFolder {
|
||||
name: "parts".into(),
|
||||
|
||||
@@ -683,6 +683,7 @@ pub(super) fn run(
|
||||
let file = matches!(
|
||||
edit,
|
||||
Edit::SaveDocument { .. }
|
||||
| Edit::NewDocument { .. }
|
||||
| Edit::ExportStep { .. }
|
||||
| Edit::ExportStl { .. }
|
||||
);
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
//! A small, local catalogue of projects known to VernierCAD.
|
||||
//!
|
||||
//! The catalogue is deliberately metadata only. Registering, renaming, archiving,
|
||||
//! or removing an entry never opens, moves, copies, or edits the native document.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fs,
|
||||
io::{self, Write},
|
||||
path::{Path, PathBuf},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
const MAX_CATALOGUE_BYTES: u64 = 4 * 1024 * 1024;
|
||||
|
||||
/// The stable identifier allocated by a [`ProjectCatalogue`].
|
||||
pub(crate) type ProjectId = u64;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub(crate) struct ProjectEntry {
|
||||
pub(crate) id: ProjectId,
|
||||
pub(crate) label: String,
|
||||
pub(crate) path: PathBuf,
|
||||
pub(crate) favourite: bool,
|
||||
pub(crate) archived: bool,
|
||||
/// A catalogue-local sequence, rather than a filesystem timestamp.
|
||||
pub(crate) last_opened: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub(crate) struct ProjectCatalogue {
|
||||
next_id: ProjectId,
|
||||
entries: Vec<ProjectEntry>,
|
||||
next_opened: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum CatalogueSort {
|
||||
Name,
|
||||
Recent,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum CatalogueError {
|
||||
Io(io::Error),
|
||||
Json(serde_json::Error),
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CatalogueError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Io(error) => write!(f, "catalogue I/O: {error}"),
|
||||
Self::Json(error) => write!(f, "catalogue JSON: {error}"),
|
||||
Self::Invalid(error) => write!(f, "invalid project catalogue: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CatalogueError {}
|
||||
|
||||
impl From<io::Error> for CatalogueError {
|
||||
fn from(error: io::Error) -> Self {
|
||||
Self::Io(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for CatalogueError {
|
||||
fn from(error: serde_json::Error) -> Self {
|
||||
Self::Json(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl ProjectCatalogue {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
next_id: 1,
|
||||
next_opened: 1,
|
||||
entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn entries(&self) -> &[ProjectEntry] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
/// Register a path exactly as supplied. The path is not canonicalised or moved.
|
||||
/// Re-registering the same path is idempotent and returns its existing ID.
|
||||
pub(crate) fn register(
|
||||
&mut self,
|
||||
path: impl Into<PathBuf>,
|
||||
label: impl Into<String>,
|
||||
) -> Result<ProjectId, CatalogueError> {
|
||||
let path = path.into();
|
||||
let label = label.into();
|
||||
validate_metadata(&path, &label)?;
|
||||
if let Some(entry) = self.entries.iter().find(|entry| entry.path == path) {
|
||||
return Ok(entry.id);
|
||||
}
|
||||
let id = self.next_id;
|
||||
self.next_id = self
|
||||
.next_id
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| CatalogueError::Invalid("project ID space exhausted".into()))?;
|
||||
self.entries.push(ProjectEntry {
|
||||
id,
|
||||
label,
|
||||
path,
|
||||
favourite: false,
|
||||
archived: false,
|
||||
last_opened: 0,
|
||||
});
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub(crate) fn rename(
|
||||
&mut self,
|
||||
id: ProjectId,
|
||||
label: impl Into<String>,
|
||||
) -> Result<(), CatalogueError> {
|
||||
let label = label.into();
|
||||
if label.trim().is_empty() {
|
||||
return Err(CatalogueError::Invalid(
|
||||
"project label cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
self.entry_mut(id)?.label = label;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn set_favourite(
|
||||
&mut self,
|
||||
id: ProjectId,
|
||||
favourite: bool,
|
||||
) -> Result<(), CatalogueError> {
|
||||
self.entry_mut(id)?.favourite = favourite;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn archive(&mut self, id: ProjectId, archived: bool) -> Result<(), CatalogueError> {
|
||||
self.entry_mut(id)?.archived = archived;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record a successful open without touching the document itself.
|
||||
pub(crate) fn mark_opened(&mut self, id: ProjectId) -> Result<(), CatalogueError> {
|
||||
self.entries
|
||||
.iter()
|
||||
.find(|entry| entry.id == id)
|
||||
.ok_or_else(|| CatalogueError::Invalid(format!("unknown project ID {id}")))?;
|
||||
let sequence = self.next_opened;
|
||||
self.next_opened = self
|
||||
.next_opened
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| CatalogueError::Invalid("recent sequence exhausted".into()))?;
|
||||
self.entry_mut(id)?.last_opened = sequence;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn locate(&self, id: ProjectId) -> Option<&Path> {
|
||||
self.entries
|
||||
.iter()
|
||||
.find(|entry| entry.id == id)
|
||||
.map(|entry| entry.path.as_path())
|
||||
}
|
||||
|
||||
pub(crate) fn relocate(&mut self, id: ProjectId, path: PathBuf) -> Result<(), CatalogueError> {
|
||||
if self
|
||||
.entries
|
||||
.iter()
|
||||
.any(|entry| entry.id != id && entry.path == path)
|
||||
{
|
||||
return Err(CatalogueError::Invalid(
|
||||
"path already belongs to another project".into(),
|
||||
));
|
||||
}
|
||||
let entry = self.entry_mut(id)?;
|
||||
validate_metadata(&path, &entry.label)?;
|
||||
entry.path = path;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove only catalogue metadata. The native file remains untouched.
|
||||
pub(crate) fn remove(&mut self, id: ProjectId) -> bool {
|
||||
let before = self.entries.len();
|
||||
self.entries.retain(|entry| entry.id != id);
|
||||
before != self.entries.len()
|
||||
}
|
||||
|
||||
pub(crate) fn search(&self, query: &str, sort: CatalogueSort) -> Vec<&ProjectEntry> {
|
||||
let query = query.trim().to_lowercase();
|
||||
let mut matches: Vec<_> = self
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
query.is_empty()
|
||||
|| entry.label.to_lowercase().contains(&query)
|
||||
|| entry.path.to_string_lossy().to_lowercase().contains(&query)
|
||||
})
|
||||
.collect();
|
||||
matches.sort_by(|a, b| match sort {
|
||||
CatalogueSort::Name => a
|
||||
.label
|
||||
.to_lowercase()
|
||||
.cmp(&b.label.to_lowercase())
|
||||
.then_with(|| a.path.cmp(&b.path))
|
||||
.then(a.id.cmp(&b.id)),
|
||||
CatalogueSort::Recent => b
|
||||
.last_opened
|
||||
.cmp(&a.last_opened)
|
||||
.then_with(|| a.label.to_lowercase().cmp(&b.label.to_lowercase()))
|
||||
.then(a.id.cmp(&b.id)),
|
||||
});
|
||||
matches
|
||||
}
|
||||
|
||||
pub(crate) fn load(path: &Path) -> Result<Self, CatalogueError> {
|
||||
let metadata = fs::metadata(path)?;
|
||||
if metadata.len() > MAX_CATALOGUE_BYTES {
|
||||
return Err(CatalogueError::Invalid(
|
||||
"catalogue file is too large".into(),
|
||||
));
|
||||
}
|
||||
let bytes = fs::read(path)?;
|
||||
let catalogue: Self = serde_json::from_slice(&bytes)?;
|
||||
catalogue.validate()?;
|
||||
Ok(catalogue)
|
||||
}
|
||||
|
||||
/// Atomically replace `path`; the parent directory must already exist.
|
||||
pub(crate) fn save(&self, path: &Path) -> Result<(), CatalogueError> {
|
||||
self.validate()?;
|
||||
let bytes = serde_json::to_vec_pretty(self)?;
|
||||
let temporary = path.with_file_name(format!(
|
||||
".{}.{}.tmp",
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("catalogue"),
|
||||
temporary_suffix()
|
||||
));
|
||||
let result = (|| {
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&temporary)?;
|
||||
file.write_all(&bytes)?;
|
||||
file.sync_all()?;
|
||||
fs::rename(&temporary, path)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::File::open(parent)?.sync_all()?;
|
||||
}
|
||||
Ok::<(), io::Error>(())
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
}
|
||||
result.map_err(CatalogueError::Io)
|
||||
}
|
||||
|
||||
fn entry_mut(&mut self, id: ProjectId) -> Result<&mut ProjectEntry, CatalogueError> {
|
||||
self.entries
|
||||
.iter_mut()
|
||||
.find(|entry| entry.id == id)
|
||||
.ok_or_else(|| CatalogueError::Invalid(format!("unknown project ID {id}")))
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), CatalogueError> {
|
||||
if self.next_id == 0 || self.next_opened == 0 {
|
||||
return Err(CatalogueError::Invalid(
|
||||
"allocation counters must be nonzero".into(),
|
||||
));
|
||||
}
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
let mut paths = std::collections::BTreeSet::new();
|
||||
for entry in self.entries() {
|
||||
validate_metadata(&entry.path, &entry.label)?;
|
||||
if !ids.insert(entry.id) {
|
||||
return Err(CatalogueError::Invalid(format!(
|
||||
"duplicate project ID {}",
|
||||
entry.id
|
||||
)));
|
||||
}
|
||||
if !paths.insert(&entry.path) {
|
||||
return Err(CatalogueError::Invalid(format!(
|
||||
"duplicate project path {}",
|
||||
entry.path.display()
|
||||
)));
|
||||
}
|
||||
if entry.id == 0 || entry.id >= self.next_id {
|
||||
return Err(CatalogueError::Invalid(
|
||||
"next ID does not follow entries".into(),
|
||||
));
|
||||
}
|
||||
if entry.last_opened >= self.next_opened && entry.last_opened != 0 {
|
||||
return Err(CatalogueError::Invalid(
|
||||
"next recent sequence does not follow entries".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_metadata(path: &Path, label: &str) -> Result<(), CatalogueError> {
|
||||
if path.as_os_str().is_empty() {
|
||||
return Err(CatalogueError::Invalid(
|
||||
"project path cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
if label.trim().is_empty() {
|
||||
return Err(CatalogueError::Invalid(
|
||||
"project label cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn temporary_suffix() -> String {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
format!("{}-{nanos}", std::process::id())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"vernier-project-catalogue-{}-{name}",
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_operations_keep_native_path_and_ids_stable() {
|
||||
let mut catalogue = ProjectCatalogue::new();
|
||||
let first = catalogue.register("/tmp/a.vernier", "Alpha").unwrap();
|
||||
let second = catalogue.register("/tmp/b.vernier", "Beta").unwrap();
|
||||
assert_eq!(
|
||||
catalogue.register("/tmp/a.vernier", "ignored").unwrap(),
|
||||
first
|
||||
);
|
||||
catalogue.rename(first, "Renamed").unwrap();
|
||||
catalogue.set_favourite(first, true).unwrap();
|
||||
catalogue.archive(second, true).unwrap();
|
||||
catalogue.mark_opened(second).unwrap();
|
||||
catalogue.mark_opened(first).unwrap();
|
||||
assert_eq!(catalogue.locate(first), Some(Path::new("/tmp/a.vernier")));
|
||||
assert_eq!(catalogue.search("ren", CatalogueSort::Name)[0].id, first);
|
||||
assert_eq!(catalogue.search("", CatalogueSort::Recent)[0].id, first);
|
||||
assert!(catalogue.remove(second));
|
||||
assert!(catalogue.locate(second).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persistence_is_round_trip_and_atomic() {
|
||||
let path = scratch("roundtrip.json");
|
||||
let mut catalogue = ProjectCatalogue::new();
|
||||
let id = catalogue.register("/native/part.vernier", "Part").unwrap();
|
||||
catalogue.mark_opened(id).unwrap();
|
||||
catalogue.save(&path).unwrap();
|
||||
let restored = ProjectCatalogue::load(&path).unwrap();
|
||||
assert_eq!(restored, catalogue);
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_catalogue_is_rejected_without_partial_state() {
|
||||
let path = scratch("corrupt.json");
|
||||
fs::write(&path, br#"{"next_id":2,"entries":[{"id":1,"label":"","path":"/x","favourite":false,"archived":false,"last_opened":0}],"next_opened":1}"#).unwrap();
|
||||
let error = ProjectCatalogue::load(&path).unwrap_err().to_string();
|
||||
assert!(error.contains("label cannot be empty"));
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ProjectCatalogue {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
//! Local catalogue IO and document transitions. Native geometry stays worker-owned.
|
||||
use super::{CloseChoice, CloseState, VernierApp};
|
||||
use crate::project_catalogue::{CatalogueSort, ProjectCatalogue};
|
||||
use crate::server::{checkpoint::ServerCheckpoint, native_files::NativeFile};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
thread::JoinHandle,
|
||||
};
|
||||
use vernier_ui::shell::{ProjectRequest, ProjectRow};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Projects {
|
||||
root: Option<PathBuf>,
|
||||
catalogue: ProjectCatalogue,
|
||||
io: Option<JoinHandle<Result<Loaded, String>>>,
|
||||
pub(super) pending_switch: Option<ProjectRequest>,
|
||||
remember: Option<String>,
|
||||
pub(super) switching: bool,
|
||||
}
|
||||
struct Loaded {
|
||||
catalogue: ProjectCatalogue,
|
||||
rows: Vec<ProjectRow>,
|
||||
transition: Option<(String, bool)>,
|
||||
status: String,
|
||||
}
|
||||
impl VernierApp {
|
||||
pub(crate) fn enable_projects_at(&mut self, root: PathBuf) {
|
||||
self.projects.root = Some(root.clone());
|
||||
self.projects.io = Some(std::thread::spawn(move || {
|
||||
std::fs::create_dir_all(&root).map_err(|e| e.to_string())?;
|
||||
let path = root.join("catalogue.json");
|
||||
let catalogue = if path.exists() {
|
||||
ProjectCatalogue::load(&path).map_err(|e| e.to_string())?
|
||||
} else {
|
||||
ProjectCatalogue::new()
|
||||
};
|
||||
Ok(loaded(catalogue, None, "", CatalogueSort::Name))
|
||||
}));
|
||||
}
|
||||
|
||||
pub(crate) fn projects_busy(&self) -> bool {
|
||||
self.projects.io.is_some() || self.projects.remember.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn poll_projects(&mut self) {
|
||||
if self
|
||||
.projects
|
||||
.io
|
||||
.as_ref()
|
||||
.is_some_and(JoinHandle::is_finished)
|
||||
{
|
||||
let result = self.projects.io.take().map(|job| job.join());
|
||||
self.projects.switching = false;
|
||||
match result {
|
||||
Some(Ok(Ok(done))) => {
|
||||
self.projects.catalogue = done.catalogue;
|
||||
self.shell.projects.rows = done.rows;
|
||||
self.shell.projects.status = done.status;
|
||||
if let Some((path, new)) = done.transition {
|
||||
self.shell.projects.open = false;
|
||||
self.submit(if new {
|
||||
crate::edit::Edit::NewDocument { path }
|
||||
} else {
|
||||
crate::edit::Edit::OpenDocument {
|
||||
path,
|
||||
identities: crate::edit::OpenIdentities::Sidecar,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(Ok(Err(error))) => self.shell.projects.status = error,
|
||||
_ => self.shell.projects.status = "Project file task stopped unexpectedly".into(),
|
||||
}
|
||||
}
|
||||
if self.projects.io.is_none()
|
||||
&& let Some(path) = self.projects.remember.take()
|
||||
{
|
||||
self.start_project_job(ProjectRequest::Register { path });
|
||||
}
|
||||
self.shell.projects.busy = self.projects.io.is_some()
|
||||
|| self.pending_jobs != 0
|
||||
|| self.projects.pending_switch.is_some();
|
||||
}
|
||||
|
||||
pub(crate) fn project_opened(&mut self, path: &str) {
|
||||
if self.projects.root.is_some() {
|
||||
self.projects.remember = Some(path.into());
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn handle_project_request(&mut self) {
|
||||
let Some(request) = self.shell.project_request.take() else {
|
||||
return;
|
||||
};
|
||||
if self.projects.io.is_some() || self.pending_jobs != 0 || self.worker_failed {
|
||||
self.shell.projects.status =
|
||||
"Finish or cancel pending work before changing projects".into();
|
||||
return;
|
||||
}
|
||||
if matches!(
|
||||
request,
|
||||
ProjectRequest::New { .. } | ProjectRequest::Open { .. }
|
||||
) && self.scene.unsaved
|
||||
{
|
||||
self.projects.pending_switch = Some(request);
|
||||
self.close_state = CloseState::Confirm;
|
||||
return;
|
||||
}
|
||||
self.start_project_job(request);
|
||||
}
|
||||
|
||||
pub(super) fn answer_project_switch(&mut self, choice: CloseChoice) -> bool {
|
||||
if self.projects.pending_switch.is_none() {
|
||||
return false;
|
||||
}
|
||||
match choice {
|
||||
CloseChoice::Cancel => {
|
||||
self.projects.pending_switch = None;
|
||||
self.close_state = CloseState::Idle;
|
||||
}
|
||||
CloseChoice::Discard => {
|
||||
self.close_state = CloseState::Idle;
|
||||
if let Some(request) = self.projects.pending_switch.take() {
|
||||
self.start_project_job(request);
|
||||
}
|
||||
}
|
||||
CloseChoice::Save => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
pub(super) fn complete_project_save(&mut self) {
|
||||
if self.close_state == CloseState::Ready && self.projects.pending_switch.is_some() {
|
||||
self.close_state = CloseState::Idle;
|
||||
if let Some(request) = self.projects.pending_switch.take() {
|
||||
self.start_project_job(request);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn start_project_job(&mut self, request: ProjectRequest) {
|
||||
let Some(root) = self.projects.root.clone() else {
|
||||
self.shell.projects.status =
|
||||
"Project library storage is not enabled in this session".into();
|
||||
return;
|
||||
};
|
||||
let catalogue = self.projects.catalogue.clone();
|
||||
let checkpoint = self
|
||||
.worker
|
||||
.acknowledged_checkpoint()
|
||||
.map(|ack| ack.checkpoint);
|
||||
if matches!(request, ProjectRequest::Duplicate { .. }) && checkpoint.is_none() {
|
||||
self.shell.projects.status =
|
||||
"Wait for the acknowledged document snapshot before duplicating".into();
|
||||
return;
|
||||
}
|
||||
let sort = if self.shell.projects.sort_by_label {
|
||||
CatalogueSort::Name
|
||||
} else {
|
||||
CatalogueSort::Recent
|
||||
};
|
||||
self.projects.switching = matches!(
|
||||
request,
|
||||
ProjectRequest::New { .. } | ProjectRequest::Open { .. }
|
||||
);
|
||||
self.projects.io = Some(std::thread::spawn(move || {
|
||||
run(&root, catalogue, request, checkpoint, sort)
|
||||
}));
|
||||
self.shell.projects.busy = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn loaded(
|
||||
catalogue: ProjectCatalogue,
|
||||
transition: Option<(String, bool)>,
|
||||
status: &str,
|
||||
sort: CatalogueSort,
|
||||
) -> Loaded {
|
||||
let entries = catalogue.search("", sort);
|
||||
let rows = entries
|
||||
.into_iter()
|
||||
.map(|entry| ProjectRow {
|
||||
id: entry.id,
|
||||
label: entry.label.clone(),
|
||||
path: entry.path.to_string_lossy().into_owned(),
|
||||
favourite: entry.favourite,
|
||||
archived: entry.archived,
|
||||
missing: !entry.path.is_file(),
|
||||
last_opened: entry.last_opened,
|
||||
})
|
||||
.collect();
|
||||
Loaded {
|
||||
catalogue,
|
||||
rows,
|
||||
transition,
|
||||
status: status.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(
|
||||
root: &Path,
|
||||
mut catalogue: ProjectCatalogue,
|
||||
request: ProjectRequest,
|
||||
checkpoint: Option<Arc<ServerCheckpoint>>,
|
||||
sort: CatalogueSort,
|
||||
) -> Result<Loaded, String> {
|
||||
use ProjectRequest::*;
|
||||
let catalogue_path = root.join("catalogue.json");
|
||||
if matches!(request, Open { .. }) {
|
||||
let Open { id } = request else { unreachable!() };
|
||||
let path = catalogue.locate(id).ok_or("Project no longer exists")?;
|
||||
if !path.is_file() {
|
||||
return Err("Project file is missing; use Locate".into());
|
||||
}
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
return Ok(loaded(catalogue, Some((path, false)), "", sort));
|
||||
}
|
||||
let catalogue_lock = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(root.join("catalogue.lock"))
|
||||
.map_err(|e| e.to_string())?;
|
||||
catalogue_lock
|
||||
.try_lock()
|
||||
.map_err(|e| format!("Project library is busy: {e}"))?;
|
||||
if catalogue_path.exists() {
|
||||
catalogue = ProjectCatalogue::load(&catalogue_path).map_err(|e| {
|
||||
format!("Catalogue unavailable; native Open and recovery remain available: {e}")
|
||||
})?;
|
||||
}
|
||||
let mut transition = None;
|
||||
let error = |e: crate::project_catalogue::CatalogueError| e.to_string();
|
||||
match request {
|
||||
New { label } => {
|
||||
if label.trim().is_empty() {
|
||||
return Err("Name the new project".into());
|
||||
}
|
||||
let path = allocate_directory(root)?.join("document.vernier");
|
||||
catalogue.register(path.clone(), label).map_err(error)?;
|
||||
transition = Some((path.to_string_lossy().into_owned(), true));
|
||||
}
|
||||
Open { id } => {
|
||||
let path = catalogue.locate(id).ok_or("Project no longer exists")?;
|
||||
if !path.is_file() {
|
||||
return Err("Project file is missing; use Locate".into());
|
||||
}
|
||||
transition = Some((path.to_string_lossy().into_owned(), false));
|
||||
}
|
||||
Duplicate { id, label } => {
|
||||
if label.trim().is_empty() {
|
||||
return Err("Name the duplicate project".into());
|
||||
}
|
||||
let source = catalogue.locate(id).ok_or("Project no longer exists")?;
|
||||
let source_text = source.to_string_lossy();
|
||||
let (document, store) = if let Some(saved) = checkpoint.filter(|saved| {
|
||||
saved.document_path.as_deref().is_some_and(|path| {
|
||||
Path::new(path) == source
|
||||
|| Path::new(path)
|
||||
.canonicalize()
|
||||
.ok()
|
||||
.zip(source.canonicalize().ok())
|
||||
.is_some_and(|(a, b)| a == b)
|
||||
})
|
||||
}) {
|
||||
let mut document =
|
||||
vernier_doc::Document::from_session_json(&saved.document_session_json)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if let Some((marker,)) = saved.repair_return_marker {
|
||||
document
|
||||
.set_rollback(marker.filter(|id| document.features().contains_key(id)))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let store = vernier_ui::NamingStore::from_json(&saved.naming_store_json)
|
||||
.map_err(|e| e.to_string())?;
|
||||
vernier_ui::validate_store(&document, &store).map_err(|e| e.to_string())?;
|
||||
(
|
||||
document.to_json().map_err(|e| e.to_string())?,
|
||||
store.to_json_for(&document).map_err(|e| e.to_string())?,
|
||||
)
|
||||
} else {
|
||||
let mut native = NativeFile::acquire(&source_text).map_err(|e| e.to_string())?;
|
||||
native.recover().map_err(|e| e.to_string())?;
|
||||
let text = native.read_document().map_err(|e| e.to_string())?;
|
||||
let names = native.read_store().map_err(|e| e.to_string())?;
|
||||
let document =
|
||||
vernier_doc::Document::from_json(&text).map_err(|e| e.to_string())?;
|
||||
let store =
|
||||
vernier_ui::NamingStore::from_json(&names).map_err(|e| e.to_string())?;
|
||||
vernier_ui::validate_store(&document, &store).map_err(|e| e.to_string())?;
|
||||
(
|
||||
document.to_json().map_err(|e| e.to_string())?,
|
||||
store.to_json_for(&document).map_err(|e| e.to_string())?,
|
||||
)
|
||||
};
|
||||
let path = allocate_directory(root)?.join("document.vernier");
|
||||
NativeFile::acquire(&path.to_string_lossy())
|
||||
.map_err(|e| e.to_string())?
|
||||
.save_pair(&document, &store)
|
||||
.map_err(|e| e.to_string())?;
|
||||
catalogue.register(path, label).map_err(error)?;
|
||||
}
|
||||
Rename { id, label } => catalogue.rename(id, label).map_err(error)?,
|
||||
Favourite { id, value } => catalogue.set_favourite(id, value).map_err(error)?,
|
||||
Archive { id, value } => catalogue.archive(id, value).map_err(error)?,
|
||||
Locate { id, path } => {
|
||||
let path = PathBuf::from(path)
|
||||
.canonicalize()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !path.is_file() {
|
||||
return Err("Choose an existing native document".into());
|
||||
}
|
||||
catalogue.relocate(id, path).map_err(error)?;
|
||||
}
|
||||
Remove { id } => {
|
||||
if !catalogue.remove(id) {
|
||||
return Err("Project no longer exists".into());
|
||||
}
|
||||
}
|
||||
Register { path } => {
|
||||
let path = PathBuf::from(path)
|
||||
.canonicalize()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !path.is_file() {
|
||||
return Err("Choose an existing native document".into());
|
||||
}
|
||||
let label = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("Project")
|
||||
.to_owned();
|
||||
let id = catalogue.register(path, label).map_err(error)?;
|
||||
catalogue.mark_opened(id).map_err(error)?;
|
||||
}
|
||||
}
|
||||
catalogue
|
||||
.save(&root.join("catalogue.json"))
|
||||
.map_err(error)?;
|
||||
Ok(loaded(catalogue, transition, "", sort))
|
||||
}
|
||||
fn allocate_directory(root: &Path) -> Result<PathBuf, String> {
|
||||
for index in 1..=1_000_000 {
|
||||
let path = root.join(format!("project-{index:06}"));
|
||||
match std::fs::create_dir(&path) {
|
||||
Ok(()) => {
|
||||
std::fs::File::open(root)
|
||||
.and_then(|f| f.sync_all())
|
||||
.map_err(|e| e.to_string())?;
|
||||
return Ok(path);
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
|
||||
Err(error) => return Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
Err("Project directory limit reached".into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
use crate::{edit::Edit, server::DocumentServer};
|
||||
#[test]
|
||||
fn projects_duplicate_acknowledged_unsaved_state_without_sharing_files() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("vernier-library-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let path = root.join("original.vernier");
|
||||
let mut server = DocumentServer::new();
|
||||
let saved = server.handle(Edit::SaveDocument {
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
});
|
||||
assert!(saved.error.is_none(), "{:?}", saved.error);
|
||||
let body = server.document.bodies()[0];
|
||||
server.handle(Edit::SetBodyAttributes {
|
||||
body,
|
||||
name: Some("unsaved copy".into()),
|
||||
colour: None,
|
||||
visible: None,
|
||||
});
|
||||
let registered = run(
|
||||
&root,
|
||||
ProjectCatalogue::new(),
|
||||
ProjectRequest::Register {
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
},
|
||||
None,
|
||||
CatalogueSort::Name,
|
||||
)
|
||||
.unwrap();
|
||||
let id = registered.catalogue.entries()[0].id;
|
||||
let copy = run(
|
||||
&root,
|
||||
registered.catalogue,
|
||||
ProjectRequest::Duplicate {
|
||||
id,
|
||||
label: "Copy".into(),
|
||||
},
|
||||
Some(Arc::new(server.checkpoint().unwrap())),
|
||||
CatalogueSort::Name,
|
||||
)
|
||||
.unwrap();
|
||||
let copy_path = ©.catalogue.entries()[1].path;
|
||||
let copied =
|
||||
vernier_doc::Document::from_json(&std::fs::read_to_string(copy_path).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
copied.body_attributes(body).name.as_deref(),
|
||||
Some("unsaved copy")
|
||||
);
|
||||
let original =
|
||||
vernier_doc::Document::from_json(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
assert_ne!(
|
||||
original.body_attributes(body).name,
|
||||
copied.body_attributes(body).name
|
||||
);
|
||||
let empty_path = root.join("empty.vernier");
|
||||
let empty = server.handle(Edit::NewDocument {
|
||||
path: empty_path.to_string_lossy().into_owned(),
|
||||
});
|
||||
assert!(
|
||||
empty.document_opened && empty.view.timeline.is_empty(),
|
||||
"{:?}",
|
||||
empty.error
|
||||
);
|
||||
assert!(
|
||||
server
|
||||
.handle(Edit::NewDocument {
|
||||
path: path.to_string_lossy().into_owned()
|
||||
})
|
||||
.error
|
||||
.is_some()
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&path).unwrap(),
|
||||
original.to_json().unwrap()
|
||||
);
|
||||
let mut catalogue = copy.catalogue;
|
||||
let missing = root.join("missing.vernier");
|
||||
catalogue.relocate(id, missing).unwrap();
|
||||
catalogue.save(&root.join("catalogue.json")).unwrap();
|
||||
assert!(
|
||||
loaded(catalogue.clone(), None, "", CatalogueSort::Name)
|
||||
.rows
|
||||
.iter()
|
||||
.any(|row| row.id == id && row.missing)
|
||||
);
|
||||
let located = run(
|
||||
&root,
|
||||
catalogue,
|
||||
ProjectRequest::Locate {
|
||||
id,
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
},
|
||||
None,
|
||||
CatalogueSort::Name,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(root.join("catalogue.json"), "invalid catalogue").unwrap();
|
||||
assert!(
|
||||
run(
|
||||
&root,
|
||||
located.catalogue.clone(),
|
||||
ProjectRequest::Open { id },
|
||||
None,
|
||||
CatalogueSort::Name
|
||||
)
|
||||
.unwrap()
|
||||
.transition
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
run(
|
||||
&root,
|
||||
located.catalogue,
|
||||
ProjectRequest::Rename {
|
||||
id,
|
||||
label: "blocked".into()
|
||||
},
|
||||
None,
|
||||
CatalogueSort::Name
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("catalogue.json")).unwrap(),
|
||||
"invalid catalogue"
|
||||
);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,16 @@ enum Role {
|
||||
Commands,
|
||||
}
|
||||
const SOURCES: &[(&str, Role, &str)] = &[
|
||||
(
|
||||
"project_library.rs",
|
||||
Role::Production,
|
||||
include_str!("project_library.rs"),
|
||||
),
|
||||
(
|
||||
"project_catalogue.rs",
|
||||
Role::Production,
|
||||
include_str!("project_catalogue.rs"),
|
||||
),
|
||||
(
|
||||
"display_preferences.rs",
|
||||
Role::Production,
|
||||
|
||||
@@ -22,7 +22,7 @@ use super::*;
|
||||
/// `a_mis_routed_edit_is_refused_rather_than_panicking`.
|
||||
pub(crate) fn apply(server: &mut DocumentServer, edit: &Edit) -> Result<Applied, String> {
|
||||
match edit {
|
||||
Edit::SaveDocument { path } => {
|
||||
Edit::SaveDocument { path } | Edit::NewDocument { path } => {
|
||||
let doc_json = server.document.to_json().map_err(stringy)?;
|
||||
let doc_json = if let Some(marker) = server.repair_return_marker {
|
||||
let mut authored = Document::from_json(&doc_json).map_err(stringy)?;
|
||||
@@ -42,6 +42,9 @@ pub(crate) fn apply(server: &mut DocumentServer, edit: &Edit) -> Result<Applied,
|
||||
.to_json_for(&server.document)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut native = native_files::NativeFile::acquire(path).map_err(|e| e.to_string())?;
|
||||
if matches!(edit, Edit::NewDocument { .. }) {
|
||||
native.require_absent().map_err(|e| e.to_string())?;
|
||||
}
|
||||
let recovery = native.recover().map_err(|error| {
|
||||
if error.phase != native_files::SavePhase::BeforePublication {
|
||||
server.unsaved = true;
|
||||
|
||||
@@ -405,6 +405,33 @@ impl DocumentServer {
|
||||
ExportObservation,
|
||||
) -> Result<(), String>,
|
||||
) -> Scene {
|
||||
if let Edit::NewDocument { path } = &edit {
|
||||
if std::path::Path::new(path).exists() {
|
||||
let mut scene = self
|
||||
.metadata_scene
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.scene_without_recompute());
|
||||
scene.error = Some("New project destination already exists".into());
|
||||
return scene;
|
||||
}
|
||||
let mut fresh = Self::empty();
|
||||
if let Err(error) =
|
||||
apply_files::apply(&mut fresh, &Edit::NewDocument { path: path.clone() })
|
||||
{
|
||||
let mut scene = self
|
||||
.metadata_scene
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.scene_without_recompute());
|
||||
scene.error = Some(error);
|
||||
return scene;
|
||||
}
|
||||
fresh.document_path = Some(path.clone());
|
||||
fresh.unsaved = false;
|
||||
let mut scene = fresh.handle_with_compile(Edit::Recompute, compile);
|
||||
scene.document_opened = true;
|
||||
*self = fresh;
|
||||
return scene;
|
||||
}
|
||||
if let Edit::PreviewBodyTool { submission, token } = &edit {
|
||||
return self.preview_body_tool(submission, *token);
|
||||
}
|
||||
@@ -1041,6 +1068,7 @@ impl DocumentServer {
|
||||
return Ok(Applied::ReadOnly);
|
||||
}
|
||||
match edit {
|
||||
Edit::NewDocument { .. } => Err("New document must pass through the lifecycle handler".into()),
|
||||
Edit::History { request } => history::apply(self, request),
|
||||
Edit::Variables { .. } | Edit::WithExpressions { .. } => expressions::apply(self, edit).map(Applied::from_family),
|
||||
Edit::ImportStep { path } => import_step::apply(self, path).map(Applied::from_family),
|
||||
|
||||
@@ -237,6 +237,21 @@ impl NativeFile {
|
||||
let p = self.0.paths.store.clone();
|
||||
self.0.read_text(&p)
|
||||
}
|
||||
pub(crate) fn require_absent(&mut self) -> Result<(), FileFailure> {
|
||||
for path in [
|
||||
self.0.paths.document.clone(),
|
||||
self.0.paths.store.clone(),
|
||||
self.0.paths.journal.clone(),
|
||||
] {
|
||||
if self.0.entry(&path)? != Entry::Missing {
|
||||
return self.0.checked(
|
||||
"create new native document",
|
||||
Err(invalid("New project destination already exists")),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn save_pair(&mut self, document: &str, store: &str) -> Result<(), FileFailure> {
|
||||
self.0.save_pair(document, store)
|
||||
}
|
||||
|
||||
@@ -128,6 +128,7 @@ impl Runner {
|
||||
mut headless: Headless,
|
||||
) -> Result<Self, DriveError> {
|
||||
std::fs::create_dir_all(out)?;
|
||||
headless.enable_project_library(out.join("projects"));
|
||||
if let Some(want) = options.require_adapter
|
||||
&& !headless.adapter_name().contains(want)
|
||||
{
|
||||
|
||||
@@ -1262,6 +1262,14 @@ pub static COMMANDS: &[CommandDescriptor] = &[
|
||||
needs: "name a native document path first",
|
||||
..BLANK
|
||||
},
|
||||
CommandDescriptor {
|
||||
dispatch: Dispatch::Run(Action::Projects),
|
||||
label: "projects",
|
||||
group: "file",
|
||||
surface: Surface::Value,
|
||||
needs: "open local projects",
|
||||
..BLANK
|
||||
},
|
||||
CommandDescriptor {
|
||||
dispatch: Dispatch::Run(Action::Files),
|
||||
label: "files and recovery",
|
||||
@@ -1799,6 +1807,7 @@ pub fn readiness_for_context(
|
||||
};
|
||||
match action {
|
||||
Action::SaveDocument
|
||||
| Action::Projects
|
||||
| Action::Files
|
||||
| Action::ChooseOpenDocument
|
||||
| Action::ChooseSaveDocument => Readiness::Ready,
|
||||
|
||||
@@ -101,7 +101,8 @@ pub(super) fn is_producer(action: Action) -> bool {
|
||||
pub fn action_allowed_before_rebuild(action: Action) -> bool {
|
||||
if matches!(
|
||||
action,
|
||||
Action::Files
|
||||
Action::Projects
|
||||
| Action::Files
|
||||
| Action::ChooseOpenDocument
|
||||
| Action::ChooseSaveDocument
|
||||
| Action::Variables
|
||||
|
||||
@@ -1510,6 +1510,10 @@ pub enum SketchCurveKind {
|
||||
/// values the toolbar's dimension fields edit.
|
||||
#[derive(Debug)]
|
||||
pub struct ShellState {
|
||||
/// Local project dashboard supplied by the app controller.
|
||||
pub projects: ProjectLibraryState,
|
||||
/// One explicit local project action.
|
||||
pub project_request: Option<ProjectRequest>,
|
||||
/// Items panel transient fields.
|
||||
pub items: ItemsState,
|
||||
/// Pending authored metadata mutation.
|
||||
@@ -1981,6 +1985,8 @@ impl Default for ShellState {
|
||||
body_tool_controls: Vec::new(),
|
||||
isolated_bodies: Vec::new(),
|
||||
body_selection_active: false,
|
||||
projects: ProjectLibraryState::default(),
|
||||
project_request: None,
|
||||
items: ItemsState::default(),
|
||||
organisation_submission: None,
|
||||
body_attribute_submission: None,
|
||||
@@ -3148,6 +3154,7 @@ pub fn show(ui: &mut egui::Ui, view: &SceneView, state: &mut ShellState) -> Shel
|
||||
import::panel(ui, view, state, &mut response);
|
||||
variables::panel(ui, view, state);
|
||||
recovery_files::panel(ui, view, state);
|
||||
projects::panel(ui, state);
|
||||
view_controls::panel(ui, state);
|
||||
let palette_owned_input = state.command_bar.open;
|
||||
ribbon::command_bar(ui, state, theme, selection, view.unsaved, &mut response);
|
||||
@@ -3739,3 +3746,6 @@ pub struct FieldExpression {
|
||||
|
||||
mod items;
|
||||
pub use items::ItemsState;
|
||||
|
||||
mod projects;
|
||||
pub use projects::{ProjectLibraryState, ProjectRequest, ProjectRow};
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
//! Local project-library dashboard.
|
||||
//!
|
||||
//! This module is deliberately an intent surface: it never reads directories,
|
||||
//! creates files, or opens a project itself. The app owns the catalogue and
|
||||
//! consumes one [`ProjectRequest`] emitted by the panel at a time.
|
||||
|
||||
use super::{BodyToolControlRect, ShellState};
|
||||
|
||||
/// A catalogue row supplied by the app's project controller.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ProjectRow {
|
||||
/// Stable catalogue identity.
|
||||
pub id: u64,
|
||||
/// User-facing project label.
|
||||
pub label: String,
|
||||
/// Native project path, when known.
|
||||
pub path: String,
|
||||
/// Whether the row is pinned to the top.
|
||||
pub favourite: bool,
|
||||
/// Whether the row is hidden unless archive filtering is enabled.
|
||||
pub archived: bool,
|
||||
/// Whether its path could not be found by the controller.
|
||||
pub missing: bool,
|
||||
/// Controller-provided recency key, larger means newer.
|
||||
pub last_opened: u64,
|
||||
}
|
||||
|
||||
/// Persistent UI state for the Projects dashboard.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ProjectLibraryState {
|
||||
/// Whether the dashboard window is open.
|
||||
pub open: bool,
|
||||
/// Catalogue rows from the app controller.
|
||||
pub rows: Vec<ProjectRow>,
|
||||
/// Controller status or refusal text.
|
||||
pub status: String,
|
||||
/// Draft for new/renamed project labels.
|
||||
pub label: String,
|
||||
/// Draft path for registering a project.
|
||||
pub path: String,
|
||||
/// Case-insensitive row search.
|
||||
pub search: String,
|
||||
/// Whether archived rows are included.
|
||||
pub show_archived: bool,
|
||||
/// Sort by label when true, otherwise recent-first.
|
||||
pub sort_by_label: bool,
|
||||
/// Controller operation in flight; disables mutations in this panel.
|
||||
pub busy: bool,
|
||||
}
|
||||
|
||||
/// One explicit project-library intent for the app controller.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum ProjectRequest {
|
||||
/// Create a new project with the supplied label.
|
||||
New {
|
||||
#[doc = "User-visible project label."]
|
||||
label: String,
|
||||
},
|
||||
/// Open a catalogue row.
|
||||
Open {
|
||||
#[doc = "Catalogue identity."]
|
||||
id: u64,
|
||||
},
|
||||
/// Duplicate a row under a new label.
|
||||
Duplicate {
|
||||
#[doc = "Catalogue identity."]
|
||||
id: u64,
|
||||
#[doc = "User-visible project label."]
|
||||
label: String,
|
||||
},
|
||||
/// Rename a row.
|
||||
Rename {
|
||||
#[doc = "Catalogue identity."]
|
||||
id: u64,
|
||||
#[doc = "User-visible project label."]
|
||||
label: String,
|
||||
},
|
||||
/// Change its favourite status.
|
||||
Favourite {
|
||||
#[doc = "Catalogue identity."]
|
||||
id: u64,
|
||||
#[doc = "Requested flag value."]
|
||||
value: bool,
|
||||
},
|
||||
/// Change its archived status without deleting files.
|
||||
Archive {
|
||||
#[doc = "Catalogue identity."]
|
||||
id: u64,
|
||||
#[doc = "Requested flag value."]
|
||||
value: bool,
|
||||
},
|
||||
/// Attach a newly discovered path to a missing row.
|
||||
Locate {
|
||||
#[doc = "Catalogue identity."]
|
||||
id: u64,
|
||||
#[doc = "Native document path."]
|
||||
path: String,
|
||||
},
|
||||
/// Remove the catalogue row only.
|
||||
Remove {
|
||||
#[doc = "Catalogue identity."]
|
||||
id: u64,
|
||||
},
|
||||
/// Register an existing native project path.
|
||||
Register {
|
||||
#[doc = "Native document path."]
|
||||
path: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn record(state: &mut ShellState, label: impl Into<String>, response: &egui::Response) {
|
||||
if response.rect.is_positive() {
|
||||
state.body_tool_controls.push(BodyToolControlRect {
|
||||
control: label.into(),
|
||||
rect: response.rect,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn request(state: &mut ShellState, value: ProjectRequest) {
|
||||
state.project_request = Some(value);
|
||||
}
|
||||
|
||||
fn shown_rows(state: &ProjectLibraryState) -> Vec<ProjectRow> {
|
||||
let query = state.search.trim().to_lowercase();
|
||||
let mut rows: Vec<_> = state
|
||||
.rows
|
||||
.iter()
|
||||
.filter(|row| state.show_archived || !row.archived)
|
||||
.filter(|row| {
|
||||
query.is_empty()
|
||||
|| row.label.to_lowercase().contains(&query)
|
||||
|| row.path.to_lowercase().contains(&query)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
if state.sort_by_label {
|
||||
rows.sort_by_key(|row| (row.label.to_lowercase(), row.id));
|
||||
} else {
|
||||
rows.sort_by_key(|row| (std::cmp::Reverse(row.last_opened), row.id));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// Paint the project library. All app effects are returned through
|
||||
/// `state.project_request`; the caller owns lifecycle and native dialogs.
|
||||
pub fn panel(ui: &mut egui::Ui, state: &mut ShellState) {
|
||||
if !state.projects.open {
|
||||
return;
|
||||
}
|
||||
let mut open = state.projects.open;
|
||||
egui::Window::new("Projects")
|
||||
.id(egui::Id::new("project-library"))
|
||||
.open(&mut open)
|
||||
.resizable(true)
|
||||
.show(ui.ctx(), |ui| {
|
||||
ui.set_min_width(420.0);
|
||||
let close = ui.button("Close projects");
|
||||
record(state, "project:close", &close);
|
||||
if close.clicked() {
|
||||
state.projects.open = false;
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Search");
|
||||
let hit = ui.add(
|
||||
egui::TextEdit::singleline(&mut state.projects.search).desired_width(140.0),
|
||||
);
|
||||
record(state, "project:search", &hit);
|
||||
let archived = ui.checkbox(&mut state.projects.show_archived, "archived");
|
||||
record(state, "project:show archived", &archived);
|
||||
let sort = ui.checkbox(&mut state.projects.sort_by_label, "A–Z");
|
||||
record(state, "project:sort label", &sort);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
let label = ui.add(
|
||||
egui::TextEdit::singleline(&mut state.projects.label).desired_width(120.0),
|
||||
);
|
||||
record(state, "project:new label", &label);
|
||||
let new = ui.add_enabled(
|
||||
!state.projects.busy && !state.projects.label.trim().is_empty(),
|
||||
egui::Button::new("New"),
|
||||
);
|
||||
record(state, "project:new", &new);
|
||||
if new.clicked() {
|
||||
request(
|
||||
state,
|
||||
ProjectRequest::New {
|
||||
label: state.projects.label.trim().to_owned(),
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
let path = ui
|
||||
.add(egui::TextEdit::singleline(&mut state.projects.path).desired_width(220.0));
|
||||
record(state, "project:register path", &path);
|
||||
let register = ui.add_enabled(
|
||||
!state.projects.busy && !state.projects.path.trim().is_empty(),
|
||||
egui::Button::new("Register"),
|
||||
);
|
||||
record(state, "project:register", ®ister);
|
||||
if register.clicked() {
|
||||
request(
|
||||
state,
|
||||
ProjectRequest::Register {
|
||||
path: state.projects.path.trim().to_owned(),
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
if !state.projects.status.is_empty() {
|
||||
ui.label(&state.projects.status);
|
||||
}
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(420.0)
|
||||
.show(ui, |ui| {
|
||||
for row in shown_rows(&state.projects) {
|
||||
ui.push_id(row.id, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let open = ui.add_enabled(
|
||||
!state.projects.busy,
|
||||
egui::Button::new(&row.label),
|
||||
);
|
||||
record(state, format!("project:{}:open", row.id), &open);
|
||||
if open.clicked() {
|
||||
request(state, ProjectRequest::Open { id: row.id });
|
||||
}
|
||||
let mark = ui.button(if row.favourite { "★" } else { "☆" });
|
||||
record(state, format!("project:{}:favourite", row.id), &mark);
|
||||
if mark.clicked() && !state.projects.busy {
|
||||
request(
|
||||
state,
|
||||
ProjectRequest::Favourite {
|
||||
id: row.id,
|
||||
value: !row.favourite,
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(if row.missing { "missing" } else { &row.path });
|
||||
let archive = ui.add_enabled(
|
||||
!state.projects.busy,
|
||||
egui::Button::new(if row.archived {
|
||||
"Unarchive"
|
||||
} else {
|
||||
"Archive"
|
||||
}),
|
||||
);
|
||||
record(state, format!("project:{}:archive", row.id), &archive);
|
||||
if archive.clicked() {
|
||||
request(
|
||||
state,
|
||||
ProjectRequest::Archive {
|
||||
id: row.id,
|
||||
value: !row.archived,
|
||||
},
|
||||
);
|
||||
}
|
||||
let remove = ui
|
||||
.add_enabled(!state.projects.busy, egui::Button::new("Remove"));
|
||||
record(state, format!("project:{}:remove", row.id), &remove);
|
||||
if remove.clicked() {
|
||||
request(state, ProjectRequest::Remove { id: row.id });
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if row.missing {
|
||||
let locate = ui.add_enabled(
|
||||
!state.projects.busy,
|
||||
egui::Button::new("Locate…"),
|
||||
);
|
||||
record(state, format!("project:{}:locate", row.id), &locate);
|
||||
if locate.clicked() {
|
||||
request(
|
||||
state,
|
||||
ProjectRequest::Locate {
|
||||
id: row.id,
|
||||
path: state.projects.path.trim().to_owned(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
let duplicate = ui.add_enabled(
|
||||
!state.projects.busy && !state.projects.label.trim().is_empty(),
|
||||
egui::Button::new("Duplicate"),
|
||||
);
|
||||
record(state, format!("project:{}:duplicate", row.id), &duplicate);
|
||||
if duplicate.clicked() {
|
||||
request(
|
||||
state,
|
||||
ProjectRequest::Duplicate {
|
||||
id: row.id,
|
||||
label: state.projects.label.trim().to_owned(),
|
||||
},
|
||||
);
|
||||
}
|
||||
let rename = ui.add_enabled(
|
||||
!state.projects.busy && !state.projects.label.trim().is_empty(),
|
||||
egui::Button::new("Rename"),
|
||||
);
|
||||
record(state, format!("project:{}:rename", row.id), &rename);
|
||||
if rename.clicked() {
|
||||
request(
|
||||
state,
|
||||
ProjectRequest::Rename {
|
||||
id: row.id,
|
||||
label: state.projects.label.trim().to_owned(),
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
state.projects.open = open && state.projects.open;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use super::*;
|
||||
#[must_use]
|
||||
pub fn label_of(action: Action) -> &'static str {
|
||||
match action {
|
||||
Action::Projects => "Projects",
|
||||
Action::EditOrganisation => "Organise",
|
||||
Action::JoinBodies => "Join bodies",
|
||||
Action::BeginBodyMove => "Move body",
|
||||
|
||||
@@ -319,6 +319,8 @@ pub enum Action {
|
||||
Variables,
|
||||
/// Native files, recents and explicit recovery choices.
|
||||
Files,
|
||||
/// Open the local project library.
|
||||
Projects,
|
||||
/// Choose a native document to open.
|
||||
ChooseOpenDocument,
|
||||
/// Choose a native Save As destination.
|
||||
|
||||
@@ -19,9 +19,14 @@ Focused evidence:
|
||||
|
||||
The broad workspace/CLI/recovery/driven gate is reserved for final programme integration as requested. Existing results are not claimed rerun here. Broader manual usability and all six programme batches are not complete.
|
||||
|
||||
## Batch 2: local project library
|
||||
|
||||
Implemented a local list dashboard, app-managed empty projects, registration by path, independent acknowledged-state duplication, labels, favourites, archive, search/sort and missing-file Locate/Remove. Switching reuses Save/Discard/Cancel. Catalogue IO runs off the UI thread and uses a cross-process lock and atomic publication; corruption does not block native Open/recovery. New uses the supervised native pair protocol and refuses existing destinations under its lock. Project switches hold editing until acknowledged.
|
||||
|
||||
Focused evidence: 15 matching app tests passed in 0.05 seconds (including catalogue metadata, missing files, corrupt catalogue and duplicate independence); strict app-library clippy passed in 4.08 seconds. Release worker/driver build took 12.76 seconds. `project-library.json` passed 68 steps / 161 frames deterministically across two processes, preserving 18,000 mm³ geometry. Independent source review confirmed fixes for destination races, interrupted creation classification, repair-state duplication and concurrent catalogue updates. Concurrent writers and interrupted New publication were source-reviewed, not separately driven.
|
||||
|
||||
## Remaining batches
|
||||
|
||||
2. Local project library: isolated catalogue and dashboard UI drafts exist; controller, New/Open/Duplicate lifecycle and acceptance pending.
|
||||
3. Unified Join/Cut/Intersect retention and independent XYZ scaling.
|
||||
4. Reference-driven Align and native planar Replace Face.
|
||||
5. Editable cubic control-point splines, identity-preserving edits and supported profiles.
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
{
|
||||
"name": "project-library-controls",
|
||||
"notes": [
|
||||
"The starter is the built-in 40 x 30 x 15 mm block, volume 18000 mm^3.",
|
||||
"The original is saved first; body 11 is renamed through its rendered name field without saving.",
|
||||
"Project duplicate must snapshot the unsaved authored body name and remain independent after the original is saved."
|
||||
],
|
||||
"size": [
|
||||
1600,
|
||||
1000
|
||||
],
|
||||
"camera": {
|
||||
"target": [
|
||||
20.0,
|
||||
15.0,
|
||||
7.5
|
||||
],
|
||||
"distance": 120.0
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"step": "wait_idle"
|
||||
},
|
||||
{
|
||||
"step": "expect_no_error"
|
||||
},
|
||||
{
|
||||
"step": "click",
|
||||
"at": "text:untitled"
|
||||
},
|
||||
{
|
||||
"step": "type_path",
|
||||
"path": "{out}/original.vernier"
|
||||
},
|
||||
{
|
||||
"step": "click",
|
||||
"at": "text:save"
|
||||
},
|
||||
{
|
||||
"step": "wait_idle"
|
||||
},
|
||||
{
|
||||
"step": "click",
|
||||
"at": {
|
||||
"by": "body",
|
||||
"name": "body 1",
|
||||
"control": "name"
|
||||
}
|
||||
},
|
||||
{
|
||||
"step": "key",
|
||||
"key": "ctrl+a"
|
||||
},
|
||||
{
|
||||
"step": "type",
|
||||
"text": "Unsaved variant"
|
||||
},
|
||||
{
|
||||
"step": "key",
|
||||
"key": "Enter"
|
||||
},
|
||||
{
|
||||
"step": "wait_idle"
|
||||
},
|
||||
{
|
||||
"step": "click",
|
||||
"at": "text:$"
|
||||
},
|
||||
{
|
||||
"step": "frames",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"step": "type",
|
||||
"text": "projects"
|
||||
},
|
||||
{
|
||||
"step": "key",
|
||||
"key": "Enter"
|
||||
},
|
||||
{
|
||||
"step": "frames",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"step": "click",
|
||||
"at": "body-tool:project:new label"
|
||||
},
|
||||
{
|
||||
"step": "type",
|
||||
"text": "Copy"
|
||||
},
|
||||
{
|
||||
"step": "click",
|
||||
"at": "body-tool:project:1:duplicate"
|
||||
},
|
||||
{
|
||||
"step": "wait_idle"
|
||||
},
|
||||
{
|
||||
"step": "expect_document",
|
||||
"path": "{out}/projects/project-000001/document.vernier",
|
||||
"pointer": "/state/bodies/11/name",
|
||||
"equals": "Unsaved variant"
|
||||
},
|
||||
{
|
||||
"step": "click",
|
||||
"at": "body-tool:project:1:favourite"
|
||||
},
|
||||
{
|
||||
"step": "wait_idle"
|
||||
},
|
||||
{
|
||||
"step": "click",
|
||||
"at": "body-tool:project:1:archive"
|
||||
},
|
||||
{
|
||||
"step": "wait_idle"
|
||||
},
|
||||
{
|
||||
"step": "click",
|
||||
"at": "body-tool:project:show archived"
|
||||
},
|
||||
{
|
||||
"step": "wait_idle"
|
||||
},
|
||||
{
|
||||
"step": "click",
|
||||
"at": "body-tool:project:1:open"
|
||||
},
|
||||
{
|
||||
"step": "wait_idle"
|
||||
},
|
||||
{
|
||||
"step": "click",
|
||||
"at": "text:Cancel switch"
|
||||
},
|
||||
{
|
||||
"step": "wait_idle"
|
||||
},
|
||||
{
|
||||
"step": "wait_idle"
|
||||
},
|
||||
{
|
||||
"step": "expect_document",
|
||||
"path": "{out}/projects/project-000001/document.vernier",
|
||||
"pointer": "/state/bodies/11/name",
|
||||
"equals": "Unsaved variant"
|
||||
},
|
||||
{
|
||||
"step": "click",
|
||||
"at": "body-tool:project:close"
|
||||
},
|
||||
{
|
||||
"step": "click",
|
||||
"at": {
|
||||
"by": "body",
|
||||
"name": "Unsaved variant",
|
||||
"control": "name"
|
||||
}
|
||||
},
|
||||
{
|
||||
"step": "key",
|
||||
"key": "ctrl+a"
|
||||
},
|
||||
{
|
||||
"step": "type",
|
||||
"text": "Original changed"
|
||||
},
|
||||
{
|
||||
"step": "key",
|
||||
"key": "Enter"
|
||||
},
|
||||
{
|
||||
"step": "wait_idle"
|
||||
},
|
||||
{
|
||||
"step": "key",
|
||||
"key": "ctrl+s"
|
||||
},
|
||||
{
|
||||
"step": "wait_idle"
|
||||
},
|
||||
{
|
||||
"step": "expect_document",
|
||||
"path": "{out}/original.vernier",
|
||||
"pointer": "/state/bodies/11/name",
|
||||
"equals": "Original changed"
|
||||
},
|
||||
{
|
||||
"step": "export_step",
|
||||
"path": "{out}/original.step"
|
||||
},
|
||||
{
|
||||
"step": "wait_idle"
|
||||
},
|
||||
{
|
||||
"step": "expect_step",
|
||||
"path": "{out}/original.step",
|
||||
"volume": 18000.0,
|
||||
"solids": 1,
|
||||
"faces": 6,
|
||||
"tol": 1e-09
|
||||
},
|
||||
{
|
||||
"step": "click",
|
||||
"at": {
|
||||
"by": "body",
|
||||
"name": "Original changed",
|
||||
"control": "name"
|
||||
}
|
||||
},
|
||||
{
|
||||
"step": "key",
|
||||
"key": "ctrl+a"
|
||||
},
|
||||
{
|
||||
"step": "type",
|
||||
"text": "Dirty before new"
|
||||
},
|
||||
{
|
||||
"step": "key",
|
||||
"key": "Enter"
|
||||
},
|
||||
{
|
||||
"step": "wait_idle"
|
||||
},
|
||||
{
|
||||
"step": "click",
|
||||
"at": "text:$"
|
||||
},
|
||||
{
|
||||
"step": "frames",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"step": "type",
|
||||
"text": "projects"
|
||||
},
|
||||
{
|
||||
"step": "key",
|
||||
"key": "Enter"
|
||||
},
|
||||
{
|
||||
"step": "frames",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"step": "click",
|
||||
"at": "body-tool:project:new label"
|
||||
},
|
||||
{
|
||||
"step": "type",
|
||||
"text": "Empty"
|
||||
},
|
||||
{
|
||||
"step": "click",
|
||||
"at": "body-tool:project:new"
|
||||
},
|
||||
{
|
||||
"step": "wait_idle"
|
||||
},
|
||||
{
|
||||
"step": "click",
|
||||
"at": "text:Cancel switch"
|
||||
},
|
||||
{
|
||||
"step": "wait_idle"
|
||||
},
|
||||
{
|
||||
"step": "expect_feature_count",
|
||||
"is": 2
|
||||
},
|
||||
{
|
||||
"step": "click",
|
||||
"at": "body-tool:project:new"
|
||||
},
|
||||
{
|
||||
"step": "wait_idle"
|
||||
},
|
||||
{
|
||||
"step": "click",
|
||||
"at": "text:Save and switch"
|
||||
},
|
||||
{
|
||||
"step": "wait_idle"
|
||||
},
|
||||
{
|
||||
"step": "expect_feature_count",
|
||||
"is": 0
|
||||
},
|
||||
{
|
||||
"step": "expect_no_error"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user