Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-app/src/persist.rs

//! **`persist` — the layout survives the process.**
//!
//! This is the gap every comparable tool had filled and facett had not: chrome state was
//! *process-lifetime* because [`Router`](crate::Router) was not serde, so a user who
//! minimised a pane, dragged a divider and swapped an area's editor got the authored
//! defaults back on the next launch. Every one of those actions is now persisted.
//!
//! ## What is stored
//!
//! A [`LayoutSnapshot`] — a schema-versioned envelope around one [`NavState`], which
//! carries:
//! - the **active workspace**,
//! - the **shell ∪ active-workspace body** arrangement: split ratios, minimised flags,
//!   the maximised pane, fold flags, active tab per tab pane, per-area editor bindings,
//! - and every **inactive workspace's parked body**, under
//!   [`NavState::workspaces`](crate::scene::NavState::workspaces).
//!
//! That last line is how a layout tells `main` in workspace *investigate* from `main` in
//! workspace *data*: the flat maps only ever hold the panes actually on screen, and
//! everything else is filed under its workspace name. Robot addresses stay unqualified.
//!
//! What is NOT stored is the *scene* — the pane tree, the menubar, the routes. Those are
//! the app's authored design, they ship with the binary, and writing them into a user
//! file would freeze an upgrade out of ever changing the shell. The snapshot is purely
//! the user's arrangement OF that design, keyed by pane name.
//!
//! ## Where it is stored
//!
//! `<dir>/<app>.layout.json`, where `<dir>` is the first of:
//!
//! | source | typical value |
//! |---|---|
//! | `$FACETT_LAYOUT_DIR` | wherever the operator says (tests, containers, a portable install) |
//! | `$XDG_STATE_HOME/facett` | `~/.local/state/facett` |
//! | `$HOME/.local/state/facett` | the XDG default, spelled out |
//! | `./.facett` | last resort, so it never silently writes nowhere |
//!
//! State, not config: a window arrangement is machine-local, disposable and not
//! something anyone hand-edits or checks in — which is exactly what `XDG_STATE_HOME` is
//! specified for, and why this is not in `$XDG_CONFIG_HOME`.
//!
//! On **wasm** there is no filesystem, so [`LayoutSnapshot::to_json`] /
//! [`LayoutSnapshot::from_json`] are the whole API and the host puts the string in
//! `localStorage`. The file helpers are simply absent there.
//!
//! ## Reconciling with a newer build
//!
//! [`LayoutSnapshot::restore`] runs [`NavState::adopt`], which reconciles the shell, the
//! active body AND every parked workspace against what this build authors — so a layout
//! saved against an older pane tree keeps the arrangement of every pane that still exists and
//! silently drops the rest. A snapshot from an incompatible [`LAYOUT_VERSION`] is
//! refused whole — a half-applied layout is worse than a default one.

use crate::scene::{NavState, Scene};
use serde::{Deserialize, Serialize};

/// The layout schema version. Bump it when [`NavState`] changes shape in a way an older
/// file cannot be read into; older snapshots are then refused, not half-applied.
///
/// **2** — workspaces moved from "one `Scene` each" to "one shell with a spliced body",
/// so the per-workspace keys are stored under
/// [`NavState::workspaces`](crate::scene::NavState::workspaces) rather than as whole
/// scenes. A v1 file cannot be read into that and is dropped.
pub const LAYOUT_VERSION: u32 = 2;

/// The persisted envelope: a schema version, the app it belongs to, and the state.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LayoutSnapshot {
    /// Schema version — see [`LAYOUT_VERSION`].
    pub version: u32,
    /// The app / scene name this layout belongs to.
    pub app: String,
    /// The saved arrangement — shell, active workspace, and every parked body.
    pub nav: NavState,
}

impl LayoutSnapshot {
    /// Snapshot a scene's whole arrangement.
    pub fn of(app: impl Into<String>, nav: &NavState) -> Self {
        Self { version: LAYOUT_VERSION, app: app.into(), nav: nav.clone() }
    }
    /// Snapshot using the scene's own name as the app key.
    pub fn of_scene(scene: &Scene, nav: &NavState) -> Self {
        Self::of(scene.name.clone(), nav)
    }

    /// Is this snapshot readable by this build?
    pub fn is_current(&self) -> bool {
        self.version == LAYOUT_VERSION
    }

