Skip to main content

termden_core/
state.rs

1//! Persisted session state: which workspaces exist, their harnesses and tile
2//! layouts. Harness processes themselves live in tmux and outlive the app;
3//! this file is what lets a restarted (or hot-reloaded) UI find them again.
4
5use std::collections::HashMap;
6use std::env;
7use std::fs;
8use std::path::PathBuf;
9
10use serde::{Deserialize, Serialize};
11
12use crate::{Activity, Choice, HarnessId, HarnessStatus, LayoutNode, Mail, Workspace, WorkspaceId};
13
14#[derive(Debug, Clone, Default, Serialize, Deserialize)]
15#[serde(default)]
16pub struct AppState {
17    pub workspaces: Vec<Workspace>,
18    pub layouts: HashMap<WorkspaceId, LayoutNode>,
19    pub active: Option<WorkspaceId>,
20    pub focused: Option<HarnessId>,
21    pub mail: Vec<Mail>,
22    pub activity: Vec<Activity>,
23    pub choices: Vec<Choice>,
24}
25
26impl AppState {
27    /// `$TERMDEN_STATE_FILE`, else `$XDG_STATE_HOME/termden/state.json`.
28    pub fn path() -> PathBuf {
29        match env::var("TERMDEN_STATE_FILE") {
30            Ok(path) if !path.is_empty() => PathBuf::from(path),
31            _ => state_dir().join("state.json"),
32        }
33    }
34
35    /// Missing file means a fresh start; a corrupt one is kept aside rather
36    /// than overwritten, so a bad write never silently loses workspaces.
37    pub fn load() -> Option<Self> {
38        let path = Self::path();
39        let text = fs::read_to_string(&path).ok()?;
40        match serde_json::from_str::<Self>(&text) {
41            Ok(mut state) => {
42                // Live statuses are re-derived from the running sessions.
43                for harness in state
44                    .workspaces
45                    .iter_mut()
46                    .flat_map(|w| w.harnesses.iter_mut())
47                {
48                    harness.status = HarnessStatus::Idle;
49                }
50                Some(state)
51            }
52            Err(error) => {
53                let backup = path.with_extension("json.corrupt");
54                eprintln!(
55                    "termden: could not parse {} ({error}); moved to {}",
56                    path.display(),
57                    backup.display()
58                );
59                let _ = fs::rename(&path, backup);
60                None
61            }
62        }
63    }
64
65    /// Atomic write: temp file + rename.
66    pub fn save(&self) -> std::io::Result<()> {
67        let path = Self::path();
68        if let Some(parent) = path.parent() {
69            fs::create_dir_all(parent)?;
70        }
71        let tmp = path.with_extension("json.tmp");
72        let text = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?;
73        fs::write(&tmp, text)?;
74        fs::rename(tmp, path)
75    }
76
77    pub fn next_harness_id(&self) -> HarnessId {
78        // Activity lines remember the harness they are about. Never hand their
79        // id to a newcomer: an old "so-and-so left" line would then be painted
80        // in the new agent's colour.
81        let max = self
82            .workspaces
83            .iter()
84            .flat_map(|w| w.harnesses.iter())
85            .map(|h| h.id.raw())
86            .chain(
87                self.activity
88                    .iter()
89                    .filter_map(|entry| entry.harness.map(|id| id.raw())),
90            )
91            .max()
92            .unwrap_or(0);
93        HarnessId::new(max + 1)
94    }
95
96    pub fn next_workspace_id(&self) -> WorkspaceId {
97        let max = self
98            .workspaces
99            .iter()
100            .map(|w| w.id.raw())
101            .max()
102            .unwrap_or(0);
103        WorkspaceId::new(max + 1)
104    }
105}
106
107pub fn state_dir() -> PathBuf {
108    xdg_dir("XDG_STATE_HOME", ".local/state").join("termden")
109}
110
111/// `$XDG_RUNTIME_DIR/termden`, home of the control socket.
112pub fn runtime_dir() -> PathBuf {
113    match env::var("XDG_RUNTIME_DIR") {
114        Ok(dir) if !dir.is_empty() => PathBuf::from(dir).join("termden"),
115        _ => env::temp_dir().join(format!("termden-{}", env::var("USER").unwrap_or_default())),
116    }
117}
118
119/// Control socket the running app listens on. Overridable for tests.
120pub fn control_socket() -> PathBuf {
121    match env::var("TERMDEN_CONTROL_SOCKET") {
122        Ok(path) if !path.is_empty() => PathBuf::from(path),
123        _ => runtime_dir().join("app.sock"),
124    }
125}
126
127pub(crate) fn xdg_dir(var: &str, fallback: &str) -> PathBuf {
128    if let Ok(dir) = env::var(var) {
129        if !dir.is_empty() {
130            return PathBuf::from(dir);
131        }
132    }
133    match env::var("HOME") {
134        Ok(home) => PathBuf::from(home).join(fallback),
135        Err(_) => PathBuf::from(fallback),
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use crate::{Axis, Harness, HarnessKind};
143
144    #[test]
145    fn round_trips_through_json() {
146        let ws = WorkspaceId::new(1);
147        let mut state = AppState {
148            workspaces: vec![Workspace {
149                id: ws,
150                name: "w".into(),
151                path: "/tmp".into(),
152                accent: 0,
153                expanded: true,
154                harnesses: vec![Harness {
155                    id: HarnessId::new(4),
156                    workspace: ws,
157                    name: "shell".into(),
158                    kind: HarnessKind::Shell,
159                    cwd: ".".into(),
160                    status: HarnessStatus::Working,
161                    subagents: vec![],
162                    unread: 0,
163                    tag: String::new(),
164                    title: String::new(),
165                    pronouns: String::new(),
166                    color: None,
167                    announced: true,
168                    started: true,
169                }],
170            }],
171            active: Some(ws),
172            focused: Some(HarnessId::new(4)),
173            ..Default::default()
174        };
175        state.layouts.insert(
176            ws,
177            LayoutNode::split(
178                Axis::Vertical,
179                0.4,
180                LayoutNode::leaf(HarnessId::new(4)),
181                LayoutNode::leaf(HarnessId::new(5)),
182            ),
183        );
184
185        let text = serde_json::to_string(&state).unwrap();
186        let back: AppState = serde_json::from_str(&text).unwrap();
187        assert_eq!(
188            back.layouts[&ws].leaves(),
189            [HarnessId::new(4), HarnessId::new(5)]
190        );
191        assert_eq!(back.next_harness_id(), HarnessId::new(5));
192        assert_eq!(back.next_workspace_id(), WorkspaceId::new(2));
193    }
194
195    #[test]
196    fn ids_are_not_reused_while_activity_still_remembers_them() {
197        // A departed agent keeps a line in the feed. Handing its id to the
198        // next agent would repaint that line in the newcomer's colour.
199        let state = AppState {
200            activity: vec![Activity {
201                time: "00:00".into(),
202                name: "gone".into(),
203                message: "left".into(),
204                accent: 0,
205                to: None,
206                harness: Some(HarnessId::new(7)),
207            }],
208            ..Default::default()
209        };
210        assert_eq!(state.next_harness_id(), HarnessId::new(8));
211    }
212}