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 if let Ok(Some(val)) = table.get("editor_name") {
79 settings.editor_name = Some(val.value().to_string());
80 }
81 }
82
83 if let Ok(table) = read_txn.open_table(RECENT_REPOS_TABLE) {
85 let mut entries: Vec<RepoHistoryEntry> = Vec::new();
86 if let Ok(iter) = table.iter() {
87 for (_key, value) in iter.flatten() {
88 if let Ok(entry) = serde_json::from_slice::<RepoHistoryEntry>(value.value()) {
89 entries.push(entry);
90 }
91 }
92 }
93 entries.sort_by_key(|e| std::cmp::Reverse(e.last_opened));
95 settings.recent_repos = entries;
96 }
97
98 Ok(settings)
99}
100
101pub fn save_settings(settings: &AppSettings) -> Result<()> {
103 let db = open_db()?;
104 let write_txn = db.begin_write()?;
105
106 {
108 let mut table = write_txn.open_table(SETTINGS_TABLE)?;
109 if let Some(ref path) = settings.last_repo {
110 table.insert("last_repo", path.to_string_lossy().as_ref())?;
111 }
112 if let Some(ref theme) = settings.theme_name {
113 table.insert("theme_name", theme.as_str())?;
114 }
115 if let Some(ref layout) = settings.layout {
116 let layout_json =
117 serde_json::to_string(layout).context("failed to serialize layout settings")?;
118 table.insert("layout", layout_json.as_str())?;
119 }
120 if let Some(ref editor) = settings.editor_name {
121 table.insert("editor_name", editor.as_str())?;
122 }
123 if !settings.open_tabs.is_empty() {
124 let tabs_json = serde_json::to_string(&settings.open_tabs)
125 .context("failed to serialize open_tabs")?;
126 table.insert("open_tabs", tabs_json.as_str())?;
127 } else {
128 let _ = table.remove("open_tabs");
129 }
130 let idx_str = settings.active_tab_index.to_string();
131 table.insert("active_tab_index", idx_str.as_str())?;
132 }
133
134 {
136 let mut table = write_txn.open_table(RECENT_REPOS_TABLE)?;
137
138 let existing_keys: Vec<String> = {
140 let iter = table.iter()?;
141 iter.filter_map(|e| e.ok().map(|(k, _)| k.value().to_string()))
142 .collect()
143 };
144 for key in &existing_keys {
145 table.remove(key.as_str())?;
146 }
147
148 for entry in &settings.recent_repos {
150 let key = entry.path.to_string_lossy();
151 let value =
152 serde_json::to_vec(entry).context("failed to serialize repo history entry")?;
153 table.insert(key.as_ref(), value.as_slice())?;
154 }
155 }
156
157 write_txn.commit()?;
158 Ok(())
159}
160
161pub fn record_repo_opened(path: &Path) -> Result<()> {
163 let mut settings = load_settings()?;
164 settings.add_recent_repo(path.to_path_buf());
165 save_settings(&settings)
166}
167
168pub fn get_last_repo() -> Result<Option<PathBuf>> {
170 let settings = load_settings()?;
171 Ok(settings.last_repo)
172}
173
174pub fn save_theme(theme_name: &str) -> Result<()> {
176 let mut settings = load_settings()?;
177 settings.theme_name = Some(theme_name.to_string());
178 save_settings(&settings)
179}
180
181pub fn get_saved_theme() -> Result<Option<String>> {
183 let settings = load_settings()?;
184 Ok(settings.theme_name)
185}
186
187pub fn save_editor(editor_name: &str) -> Result<()> {
189 let mut settings = load_settings()?;
190 settings.editor_name = Some(editor_name.to_string());
191 save_settings(&settings)
192}
193
194pub fn get_saved_editor() -> Result<Option<String>> {
196 let settings = load_settings()?;
197 Ok(settings.editor_name)
198}
199
200pub fn save_layout(layout: &super::types::LayoutSettings) -> Result<()> {
202 let mut settings = load_settings()?;
203 settings.layout = Some(layout.clone());
204 save_settings(&settings)
205}
206
207pub fn get_saved_layout() -> Result<Option<super::types::LayoutSettings>> {
209 let settings = load_settings()?;
210 Ok(settings.layout)
211}
212
213pub fn record_repo_and_save_session(
216 path: &Path,
217 open_tabs: &[PathBuf],
218 active_tab_index: usize,
219) -> Result<Vec<RepoHistoryEntry>> {
220 let mut settings = load_settings()?;
221 settings.add_recent_repo(path.to_path_buf());
222 settings.open_tabs = open_tabs.to_vec();
223 settings.active_tab_index = active_tab_index;
224 save_settings(&settings)?;
225 Ok(settings.recent_repos)
226}
227
228pub fn save_session(open_tabs: &[PathBuf], active_tab_index: usize) -> Result<()> {
230 let mut settings = load_settings()?;
231 settings.open_tabs = open_tabs.to_vec();
232 settings.active_tab_index = active_tab_index;
233 save_settings(&settings)
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 #[test]
241 fn add_recent_deduplicates() {
242 let mut settings = AppSettings::default();
243 settings.add_recent_repo(PathBuf::from("/tmp/repo1"));
244 settings.add_recent_repo(PathBuf::from("/tmp/repo2"));
245 settings.add_recent_repo(PathBuf::from("/tmp/repo1"));
246 assert_eq!(settings.recent_repos.len(), 2);
247 assert_eq!(settings.recent_repos[0].path, PathBuf::from("/tmp/repo1"));
248 }
249
250 #[test]
251 fn add_recent_respects_max() {
252 let mut settings = AppSettings {
253 max_recent: 3,
254 ..Default::default()
255 };
256 for i in 0..5 {
257 settings.add_recent_repo(PathBuf::from(format!("/tmp/repo{i}")));
258 }
259 assert_eq!(settings.recent_repos.len(), 3);
260 }
261
262 #[test]
263 fn settings_round_trip() {
264 let mut settings = AppSettings::default();
266 settings.add_recent_repo(PathBuf::from("/tmp/repo1"));
267 settings.add_recent_repo(PathBuf::from("/tmp/repo2"));
268 settings.theme_name = Some("Dark".to_string());
269
270 let entry = &settings.recent_repos[0];
271 let bytes = serde_json::to_vec(entry).unwrap();
272 let decoded: RepoHistoryEntry = serde_json::from_slice(&bytes).unwrap();
273 assert_eq!(decoded.path, entry.path);
274 assert_eq!(decoded.display_name, entry.display_name);
275 }
276}