feat: add local project library and safe document switching

This commit is contained in:
2026-09-09 17:10:51 +02:00
parent ca1226b461
commit f1b0fb841d
22 changed files with 1636 additions and 9 deletions
+28 -5
View File
@@ -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,
+3
View File
@@ -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());
+3
View File
@@ -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()
+19 -1
View File
@@ -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);
}
}
+1
View File
@@ -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 { .. }
);
+384
View File
@@ -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()
}
}
+490
View File
@@ -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 = &copy.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();
}
}
+10
View File
@@ -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,
+4 -1
View File
@@ -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;
+28
View File
@@ -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)
}
+1
View File
@@ -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)
{
+9
View File
@@ -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,
+2 -1
View File
@@ -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
+10
View File
@@ -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};
+319
View File
@@ -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", &register);
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;
}
+1
View File
@@ -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",
+2
View File
@@ -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.
+6 -1
View File
@@ -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.
+296
View File
@@ -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"
}
]
}