use std::fs;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::domain::error::{Error, Result};
use crate::domain::filter::Tab;
use crate::domain::sort::{SortDir, SortMode};
use crate::util::fs::write_atomic;
pub const DEFAULT_PREVIEW_WIDTH_PCT: u16 = 40;
pub const DEFAULT_PREVIEW_HEIGHT_ROWS: u16 = 9;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UiState {
pub sort: SortMode,
pub sort_dir: SortDir,
pub tab: Tab,
pub show_slugs: bool,
pub preview: String,
pub preview_width_pct: u16,
pub preview_height_rows: u16,
pub columns: String,
pub hints_visible: bool,
}
impl Default for UiState {
fn default() -> Self {
UiState {
sort: SortMode::default(),
sort_dir: SortDir::default(),
tab: Tab::default(),
show_slugs: false,
preview: "off".to_string(),
preview_width_pct: DEFAULT_PREVIEW_WIDTH_PCT,
preview_height_rows: DEFAULT_PREVIEW_HEIGHT_ROWS,
columns: "standard".to_string(),
hints_visible: true,
}
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct UiStateDoc {
sort: Option<String>,
sort_dir: Option<String>,
tab: Option<String>,
show_slugs: Option<bool>,
preview: Option<String>,
preview_width_pct: Option<u16>,
preview_height_rows: Option<u16>,
columns: Option<String>,
hints_visible: Option<bool>,
}
pub fn load(path: &Path) -> UiState {
let Ok(text) = fs::read_to_string(path) else {
return UiState::default();
};
let Ok(doc) = toml::from_str::<UiStateDoc>(&text) else {
return UiState::default();
};
let defaults = UiState::default();
UiState {
sort: doc
.sort
.as_deref()
.map_or_else(SortMode::default, SortMode::from_config_value),
sort_dir: doc
.sort_dir
.as_deref()
.map_or_else(SortDir::default, SortDir::from_config_value),
tab: doc.tab.as_deref().map_or_else(Tab::default, Tab::from_key),
show_slugs: doc.show_slugs.unwrap_or(false),
preview: doc.preview.unwrap_or(defaults.preview),
preview_width_pct: doc
.preview_width_pct
.unwrap_or(DEFAULT_PREVIEW_WIDTH_PCT),
preview_height_rows: doc
.preview_height_rows
.unwrap_or(DEFAULT_PREVIEW_HEIGHT_ROWS),
columns: doc.columns.unwrap_or(defaults.columns),
hints_visible: doc.hints_visible.unwrap_or(true),
}
}
pub fn save(path: &Path, state: &UiState) -> Result<()> {
let doc = UiStateDoc {
sort: Some(state.sort.label().to_string()),
sort_dir: Some(state.sort_dir.label().to_string()),
tab: Some(state.tab.as_key().to_string()),
show_slugs: Some(state.show_slugs),
preview: Some(state.preview.clone()),
preview_width_pct: Some(state.preview_width_pct),
preview_height_rows: Some(state.preview_height_rows),
columns: Some(state.columns.clone()),
hints_visible: Some(state.hints_visible),
};
let text = toml::to_string_pretty(&doc)
.map_err(|e| Error::invalid(format!("serialise ui state: {e}")))?;
write_atomic(path, &text, "ui state")
}
#[cfg(test)]
mod tests {
use super::*;
fn temp(tag: &str) -> std::path::PathBuf {
std::env::temp_dir()
.join(format!("hop-uistate-{tag}-{}", std::process::id()))
}
#[test]
fn save_then_load_round_trips() {
let dir = temp("round");
let file = dir.join("ui-state.toml");
let state = UiState {
sort: SortMode::Loc,
sort_dir: SortDir::Desc,
tab: Tab::Archive,
show_slugs: true,
preview: "right".to_string(),
preview_width_pct: 55,
preview_height_rows: 14,
columns: "code".to_string(),
hints_visible: false,
};
save(&file, &state).unwrap();
assert_eq!(load(&file), state);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_file_from_before_the_panel_had_a_size_keeps_its_position() {
let dir = temp("legacy");
let file = dir.join("ui-state.toml");
fs::create_dir_all(&dir).unwrap();
fs::write(
&file,
"sort = \"name\"\ntab = \"git\"\nshow_slugs = false\n\
preview = \"right\"\nhints_visible = true\n",
)
.unwrap();
let state = load(&file);
assert_eq!(state.preview, "right", "the panel must stay on the right");
assert_eq!(state.preview_width_pct, DEFAULT_PREVIEW_WIDTH_PCT);
assert_eq!(state.preview_height_rows, DEFAULT_PREVIEW_HEIGHT_ROWS);
assert_eq!(state.columns, "standard");
assert_eq!(state.sort_dir, SortDir::Asc);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn missing_file_defaults() {
assert_eq!(
load(Path::new("/nonexistent/hop-ui-state.toml")),
UiState::default()
);
}
}