use std::cell::RefCell;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tracing::warn;
const APP_NAME: &str = "octopeek";
const STATE_FILE: &str = "state.toml";
thread_local! {
static STATE_DIR_OVERRIDE: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
}
#[allow(dead_code)] pub fn set_state_dir_override(dir: impl Into<PathBuf>) {
let dir: PathBuf = dir.into();
STATE_DIR_OVERRIDE.with(|c| *c.borrow_mut() = Some(dir));
}
#[allow(dead_code)] pub fn with_state_dir_override<R>(dir: impl AsRef<Path>, f: impl FnOnce() -> R) -> R {
struct Guard(Option<PathBuf>);
impl Drop for Guard {
fn drop(&mut self) {
STATE_DIR_OVERRIDE.with(|c| *c.borrow_mut() = self.0.take());
}
}
let previous = STATE_DIR_OVERRIDE.with(|c| c.borrow_mut().replace(dir.as_ref().to_path_buf()));
let _guard = Guard(previous);
f()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ViewMode {
#[default]
Prs,
Issues,
}
pub const DEFAULT_SIDEBAR_WIDTH: u16 = 28;
fn default_sidebar_width() -> u16 {
DEFAULT_SIDEBAR_WIDTH
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(from = "SessionCompat")]
pub struct AppSession {
pub active_tab_index: usize,
pub per_repo_view: HashMap<String, ViewMode>,
#[serde(default = "default_sidebar_width")]
pub sidebar_width: u16,
#[serde(default)]
pub sidebar_hidden: bool,
}
impl Default for AppSession {
fn default() -> Self {
Self {
active_tab_index: 0,
per_repo_view: HashMap::new(),
sidebar_width: DEFAULT_SIDEBAR_WIDTH,
sidebar_hidden: false,
}
}
}
#[derive(Deserialize)]
#[serde(untagged)]
enum SessionCompat {
New {
active_tab_index: usize,
per_repo_view: HashMap<String, ViewMode>,
#[serde(default = "default_sidebar_width")]
sidebar_width: u16,
#[serde(default)]
sidebar_hidden: bool,
},
Legacy {},
}
impl From<SessionCompat> for AppSession {
fn from(v: SessionCompat) -> Self {
match v {
SessionCompat::New {
active_tab_index,
per_repo_view,
sidebar_width,
sidebar_hidden,
} => Self { active_tab_index, per_repo_view, sidebar_width, sidebar_hidden },
SessionCompat::Legacy {} => Self::default(),
}
}
}
impl AppSession {
pub fn load() -> Self {
let Some(path) = state_path() else {
return Self::default();
};
let Ok(text) = fs::read_to_string(&path) else {
return Self::default();
};
match toml::from_str(&text) {
Ok(session) => session,
Err(e) => {
warn!(
"failed to parse session at {}: {e}; falling back to defaults",
path.display()
);
Self::default()
}
}
}
pub fn save(&self) {
let Some(path) = state_path() else {
warn!("cannot resolve state path; skipping save");
return;
};
if let Some(parent) = path.parent()
&& let Err(e) = fs::create_dir_all(parent)
{
warn!("failed to create state dir {}: {e}", parent.display());
return;
}
let text = match toml::to_string_pretty(self) {
Ok(t) => t,
Err(e) => {
warn!("failed to serialize state: {e}");
return;
}
};
if let Err(e) = fs::write(&path, text) {
warn!("failed to write state to {}: {e}", path.display());
}
}
#[allow(dead_code)] pub fn set_view_mode(&mut self, repo: &str, mode: ViewMode) {
self.per_repo_view.insert(repo.to_owned(), mode);
self.save();
}
#[allow(dead_code)] pub fn view_mode(&self, repo: &str) -> ViewMode {
self.per_repo_view.get(repo).copied().unwrap_or_default()
}
}
fn state_path() -> Option<PathBuf> {
if let Some(mut p) = STATE_DIR_OVERRIDE.with(|c| c.borrow().clone()) {
p.push(STATE_FILE);
return Some(p);
}
let base = dirs::state_dir().or_else(dirs::data_dir)?;
let mut path = base;
path.push(APP_NAME);
path.push(STATE_FILE);
Some(path)
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn default_session_round_trips() {
let session = AppSession::default();
let serialized = toml::to_string_pretty(&session).expect("serialize");
let deserialized: AppSession = toml::from_str(&serialized).expect("deserialize");
assert_eq!(deserialized, session);
}
#[test]
fn view_mode_defaults_to_prs() {
let session = AppSession::default();
assert_eq!(session.view_mode("rust-lang/rust"), ViewMode::Prs);
}
#[test]
fn set_view_mode_persists_in_memory() {
let mut session = AppSession::default();
session.per_repo_view.insert("octocat/Hello-World".to_owned(), ViewMode::Issues);
assert_eq!(session.view_mode("octocat/Hello-World"), ViewMode::Issues,);
}
#[test]
fn legacy_session_without_sidebar_fields_loads_with_defaults() {
let toml_str = "active_tab_index = 0\n[per_repo_view]\n";
let session: AppSession = toml::from_str(toml_str).expect("deserialize");
assert_eq!(session.sidebar_width, DEFAULT_SIDEBAR_WIDTH);
assert!(!session.sidebar_hidden);
}
#[test]
fn session_sidebar_state_round_trips() {
let session =
AppSession { sidebar_width: 42, sidebar_hidden: true, ..AppSession::default() };
let serialized = toml::to_string_pretty(&session).expect("serialize");
let restored: AppSession = toml::from_str(&serialized).expect("deserialize");
assert_eq!(restored.sidebar_width, 42);
assert!(restored.sidebar_hidden);
}
#[test]
fn session_new_format_deserializes() {
let toml_str = r#"
active_tab_index = 2
[per_repo_view]
"rust-lang/rust" = "issues"
"octocat/Hello-World" = "prs"
"#;
let session: AppSession = toml::from_str(toml_str).expect("deserialize");
assert_eq!(session.active_tab_index, 2);
assert_eq!(session.view_mode("rust-lang/rust"), ViewMode::Issues);
assert_eq!(session.view_mode("octocat/Hello-World"), ViewMode::Prs);
}
}