use super::types::{AppSettings, RepoHistoryEntry};
use anyhow::{Context, Result};
use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
use std::path::{Path, PathBuf};
const SETTINGS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("settings");
const RECENT_REPOS_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("recent_repos");
pub fn settings_dir() -> Result<PathBuf> {
let base = dirs::config_dir().context("could not determine config directory")?;
Ok(base.join("gitkraft"))
}
fn db_path() -> Result<PathBuf> {
Ok(settings_dir()?.join("gitkraft.redb"))
}
fn open_db() -> Result<Database> {
let dir = settings_dir()?;
std::fs::create_dir_all(&dir)
.with_context(|| format!("failed to create settings directory {}", dir.display()))?;
let path = db_path()?;
match Database::create(&path) {
Ok(db) => Ok(db),
Err(e) => {
tracing::warn!(
"Could not open database ({e}); removing stale file and creating fresh one."
);
let _ = std::fs::remove_file(&path);
Database::create(&path)
.with_context(|| format!("failed to open database at {}", path.display()))
}
}
}
pub fn load_settings() -> Result<AppSettings> {
let db = match open_db() {
Ok(db) => db,
Err(_) => return Ok(AppSettings::default()),
};
let read_txn = db.begin_read()?;
let mut settings = AppSettings::default();
if let Ok(table) = read_txn.open_table(SETTINGS_TABLE) {
if let Ok(Some(val)) = table.get("last_repo") {
settings.last_repo = Some(PathBuf::from(val.value()));
}
if let Ok(Some(val)) = table.get("theme_name") {
settings.theme_name = Some(val.value().to_string());
}
if let Ok(Some(val)) = table.get("layout") {
if let Ok(layout) = serde_json::from_str::<super::types::LayoutSettings>(val.value()) {
settings.layout = Some(layout);
}
}
if let Ok(Some(val)) = table.get("open_tabs") {
if let Ok(tabs) = serde_json::from_str::<Vec<PathBuf>>(val.value()) {
settings.open_tabs = tabs;
}
}
if let Ok(Some(val)) = table.get("active_tab_index") {
if let Ok(idx) = val.value().parse::<usize>() {
settings.active_tab_index = idx;
}
}
}
if let Ok(table) = read_txn.open_table(RECENT_REPOS_TABLE) {
let mut entries: Vec<RepoHistoryEntry> = Vec::new();
if let Ok(iter) = table.iter() {
for (_key, value) in iter.flatten() {
if let Ok(entry) = serde_json::from_slice::<RepoHistoryEntry>(value.value()) {
entries.push(entry);
}
}
}
entries.sort_by_key(|e| std::cmp::Reverse(e.last_opened));
settings.recent_repos = entries;
}
Ok(settings)
}
pub fn save_settings(settings: &AppSettings) -> Result<()> {
let db = open_db()?;
let write_txn = db.begin_write()?;
{
let mut table = write_txn.open_table(SETTINGS_TABLE)?;
if let Some(ref path) = settings.last_repo {
table.insert("last_repo", path.to_string_lossy().as_ref())?;
}
if let Some(ref theme) = settings.theme_name {
table.insert("theme_name", theme.as_str())?;
}
if let Some(ref layout) = settings.layout {
let layout_json =
serde_json::to_string(layout).context("failed to serialize layout settings")?;
table.insert("layout", layout_json.as_str())?;
}
if !settings.open_tabs.is_empty() {
let tabs_json = serde_json::to_string(&settings.open_tabs)
.context("failed to serialize open_tabs")?;
table.insert("open_tabs", tabs_json.as_str())?;
} else {
let _ = table.remove("open_tabs");
}
let idx_str = settings.active_tab_index.to_string();
table.insert("active_tab_index", idx_str.as_str())?;
}
{
let mut table = write_txn.open_table(RECENT_REPOS_TABLE)?;
let existing_keys: Vec<String> = {
let iter = table.iter()?;
iter.filter_map(|e| e.ok().map(|(k, _)| k.value().to_string()))
.collect()
};
for key in &existing_keys {
table.remove(key.as_str())?;
}
for entry in &settings.recent_repos {
let key = entry.path.to_string_lossy();
let value =
serde_json::to_vec(entry).context("failed to serialize repo history entry")?;
table.insert(key.as_ref(), value.as_slice())?;
}
}
write_txn.commit()?;
Ok(())
}
pub fn record_repo_opened(path: &Path) -> Result<()> {
let mut settings = load_settings()?;
settings.add_recent_repo(path.to_path_buf());
save_settings(&settings)
}
pub fn get_last_repo() -> Result<Option<PathBuf>> {
let settings = load_settings()?;
Ok(settings.last_repo)
}
pub fn save_theme(theme_name: &str) -> Result<()> {
let mut settings = load_settings()?;
settings.theme_name = Some(theme_name.to_string());
save_settings(&settings)
}
pub fn get_saved_theme() -> Result<Option<String>> {
let settings = load_settings()?;
Ok(settings.theme_name)
}
pub fn save_layout(layout: &super::types::LayoutSettings) -> Result<()> {
let mut settings = load_settings()?;
settings.layout = Some(layout.clone());
save_settings(&settings)
}
pub fn get_saved_layout() -> Result<Option<super::types::LayoutSettings>> {
let settings = load_settings()?;
Ok(settings.layout)
}
pub fn record_repo_and_save_session(
path: &Path,
open_tabs: &[PathBuf],
active_tab_index: usize,
) -> Result<Vec<RepoHistoryEntry>> {
let mut settings = load_settings()?;
settings.add_recent_repo(path.to_path_buf());
settings.open_tabs = open_tabs.to_vec();
settings.active_tab_index = active_tab_index;
save_settings(&settings)?;
Ok(settings.recent_repos)
}
pub fn save_session(open_tabs: &[PathBuf], active_tab_index: usize) -> Result<()> {
let mut settings = load_settings()?;
settings.open_tabs = open_tabs.to_vec();
settings.active_tab_index = active_tab_index;
save_settings(&settings)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn add_recent_deduplicates() {
let mut settings = AppSettings::default();
settings.add_recent_repo(PathBuf::from("/tmp/repo1"));
settings.add_recent_repo(PathBuf::from("/tmp/repo2"));
settings.add_recent_repo(PathBuf::from("/tmp/repo1"));
assert_eq!(settings.recent_repos.len(), 2);
assert_eq!(settings.recent_repos[0].path, PathBuf::from("/tmp/repo1"));
}
#[test]
fn add_recent_respects_max() {
let mut settings = AppSettings {
max_recent: 3,
..Default::default()
};
for i in 0..5 {
settings.add_recent_repo(PathBuf::from(format!("/tmp/repo{i}")));
}
assert_eq!(settings.recent_repos.len(), 3);
}
#[test]
fn settings_round_trip() {
let mut settings = AppSettings::default();
settings.add_recent_repo(PathBuf::from("/tmp/repo1"));
settings.add_recent_repo(PathBuf::from("/tmp/repo2"));
settings.theme_name = Some("Dark".to_string());
let entry = &settings.recent_repos[0];
let bytes = serde_json::to_vec(entry).unwrap();
let decoded: RepoHistoryEntry = serde_json::from_slice(&bytes).unwrap();
assert_eq!(decoded.path, entry.path);
assert_eq!(decoded.display_name, entry.display_name);
}
}