493 lines
17 KiB
Rust
493 lines
17 KiB
Rust
//! Start workspace and bounded, user-local recent-file metadata.
|
|
//! Events request the application's existing guarded actions; this module never
|
|
//! opens, creates, saves or modifies a model document.
|
|
use crate::{style, views::short};
|
|
use iced::{
|
|
Element, Fill,
|
|
widget::{button, column, container, row, scrollable, text},
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::{
|
|
fs::{self, File},
|
|
io::{Read, Write},
|
|
path::{Path, PathBuf},
|
|
};
|
|
|
|
const MAX_RECENTS: usize = 12;
|
|
const MAX_STORE_BYTES: u64 = 256 * 1024;
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum Event {
|
|
New,
|
|
Open,
|
|
Example,
|
|
Walkthrough,
|
|
Guide,
|
|
Resume,
|
|
OpenRecent(PathBuf),
|
|
Forget(PathBuf),
|
|
Refresh,
|
|
Recover(oa_core::Id),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct Entry {
|
|
pub path: PathBuf,
|
|
pub name: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum Availability {
|
|
Available,
|
|
Missing,
|
|
Unavailable(String),
|
|
}
|
|
|
|
/// Resolve the packaged example independently of the process working directory.
|
|
pub(crate) fn walkthrough_path() -> Result<PathBuf, String> {
|
|
let executable = std::env::current_exe().map_err(|error| error.to_string())?;
|
|
let development =
|
|
cfg!(debug_assertions).then(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."));
|
|
resolve_walkthrough(&executable, development.as_deref())
|
|
}
|
|
|
|
fn resolve_walkthrough(executable: &Path, development: Option<&Path>) -> Result<PathBuf, String> {
|
|
let relative = Path::new("examples/release1/project.arcadia");
|
|
let packaged = executable
|
|
.parent()
|
|
.ok_or("Application directory is unavailable.")?
|
|
.join(relative);
|
|
if packaged.is_file() {
|
|
return Ok(packaged);
|
|
}
|
|
if let Some(root) = development {
|
|
let source = root.join(relative);
|
|
if source.is_file() {
|
|
return Ok(source);
|
|
}
|
|
}
|
|
Err(format!(
|
|
"The water-pressure walkthrough is missing. Restore the complete examples/release1 folder beside the application, then try again. Expected {}.",
|
|
packaged.display()
|
|
))
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Recent {
|
|
pub entry: Entry,
|
|
pub availability: Availability,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
pub struct State {
|
|
pub recent: Vec<Recent>,
|
|
pub notice: Option<String>,
|
|
store: Option<PathBuf>,
|
|
/// Unknown/corrupt metadata is retained on disk until repaired externally.
|
|
/// Remember/forget still work for this application session.
|
|
read_only: bool,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct Stored {
|
|
version: u32,
|
|
entries: Vec<Entry>,
|
|
}
|
|
|
|
/// User-local application metadata directory, shared with recovery facilities.
|
|
/// No current-directory fallback: missing configuration must not write into a
|
|
/// model checkout. An explicitly configured relative XDG location is invalid.
|
|
pub fn data_directory() -> Option<PathBuf> {
|
|
if cfg!(target_os = "windows") {
|
|
std::env::var_os("LOCALAPPDATA")
|
|
.map(PathBuf::from)
|
|
.filter(|path| path.is_absolute())
|
|
.map(|path| path.join("ArcadiaStudio"))
|
|
} else {
|
|
std::env::var_os("XDG_DATA_HOME")
|
|
.map(PathBuf::from)
|
|
.filter(|path| path.is_absolute())
|
|
.or_else(|| {
|
|
std::env::var_os("HOME")
|
|
.map(PathBuf::from)
|
|
.filter(|path| path.is_absolute())
|
|
.map(|path| path.join(".local/share"))
|
|
})
|
|
.map(|path| path.join("arcadia-studio"))
|
|
}
|
|
}
|
|
|
|
impl State {
|
|
pub fn load_default() -> Self {
|
|
match data_directory() {
|
|
Some(directory) => Self::load(directory.join("recent-models.json")),
|
|
None => Self {
|
|
notice: Some("Recent models are available for this session; the user data directory is unavailable.".into()),
|
|
..Self::default()
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn load(store: PathBuf) -> Self {
|
|
let mut state = Self {
|
|
store: Some(store.clone()),
|
|
..Self::default()
|
|
};
|
|
match read_store(&store) {
|
|
Ok(entries) => {
|
|
state.recent = entries
|
|
.into_iter()
|
|
.map(|entry| Recent {
|
|
availability: availability(&entry.path),
|
|
entry,
|
|
})
|
|
.collect();
|
|
}
|
|
Err(error) => {
|
|
state.read_only = true;
|
|
state.notice = Some(format!(
|
|
"Recent model history could not be loaded: {error}. Your model files are unaffected; this session's history will remain in memory."
|
|
));
|
|
}
|
|
}
|
|
state
|
|
}
|
|
|
|
/// Call only after the application's existing open/save action succeeds.
|
|
/// Canonical local file identities prevent duplicate entries for symlinks
|
|
/// and alternate spellings. Unavailable files are never newly remembered.
|
|
pub fn remember(&mut self, path: &Path, name: &str) -> Result<(), String> {
|
|
let path = fs::canonicalize(path).map_err(|error| error.to_string())?;
|
|
if !path.is_file() {
|
|
return Err("Recent models must reference a file.".into());
|
|
}
|
|
self.recent
|
|
.retain(|recent| !same_path(&recent.entry.path, &path));
|
|
self.recent.insert(
|
|
0,
|
|
Recent {
|
|
entry: Entry {
|
|
path,
|
|
name: short(name, 160),
|
|
},
|
|
availability: Availability::Available,
|
|
},
|
|
);
|
|
self.recent.truncate(MAX_RECENTS);
|
|
self.persist()
|
|
}
|
|
|
|
/// Removing history only removes metadata; it never deletes a model file.
|
|
pub fn forget(&mut self, path: &Path) -> Result<(), String> {
|
|
self.recent
|
|
.retain(|recent| !same_path(&recent.entry.path, path));
|
|
self.persist()
|
|
}
|
|
|
|
pub fn refresh(&mut self) {
|
|
for recent in &mut self.recent {
|
|
recent.availability = availability(&recent.entry.path);
|
|
}
|
|
}
|
|
|
|
fn persist(&mut self) -> Result<(), String> {
|
|
let Some(path) = &self.store else {
|
|
return Ok(());
|
|
};
|
|
if self.read_only {
|
|
return Err("Recent history is retained in memory because its existing metadata could not be read.".into());
|
|
}
|
|
let entries = self
|
|
.recent
|
|
.iter()
|
|
.map(|recent| recent.entry.clone())
|
|
.collect();
|
|
match write_store(path, entries) {
|
|
Ok(()) => {
|
|
self.notice = None;
|
|
Ok(())
|
|
}
|
|
Err(error) => {
|
|
let message = format!(
|
|
"Recent model history could not be saved: {error}. It remains available for this session."
|
|
);
|
|
self.notice = Some(message.clone());
|
|
Err(message)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn same_path(a: &Path, b: &Path) -> bool {
|
|
#[cfg(windows)]
|
|
{
|
|
a.to_string_lossy()
|
|
.eq_ignore_ascii_case(&b.to_string_lossy())
|
|
}
|
|
#[cfg(not(windows))]
|
|
{
|
|
a == b
|
|
}
|
|
}
|
|
|
|
fn availability(path: &Path) -> Availability {
|
|
match fs::metadata(path) {
|
|
Ok(metadata) if metadata.is_file() => Availability::Available,
|
|
Ok(_) => Availability::Unavailable("The path is not a file".into()),
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Availability::Missing,
|
|
Err(error) => Availability::Unavailable(error.to_string()),
|
|
}
|
|
}
|
|
|
|
fn read_store(path: &Path) -> Result<Vec<Entry>, String> {
|
|
let file = match File::open(path) {
|
|
Ok(file) => file,
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
|
|
Err(error) => return Err(error.to_string()),
|
|
};
|
|
let mut bytes = Vec::new();
|
|
file.take(MAX_STORE_BYTES + 1)
|
|
.read_to_end(&mut bytes)
|
|
.map_err(|e| e.to_string())?;
|
|
if bytes.len() as u64 > MAX_STORE_BYTES {
|
|
return Err("history file exceeds its size limit".into());
|
|
}
|
|
let stored: Stored = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
|
|
if stored.version != 1 {
|
|
return Err("unsupported history version".into());
|
|
}
|
|
if stored.entries.len() > MAX_RECENTS
|
|
|| stored
|
|
.entries
|
|
.iter()
|
|
.any(|entry| !entry.path.is_absolute() || entry.name.chars().count() > 160)
|
|
{
|
|
return Err("history contains invalid entries".into());
|
|
}
|
|
let mut entries: Vec<Entry> = Vec::new();
|
|
for entry in stored.entries {
|
|
if !entries
|
|
.iter()
|
|
.any(|prior| same_path(&prior.path, &entry.path))
|
|
{
|
|
entries.push(entry);
|
|
}
|
|
}
|
|
Ok(entries)
|
|
}
|
|
|
|
fn write_store(path: &Path, entries: Vec<Entry>) -> Result<(), String> {
|
|
let parent = path
|
|
.parent()
|
|
.filter(|path| !path.as_os_str().is_empty())
|
|
.ok_or_else(|| "history path requires a parent directory".to_string())?;
|
|
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
|
let bytes = serde_json::to_vec_pretty(&Stored {
|
|
version: 1,
|
|
entries,
|
|
})
|
|
.map_err(|e| e.to_string())?;
|
|
if bytes.len() as u64 > MAX_STORE_BYTES {
|
|
return Err("history exceeds its size limit".into());
|
|
}
|
|
let mut temporary = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?;
|
|
temporary.write_all(&bytes).map_err(|e| e.to_string())?;
|
|
temporary.as_file().sync_all().map_err(|e| e.to_string())?;
|
|
temporary.persist(path).map_err(|e| e.error.to_string())?;
|
|
#[cfg(unix)]
|
|
File::open(parent)
|
|
.and_then(|directory| directory.sync_all())
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
fn action<'a>(label: &str, event: Event) -> iced::widget::Button<'a, Event> {
|
|
button(text(label.to_owned()).size(13))
|
|
.padding([10, 14])
|
|
.on_press(event)
|
|
}
|
|
|
|
fn recovery_age(modified: u64) -> String {
|
|
let now = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs();
|
|
let elapsed = now.saturating_sub(modified);
|
|
let (count, unit) = match elapsed {
|
|
0..60 => return "Retained just now".into(),
|
|
60..3600 => (elapsed / 60, "minute"),
|
|
3600..86400 => (elapsed / 3600, "hour"),
|
|
_ => (elapsed / 86400, "day"),
|
|
};
|
|
format!(
|
|
"Retained {count} {unit}{} ago",
|
|
if count == 1 { "" } else { "s" }
|
|
)
|
|
}
|
|
|
|
pub fn view<'a>(
|
|
state: &'a State,
|
|
current: Option<(&str, bool)>,
|
|
recovery: &'a crate::recovery::State,
|
|
) -> Element<'a, Event> {
|
|
let mut content = column![
|
|
text("Your systems engineering workspace").size(28),
|
|
text("Develop operational needs and system architecture through connected diagrams, source and engineering evidence.").size(14).color(style::MUTED),
|
|
row![
|
|
action("New model", Event::New),
|
|
action("Open model…", Event::Open).style(style::nav),
|
|
action("Explore rescue example", Event::Example).style(style::nav),
|
|
].spacing(12),
|
|
].spacing(18).max_width(940);
|
|
content = content.push(container(column![
|
|
text("Restore safe water pressure").size(18),
|
|
text("Follow an OA → SA walkthrough with processes, scenarios, states and evidence. Copy the whole examples/release1 folder and open its project.arcadia before editing; the tutorial is README.md. Save as copies model source only, so also copy evidence/ to the new folder if you use it.").size(13),
|
|
action("Open water-pressure walkthrough", Event::Walkthrough).style(style::nav),
|
|
].spacing(10)).padding(20).style(style::subtle));
|
|
if let Some(warning) = &recovery.warning {
|
|
content = content.push(text(warning).size(12).color(style::MUTED));
|
|
}
|
|
if !recovery.entries.is_empty() {
|
|
content = content.push(text("Recover unsaved work").size(20));
|
|
content = content.push(text("Edits retained from an earlier session. Recovery opens an unsaved copy; choose a destination when saving. Undo history is not restored.").size(12).color(style::MUTED));
|
|
for entry in &recovery.entries {
|
|
content = content.push(
|
|
container(
|
|
row![
|
|
column![
|
|
text(short(&entry.name, 80)).size(15),
|
|
text(
|
|
entry
|
|
.origin
|
|
.as_ref()
|
|
.map(|p| p.display().to_string())
|
|
.unwrap_or_else(|| "Unsaved model".into())
|
|
)
|
|
.size(11)
|
|
.color(style::MUTED),
|
|
text(recovery_age(entry.modified))
|
|
.size(11)
|
|
.color(style::MUTED),
|
|
]
|
|
.spacing(5)
|
|
.width(Fill),
|
|
action("Recover", Event::Recover(entry.id)),
|
|
]
|
|
.spacing(12)
|
|
.align_y(iced::Center),
|
|
)
|
|
.padding(14)
|
|
.style(style::card),
|
|
);
|
|
}
|
|
}
|
|
if let Some((name, dirty)) = current {
|
|
content = content.push(
|
|
container(
|
|
row![
|
|
column![
|
|
text(format!("Current model · {}", short(name, 64))).size(16),
|
|
text(if dirty {
|
|
"You have unsaved edits. They remain available while Home is open."
|
|
} else {
|
|
"Continue working with the model already open in this window."
|
|
})
|
|
.size(12)
|
|
.color(style::MUTED)
|
|
]
|
|
.spacing(5)
|
|
.width(Fill),
|
|
action("Continue editing", Event::Resume),
|
|
]
|
|
.spacing(12)
|
|
.align_y(iced::Center),
|
|
)
|
|
.padding(16)
|
|
.style(style::card),
|
|
);
|
|
}
|
|
content = content.push(
|
|
row![
|
|
text("Recent models").size(20).width(Fill),
|
|
action("Refresh", Event::Refresh).style(style::nav),
|
|
]
|
|
.align_y(iced::Center),
|
|
);
|
|
if let Some(notice) = &state.notice {
|
|
content = content.push(text(notice).size(12).color(style::MUTED));
|
|
}
|
|
if state.recent.is_empty() {
|
|
content = content.push(container(column![
|
|
text("Models you open or save will appear here.").size(14),
|
|
text("Open an .arcadia project entry or import a .json model. Included source files load together.").size(12).color(style::MUTED),
|
|
].spacing(6)).padding(20).style(style::subtle));
|
|
}
|
|
for recent in &state.recent {
|
|
let entry = &recent.entry;
|
|
let name = if entry.name.is_empty() {
|
|
entry
|
|
.path
|
|
.file_name()
|
|
.unwrap_or_default()
|
|
.to_string_lossy()
|
|
.into_owned()
|
|
} else {
|
|
entry.name.clone()
|
|
};
|
|
let mut description = column![
|
|
text(short(&name, 85)).size(15),
|
|
text(entry.path.display().to_string())
|
|
.size(11)
|
|
.color(style::MUTED),
|
|
]
|
|
.spacing(5)
|
|
.width(Fill);
|
|
let open = match &recent.availability {
|
|
Availability::Available => action("Open", Event::OpenRecent(entry.path.clone())),
|
|
availability => {
|
|
description = description.push(
|
|
text(match availability {
|
|
Availability::Missing => {
|
|
"File not found. Locate it after moving or reconnecting its drive."
|
|
.to_string()
|
|
}
|
|
Availability::Unavailable(reason) => {
|
|
format!("Unavailable: {}", short(reason, 120))
|
|
}
|
|
Availability::Available => unreachable!(),
|
|
})
|
|
.size(12)
|
|
.color(style::MUTED),
|
|
);
|
|
action("Locate…", Event::Open)
|
|
}
|
|
};
|
|
content = content.push(
|
|
container(
|
|
row![
|
|
description,
|
|
open,
|
|
action("Remove", Event::Forget(entry.path.clone())).style(style::nav)
|
|
]
|
|
.spacing(10)
|
|
.align_y(iced::Center),
|
|
)
|
|
.padding(14)
|
|
.style(style::card),
|
|
);
|
|
}
|
|
content = content.push(container(column![
|
|
text("Build a model you can explain").size(18),
|
|
text("Start with operational actors and activities, trace the needs into system functions, then describe behavior and interfaces. The engineering guide includes concepts, examples and editing workflows.").size(13),
|
|
action("Open engineering guide", Event::Guide).style(style::nav),
|
|
].spacing(10)).padding(20).style(style::subtle));
|
|
scrollable(container(content).padding(28).width(Fill).center_x(Fill))
|
|
.height(Fill)
|
|
.into()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|