Skip to main content

vibe_workspace/ui/
state.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::fs;
5use std::io::Write;
6use std::path::{Path, PathBuf};
7
8use anyhow::Result;
9
10/// Represents a recently accessed repository
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct RecentRepo {
13    /// Repository identifier (path or name)
14    pub repo_id: String,
15    /// Full path to the repository
16    pub path: PathBuf,
17    /// Last access timestamp
18    pub last_accessed: DateTime<Utc>,
19    /// Last used app for this repository
20    pub last_app: Option<String>,
21    /// Number of times this repo has been accessed
22    pub access_count: u32,
23}
24
25/// User preferences for the vibe workspace
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct UserPreferences {
28    /// Default app to use when none is specified
29    pub default_app: Option<String>,
30    /// Whether to show the setup wizard on startup
31    pub show_setup_wizard: bool,
32    /// Whether to auto-open last repo on startup
33    pub auto_open_last_repo: bool,
34    /// Maximum number of recent repos to track
35    pub max_recent_repos: usize,
36    /// Whether to show hints in the interface
37    pub show_hints: bool,
38}
39
40impl Default for UserPreferences {
41    fn default() -> Self {
42        Self {
43            default_app: None,
44            show_setup_wizard: true,
45            auto_open_last_repo: false,
46            max_recent_repos: 10,
47            show_hints: true,
48        }
49    }
50}
51
52/// Persistent state for user preferences and recent actions
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct VibeState {
55    /// List of recently accessed repositories
56    pub recent_repos: Vec<RecentRepo>,
57    /// Last used app per repository
58    pub last_used_apps: HashMap<String, String>,
59    /// User preferences
60    pub user_preferences: UserPreferences,
61    /// Groups of repositories for batch operations
62    pub repo_groups: HashMap<String, Vec<String>>,
63    /// First run timestamp (for setup wizard)
64    pub first_run: Option<DateTime<Utc>>,
65    /// Version of the state file format
66    pub version: u32,
67}
68
69impl Default for VibeState {
70    fn default() -> Self {
71        Self {
72            recent_repos: Vec::new(),
73            last_used_apps: HashMap::new(),
74            user_preferences: UserPreferences::default(),
75            repo_groups: HashMap::new(),
76            first_run: Some(Utc::now()),
77            version: 1,
78        }
79    }
80}
81
82impl VibeState {
83    /// Load state from the default location
84    pub fn load() -> Result<Self> {
85        let state_path = Self::default_state_path()?;
86        if state_path.exists() {
87            Self::load_from_path(&state_path)
88        } else {
89            Ok(Self::default())
90        }
91    }
92
93    /// Load state from a specific path
94    pub fn load_from_path(path: &Path) -> Result<Self> {
95        let content = fs::read_to_string(path)?;
96        let state: VibeState = serde_json::from_str(&content)?;
97        Ok(state)
98    }
99
100    /// Save state to the default location
101    pub fn save(&self) -> Result<()> {
102        let state_path = Self::default_state_path()?;
103        self.save_to_path(&state_path)
104    }
105
106    /// Save state to a specific path
107    pub fn save_to_path(&self, path: &Path) -> Result<()> {
108        // Ensure the parent directory exists
109        if let Some(parent) = path.parent() {
110            fs::create_dir_all(parent)?;
111        }
112
113        let json = serde_json::to_string_pretty(self)?;
114        let mut file = fs::File::create(path)?;
115        file.write_all(json.as_bytes())?;
116        Ok(())
117    }
118
119    /// Get the default state file path
120    fn default_state_path() -> Result<PathBuf> {
121        let _home =
122            dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?;
123        Ok(crate::workspace::constants::get_state_file_path())
124    }
125
126    /// Add or update a recent repository
127    pub fn add_recent_repo(&mut self, repo_id: String, path: PathBuf, app: Option<String>) {
128        let now = Utc::now();
129
130        // Update last used app if provided
131        if let Some(app_name) = &app {
132            self.last_used_apps
133                .insert(repo_id.clone(), app_name.clone());
134        }
135
136        // Check if repo already exists
137        if let Some(existing) = self.recent_repos.iter_mut().find(|r| r.repo_id == repo_id) {
138            existing.last_accessed = now;
139            existing.access_count += 1;
140            if app.is_some() {
141                existing.last_app = app;
142            }
143        } else {
144            // Add new repo
145            self.recent_repos.push(RecentRepo {
146                repo_id: repo_id.clone(),
147                path,
148                last_accessed: now,
149                last_app: app,
150                access_count: 1,
151            });
152        }
153
154        // Sort by last accessed (most recent first) and trim to max
155        self.recent_repos
156            .sort_by(|a, b| b.last_accessed.cmp(&a.last_accessed));
157        self.recent_repos
158            .truncate(self.user_preferences.max_recent_repos);
159    }
160
161    /// Get the most recently accessed repositories
162    pub fn get_recent_repos(&self, limit: usize) -> &[RecentRepo] {
163        let end = limit.min(self.recent_repos.len());
164        &self.recent_repos[..end]
165    }
166
167    /// Get the last used app for a repository
168    pub fn get_last_app(&self, repo_id: &str) -> Option<&String> {
169        self.last_used_apps.get(repo_id)
170    }
171
172    /// Check if this is the first run
173    pub fn is_first_run(&self) -> bool {
174        self.first_run.is_some() && self.recent_repos.is_empty()
175    }
176
177    /// Mark setup wizard as completed
178    pub fn complete_setup_wizard(&mut self) {
179        self.user_preferences.show_setup_wizard = false;
180        self.first_run = None;
181    }
182
183    /// Add a repository group
184    pub fn add_repo_group(&mut self, name: String, repos: Vec<String>) {
185        self.repo_groups.insert(name, repos);
186    }
187
188    /// Get repositories in a group
189    pub fn get_repo_group(&self, name: &str) -> Option<&Vec<String>> {
190        self.repo_groups.get(name)
191    }
192
193    /// Get the most frequently accessed repositories
194    pub fn get_frequent_repos(&self, limit: usize) -> Vec<&RecentRepo> {
195        let mut repos: Vec<&RecentRepo> = self.recent_repos.iter().collect();
196        repos.sort_by(|a, b| b.access_count.cmp(&a.access_count));
197        repos.truncate(limit);
198        repos
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use tempfile::tempdir;
206
207    #[test]
208    fn test_state_persistence() {
209        let dir = tempdir().unwrap();
210        let state_path = dir.path().join("state.json");
211
212        // Create and save state
213        let mut state = VibeState::default();
214        state.add_recent_repo(
215            "test-repo".to_string(),
216            PathBuf::from("/path/to/repo"),
217            Some("vscode".to_string()),
218        );
219        state.save_to_path(&state_path).unwrap();
220
221        // Load and verify
222        let loaded = VibeState::load_from_path(&state_path).unwrap();
223        assert_eq!(loaded.recent_repos.len(), 1);
224        assert_eq!(loaded.recent_repos[0].repo_id, "test-repo");
225        assert_eq!(
226            loaded.get_last_app("test-repo"),
227            Some(&"vscode".to_string())
228        );
229    }
230
231    #[test]
232    fn test_recent_repos_ordering() {
233        let mut state = VibeState::default();
234        state.user_preferences.max_recent_repos = 3;
235
236        // Add repos
237        state.add_recent_repo("repo1".to_string(), PathBuf::from("/repo1"), None);
238        std::thread::sleep(std::time::Duration::from_millis(10));
239        state.add_recent_repo("repo2".to_string(), PathBuf::from("/repo2"), None);
240        std::thread::sleep(std::time::Duration::from_millis(10));
241        state.add_recent_repo("repo3".to_string(), PathBuf::from("/repo3"), None);
242
243        // Access repo1 again - should move to top
244        state.add_recent_repo("repo1".to_string(), PathBuf::from("/repo1"), None);
245
246        let recent = state.get_recent_repos(3);
247        assert_eq!(recent[0].repo_id, "repo1");
248        assert_eq!(recent[0].access_count, 2);
249        assert_eq!(recent[1].repo_id, "repo3");
250        assert_eq!(recent[2].repo_id, "repo2");
251    }
252
253    #[test]
254    fn test_repo_groups() {
255        let mut state = VibeState::default();
256
257        state.add_repo_group(
258            "frontend".to_string(),
259            vec!["web-app".to_string(), "mobile-app".to_string()],
260        );
261        state.add_repo_group(
262            "backend".to_string(),
263            vec!["api".to_string(), "services".to_string()],
264        );
265
266        assert_eq!(state.get_repo_group("frontend").unwrap().len(), 2);
267        assert_eq!(state.get_repo_group("backend").unwrap().len(), 2);
268        assert!(state.get_repo_group("nonexistent").is_none());
269    }
270}