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

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;