//! Threads and projects kept between runs, under
//! `$XDG_DATA_HOME/vectrum-code`: one JSON file per thread in `threads/`,
//! and `state.json` for the project list and the open thread.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::session::SavedThread;
use crate::settings;
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct SavedProject {
pub path: PathBuf,
pub name: String,
/// Milliseconds since the epoch.
pub added: u64,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct AppState {
pub projects: Vec<SavedProject>,
/// The thread that was open.
pub active: Option<String>,
/// The sidebar's project filter, by project path.
pub project_scope: Option<PathBuf>,
/// Which of the sidebar's shelves are unfolded.
pub working_expanded: bool,
pub snoozed_expanded: bool,
pub settled_expanded: bool,
/// Projects folded away in the legacy sidebar.
pub collapsed_projects: Vec<PathBuf>,
/// The thread sidebar's width as last dragged; `None` is the default.
pub sidebar_width: Option<f32>,
}
fn threads_dir() -> Option<PathBuf> {
Some(settings::data_dir()?.join("threads"))
}
fn state_path() -> Option<PathBuf> {
Some(settings::data_dir()?.join("state.json"))
}
/// Writes through a temporary file, so a crash mid-write can't leave a
/// half-written file behind.
fn write_atomically(path: &Path, contents: &[u8]) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let temp = path.with_extension("json.tmp");
std::fs::write(&temp, contents)?;
std::fs::rename(temp, path)
}
pub fn load_state() -> AppState {
state_path()
.and_then(|path| std::fs::read_to_string(path).ok())
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_default()
}
pub fn save_state(state: &AppState) {
let Some(path) = state_path() else { return };
if let Err(err) = write_atomically(&path, serde_json::to_string_pretty(state).unwrap_or_default().as_bytes()) {
eprintln!("vectrum-code: couldn't save {}: {err}", path.display());
}
}
/// Every saved thread that still reads.
pub fn load_threads() -> Vec<SavedThread> {
let Some(dir) = threads_dir() else { return Vec::new() };
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
entries
.flatten()
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|extension| extension == "json"))
.filter_map(|path| {
let text = std::fs::read_to_string(&path).ok()?;
match serde_json::from_str(&text) {
Ok(thread) => Some(thread),
Err(err) => {
eprintln!("vectrum-code: skipping {}: {err}", path.display());
None
}
}
})
.collect()
}
pub fn save_thread(thread: &SavedThread) {
let Some(dir) = threads_dir() else { return };
let path = dir.join(format!("{}.json", thread.id));
match serde_json::to_vec(thread) {
Ok(bytes) => {
if let Err(err) = write_atomically(&path, &bytes) {
eprintln!("vectrum-code: couldn't save {}: {err}", path.display());
}
}
Err(err) => eprintln!("vectrum-code: couldn't save thread {}: {err}", thread.id),
}
}
pub fn delete_thread(id: &str) {
if let Some(dir) = threads_dir() {
let _ = std::fs::remove_file(dir.join(format!("{id}.json")));
}
}