    /// **Restore into a live scene**, reconciling with whatever the current build
    /// authored: unknown panes and workspaces dropped, new ones seeded from their
    /// authored defaults, a stale maximise or route cleared, a ratio vector whose child
    /// count changed discarded. Returns the authored defaults ([`NavState::new`]) when
    /// the snapshot is from another schema version or another app — a half-applied
    /// layout is worse than a default one.
    pub fn restore(&self, scene: &Scene) -> NavState {
        if !self.is_current() || self.app != scene.name {
            return NavState::new(scene);
        }
        let mut nav = self.nav.clone();
        nav.adopt(scene);
        nav
    }

    /// The snapshot as JSON — **the whole API on wasm**, where the host writes it to
    /// `localStorage`.
    pub fn to_json(&self) -> String {
        serde_json::to_string_pretty(self).expect("LayoutSnapshot serializes")
    }
    /// Parse a snapshot from JSON. `None` for malformed input (a corrupt layout file
    /// must open the app on its defaults, never fail to open it).
    pub fn from_json(text: &str) -> Option<Self> {
        serde_json::from_str(text).ok()
    }
}

// ── Filesystem (native only — wasm has no fs; use to_json/from_json there) ────────

#[cfg(not(target_arch = "wasm32"))]
mod fs_impl {
    use super::*;
    use std::path::{Path, PathBuf};

    /// The environment override for the layout directory.
    pub const LAYOUT_DIR_ENV: &str = "FACETT_LAYOUT_DIR";

    /// Resolve the layout directory from the three environment inputs — **pure**, so the
    /// precedence is unit-testable without touching the process environment.
    pub fn resolve_dir(override_dir: Option<&str>, xdg_state: Option<&str>, home: Option<&str>) -> PathBuf {
        if let Some(d) = override_dir.filter(|d| !d.is_empty()) {
            return PathBuf::from(d);
        }
        if let Some(d) = xdg_state.filter(|d| !d.is_empty()) {
            return Path::new(d).join("facett");
        }
        if let Some(h) = home.filter(|h| !h.is_empty()) {
            return Path::new(h).join(".local").join("state").join("facett");
        }
        PathBuf::from(".facett")
    }

    /// The directory layouts are stored in for this process.
    pub fn layout_dir() -> PathBuf {
        resolve_dir(
            std::env::var("FACETT_LAYOUT_DIR").ok().as_deref(),
            std::env::var("XDG_STATE_HOME").ok().as_deref(),
            std::env::var("HOME").ok().as_deref(),
        )
    }

    /// The file `app`'s layout is stored in: `<layout_dir>/<app>.layout.json`.
    pub fn layout_path(app: &str) -> PathBuf {
        layout_dir().join(format!("{}.layout.json", sanitize(app)))
    }

    /// An app name is a filename component here, so keep it to one.
    fn sanitize(app: &str) -> String {
        app.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect()
    }

    impl LayoutSnapshot {
        /// Write the snapshot to `dir/<app>.layout.json`, creating `dir`. Returns the
        /// path written.
        pub fn save_to(&self, dir: &Path) -> std::io::Result<PathBuf> {
            std::fs::create_dir_all(dir)?;
            let path = dir.join(format!("{}.layout.json", sanitize(&self.app)));
            std::fs::write(&path, self.to_json())?;
            Ok(path)
        }
        /// Write the snapshot to the resolved [`layout_path`].
        pub fn save(&self) -> std::io::Result<PathBuf> {
            self.save_to(&layout_dir())
        }
        /// Read `app`'s snapshot from `dir`. `None` when absent or unreadable — a
        /// missing or corrupt layout opens the app on its defaults.
        pub fn load_from(dir: &Path, app: &str) -> Option<LayoutSnapshot> {
            let path = dir.join(format!("{}.layout.json", sanitize(app)));
            LayoutSnapshot::from_json(&std::fs::read_to_string(path).ok()?)
        }
        /// Read `app`'s snapshot from the resolved [`layout_dir`].
        pub fn load(app: &str) -> Option<LayoutSnapshot> {
            Self::load_from(&layout_dir(), app)
        }
        /// Delete `app`'s stored layout — the "reset my layout" a host menu hangs off.
        /// Returns true if a file was removed.
        pub fn forget(app: &str) -> bool {
            std::fs::remove_file(layout_path(app)).is_ok()
        }
    }
}

#[cfg(not(target_arch = "wasm32"))]
pub use fs_impl::{LAYOUT_DIR_ENV, layout_dir, layout_path, resolve_dir};

#[cfg(test)]
#[path = "persist_tests.rs"]
mod persist_tests;