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 TabView {
pub sort: SortMode,
pub sort_dir: SortDir,
pub columns: String,
pub grouped: bool,
pub fav_float: bool,
}
impl Default for TabView {
fn default() -> Self {
TabView {
sort: SortMode::default(),
sort_dir: SortDir::default(),
columns: "standard".to_string(),
grouped: true,
fav_float: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UiState {
pub git: TabView,
pub files: TabView,
pub tab: Tab,
pub show_slugs: bool,
pub preview: String,
pub preview_width_pct: u16,
pub preview_height_rows: u16,
pub hints_visible: bool,
}
impl Default for UiState {
fn default() -> Self {
UiState {
git: TabView::default(),
files: TabView::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,
hints_visible: true,
}
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct TabViewDoc {
sort: Option<String>,
sort_dir: Option<String>,
columns: Option<String>,
grouped: Option<bool>,
fav_float: Option<bool>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct TabsDoc {
git: Option<TabViewDoc>,
files: Option<TabViewDoc>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct UiStateDoc {
tab: Option<String>,
show_slugs: Option<bool>,
preview: Option<String>,
preview_width_pct: Option<u16>,
preview_height_rows: Option<u16>,
hints_visible: Option<bool>,
tabs: Option<TabsDoc>,
sort: Option<String>,
sort_dir: Option<String>,
columns: Option<String>,
}
fn tab_view(block: Option<TabViewDoc>, legacy: &UiStateDoc) -> TabView {
let block = block.unwrap_or_default();
let sort = block.sort.or_else(|| legacy.sort.clone());
let sort_dir = block.sort_dir.or_else(|| legacy.sort_dir.clone());
let columns = block.columns.or_else(|| legacy.columns.clone());
TabView {
sort: sort
.as_deref()
.map_or_else(SortMode::default, SortMode::from_config_value),
sort_dir: sort_dir
.as_deref()
.map_or_else(SortDir::default, SortDir::from_config_value),
columns: columns.unwrap_or_else(|| "standard".to_string()),
grouped: block.grouped.unwrap_or(true),
fav_float: block.fav_float.unwrap_or(true),
}
}
pub fn load(path: &Path) -> UiState {
let Ok(text) = fs::read_to_string(path) else {
return UiState::default();
};
let Ok(mut doc) = toml::from_str::<UiStateDoc>(&text) else {
return UiState::default();
};
let defaults = UiState::default();
let tabs = doc.tabs.take().unwrap_or_default();
UiState {
git: tab_view(tabs.git, &doc),
files: tab_view(tabs.files, &doc),
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),
hints_visible: doc.hints_visible.unwrap_or(true),
}
}
fn view_doc(view: &TabView) -> TabViewDoc {
TabViewDoc {
sort: Some(view.sort.label().to_string()),
sort_dir: Some(view.sort_dir.label().to_string()),
columns: Some(view.columns.clone()),
grouped: Some(view.grouped),
fav_float: Some(view.fav_float),
}
}
pub fn save(path: &Path, state: &UiState) -> Result<()> {
let doc = UiStateDoc {
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),
hints_visible: Some(state.hints_visible),
tabs: Some(TabsDoc {
git: Some(view_doc(&state.git)),
files: Some(view_doc(&state.files)),
}),
sort: None,
sort_dir: None,
columns: None,
};
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 {
git: TabView {
sort: SortMode::Loc,
sort_dir: SortDir::Desc,
columns: "code".to_string(),
grouped: false,
fav_float: false,
},
files: TabView {
sort: SortMode::Recent,
sort_dir: SortDir::Asc,
columns: "standard".to_string(),
grouped: true,
fav_float: false,
},
tab: Tab::FilesArchive,
show_slugs: true,
preview: "right".to_string(),
preview_width_pct: 55,
preview_height_rows: 14,
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.git.sort_dir, SortDir::Asc);
assert_eq!(state.git.columns, "standard");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn legacy_global_sort_seeds_both_kinds() {
let dir = temp("legacy-sort");
let file = dir.join("ui-state.toml");
fs::create_dir_all(&dir).unwrap();
fs::write(&file, "sort = \"frecency\"\ncolumns = \"code\"\n").unwrap();
let state = load(&file);
assert_eq!(state.git.sort, SortMode::Frecency);
assert_eq!(state.files.sort, SortMode::Frecency);
assert_eq!(state.git.columns, "code");
assert_eq!(state.files.columns, "code");
assert!(state.git.grouped && state.git.fav_float);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn missing_file_defaults() {
assert_eq!(
load(Path::new("/nonexistent/hop-ui-state.toml")),
UiState::default()
);
}
}