gitkraft_core/features/persistence/
ops.rs1use super::types::{AppSettings, RepoHistoryEntry};
5use anyhow::{Context, Result};
6use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
7use std::path::{Path, PathBuf};
8
9const SETTINGS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("settings");
10const RECENT_REPOS_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("recent_repos");
11
12pub fn settings_dir() -> Result<PathBuf> {
14 let base = dirs::config_dir().context("could not determine config directory")?;
15 Ok(base.join("gitkraft"))
16}
17
18fn db_path() -> Result<PathBuf> {
20 Ok(settings_dir()?.join("gitkraft.redb"))
21}
22
23fn open_db() -> Result<Database> {
27 let dir = settings_dir()?;
28 std::fs::create_dir_all(&dir)
29 .with_context(|| format!("failed to create settings directory {}", dir.display()))?;
30 let path = db_path()?;
31 match Database::create(&path) {
32 Ok(db) => Ok(db),
33 Err(e) => {
34 tracing::warn!(
35 "Could not open database ({e}); removing stale file and creating fresh one."
36 );
37 let _ = std::fs::remove_file(&path);
38 Database::create(&path)
39 .with_context(|| format!("failed to open database at {}", path.display()))
40 }
41 }
42}
43
44pub fn load_settings() -> Result<AppSettings> {
47 let db = match open_db() {
48 Ok(db) => db,
49 Err(_) => return Ok(AppSettings::default()),
50 };
51
52 let read_txn = db.begin_read()?;
53 let mut settings = AppSettings::default();
54
55 if let Ok(table) = read_txn.open_table(SETTINGS_TABLE) {
57 if let Ok(Some(val)) = table.get("last_repo") {
58 settings.last_repo = Some(PathBuf::from(val.value()));
59 }
60 if let Ok(Some(val)) = table.get("theme_name") {
61 settings.theme_name = Some(val.value().to_string());
62 }
63 if let Ok(Some(val)) = table.get("layout") {
64 if let Ok(layout) = serde_json::from_str::<super::types::LayoutSettings>(val.value()) {
65 settings.layout = Some(layout);
66 }
67 }
68 if let Ok(Some(val)) = table.get("open_tabs") {
69 if let Ok(tabs) = serde_json::from_str::<Vec<PathBuf>>(val.value()) {
70 settings.open_tabs = tabs;
71 }
72 }
73 if let Ok(Some(val)) = table.get("active_tab_index") {
74 if let Ok(idx) = val.value().parse::<usize>() {
75 settings.active_tab_index = idx;
76 }
77 }
78 }
79
80 if let Ok(table) = read_txn.open_table(RECENT_REPOS_TABLE) {
82 let mut entries: Vec<RepoHistoryEntry> = Vec::new();
83 if let Ok(iter) = table.iter() {
84 for (_key, value) in iter.flatten() {
85 if let Ok(entry) = serde_json::from_slice::<RepoHistoryEntry>(value.value()) {
86 entries.push(entry);
87 }
88 }
89 }
90 entries.sort_by_key(|e| std::cmp::Reverse(e.last_opened));
92 settings.recent_repos = entries;
93 }
94
95 Ok(settings)
96}
97
98pub fn save_settings(settings: &AppSettings) -> Result<()> {
100 let db = open_db()?;
101 let write_txn = db.begin_write()?;
102
103 {
105 let mut table = write_txn.open_table(SETTINGS_TABLE)?;
106 if let Some(ref path) = settings.last_repo {
107 table.insert("last_repo", path.to_string_lossy().as_ref())?;
108 }
109 if let Some(ref theme) = settings.theme_name {
110 table.insert("theme_name", theme.as_str())?;
111 }
112 if let Some(ref layout) = settings.layout {
113 let layout_json =
114 serde_json::to_string(layout).context("failed to serialize layout settings")?;
115 table.insert("layout", layout_json.as_str())?;
116 }
117 if !settings.open_tabs.is_empty() {
118 let tabs_json = serde_json::to_string(&settings.open_tabs)
119 .context("failed to serialize open_tabs")?;
120 table.insert("open_tabs", tabs_json.as_str())?;
121 } else {
122 let _ = table.remove("open_tabs");
123 }
124 let idx_str = settings.active_tab_index.to_string();
125 table.insert("active_tab_index", idx_str.as_str())?;
126 }
127
128 {
130 let mut table = write_txn.open_table(RECENT_REPOS_TABLE)?;
131
132 let existing_keys: Vec<String> = {
134 let iter = table.iter()?;
135 iter.filter_map(|e| e.ok().map(|(k, _)| k.value().to_string()))
136 .collect()
137 };
138 for key in &existing_keys {
139 table.remove(key.as_str())?;
140 }
141
142 for entry in &settings.recent_repos {
144 let key = entry.path.to_string_lossy();
145 let value =
146 serde_json::to_vec(entry).context("failed to serialize repo history entry")?;
147 table.insert(key.as_ref(), value.as_slice())?;
148 }
149 }
150
151 write_txn.commit()?;
152 Ok(())
153}
154
155pub fn record_repo_opened(path: &Path) -> Result<()> {
157 let mut settings = load_settings()?;
158 settings.add_recent_repo(path.to_path_buf());
159 save_settings(&settings)
160}
161
162pub fn get_last_repo() -> Result<Option<PathBuf>> {
164 let settings = load_settings()?;
165 Ok(settings.last_repo)
166}
167
168pub fn save_theme(theme_name: &str) -> Result<()> {
170 let mut settings = load_settings()?;
171 settings.theme_name = Some(theme_name.to_string());
172 save_settings(&settings)
173}
174
175pub fn get_saved_theme() -> Result<Option<String>> {
177 let settings = load_settings()?;
178 Ok(settings.theme_name)
179}
180
181pub fn save_layout(layout: &super::types::LayoutSettings) -> Result<()> {
183 let mut settings = load_settings()?;
184 settings.layout = Some(layout.clone());
185 save_settings(&settings)
186}
187
188pub fn get_saved_layout() -> Result<Option<super::types::LayoutSettings>> {
190 let settings = load_settings()?;
191 Ok(settings.layout)
192}
193
194pub fn record_repo_and_save_session(
197 path: &Path,
198 open_tabs: &[PathBuf],
199 active_tab_index: usize,
200) -> Result<Vec<RepoHistoryEntry>> {
201 let mut settings = load_settings()?;
202 settings.add_recent_repo(path.to_path_buf());
203 settings.open_tabs = open_tabs.to_vec();
204 settings.active_tab_index = active_tab_index;
205 save_settings(&settings)?;
206 Ok(settings.recent_repos)
207}
208
209pub fn save_session(open_tabs: &[PathBuf], active_tab_index: usize) -> Result<()> {
211 let mut settings = load_settings()?;
212 settings.open_tabs = open_tabs.to_vec();
213 settings.active_tab_index = active_tab_index;
214 save_settings(&settings)
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[test]
222 fn add_recent_deduplicates() {
223 let mut settings = AppSettings::default();
224 settings.add_recent_repo(PathBuf::from("/tmp/repo1"));
225 settings.add_recent_repo(PathBuf::from("/tmp/repo2"));
226 settings.add_recent_repo(PathBuf::from("/tmp/repo1"));
227 assert_eq!(settings.recent_repos.len(), 2);
228 assert_eq!(settings.recent_repos[0].path, PathBuf::from("/tmp/repo1"));
229 }
230
231 #[test]
232 fn add_recent_respects_max() {
233 let mut settings = AppSettings {
234 max_recent: 3,
235 ..Default::default()
236 };
237 for i in 0..5 {
238 settings.add_recent_repo(PathBuf::from(format!("/tmp/repo{i}")));
239 }
240 assert_eq!(settings.recent_repos.len(), 3);
241 }
242
243 #[test]
244 fn settings_round_trip() {
245 let mut settings = AppSettings::default();
247 settings.add_recent_repo(PathBuf::from("/tmp/repo1"));
248 settings.add_recent_repo(PathBuf::from("/tmp/repo2"));
249 settings.theme_name = Some("Dark".to_string());
250
251 let entry = &settings.recent_repos[0];
252 let bytes = serde_json::to_vec(entry).unwrap();
253 let decoded: RepoHistoryEntry = serde_json::from_slice(&bytes).unwrap();
254 assert_eq!(decoded.path, entry.path);
255 assert_eq!(decoded.display_name, entry.display_name);
256 }
257}