1use 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 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 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 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 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 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
111pub 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
119pub 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 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}