Skip to main content

oo_ide/
project.rs

1use std::{
2    collections::{HashMap, HashSet},
3    fs,
4    path::{Path, PathBuf},
5    sync::{Arc, RwLock},
6};
7
8use serde::{Deserialize, Serialize};
9
10use crate::editor::{
11    Position,
12    buffer::{Buffer, Marker, SerializableSnapshot},
13    fold::FoldState,
14    selection::Selection,
15};
16use crate::extension::{ExtensionConfig, ExtensionManager};
17use crate::prelude::*;
18use crate::schema::SchemaRegistry;
19use crate::views::terminal::TerminalStateStore;
20
21pub(crate) mod detect;
22
23const MAX_HISTORY: usize = 1000;
24
25#[derive(Debug, Default, Serialize, Deserialize)]
26struct HistoryStore {
27    /// File paths relative to the project root, most-recent first.
28    files: Vec<PathBuf>,
29}
30
31/// Light-weight persisted project state file (.oo/cache/project_state.yaml)
32#[derive(Debug, Default, Serialize, Deserialize, Clone)]
33pub(crate) struct PersistedHistoryEntry {
34    pub id: String,
35    #[serde(default)]
36    pub args: std::collections::HashMap<String, String>,
37    #[serde(default)]
38    pub count: Option<u32>,
39}
40
41#[derive(Debug, Default, Serialize, Deserialize)]
42struct ProjectState {
43    pub last_opened_file: Option<PathBuf>,
44    #[serde(default)]
45    pub command_history: Vec<PersistedHistoryEntry>,
46}
47
48/// Serialisable record for one file's fold state.
49#[derive(Debug, Default, Serialize, Deserialize)]
50struct FileEditorState {
51    folds: FoldState,
52    #[serde(default)]
53    lines: Vec<String>,
54    #[serde(default)]
55    cursor: Position,
56    #[serde(default)]
57    selection: Option<Selection>,
58    #[serde(default)]
59    scroll: usize,
60    #[serde(default)]
61    dirty: bool,
62    #[serde(default)]
63    markers: Vec<Marker>,
64    #[serde(default)]
65    undo_stack: Vec<SerializableSnapshot>,
66    #[serde(default)]
67    redo_stack: Vec<SerializableSnapshot>,
68}
69
70/// The full `.oo/editor_state.yaml` file.
71/// Maps relative-path strings → per-file state.
72#[derive(Debug, Default, Serialize, Deserialize)]
73struct EditorStateFile {
74    files: HashMap<String, FileEditorState>,
75}
76
77pub struct Project {
78    /// Path to the root project directory
79    pub project_path: PathBuf,
80    /// Path to the project settings (aka `.oo`)
81    pub(crate) project_settings_path: PathBuf,
82    /// Path to the per-project persisted state file (.oo/cache/project_state.yaml)
83    project_state_path: PathBuf,
84    /// Last opened file for this project (cached in IDE cache)
85    pub(crate) last_opened_file: Option<PathBuf>,
86    /// Channel sender for debounced async saves; created by start_state_save_daemon().
87    project_state_save_tx: Option<tokio::sync::mpsc::UnboundedSender<Option<PathBuf>>>,
88    history: HistoryStore,
89    history_path: PathBuf,
90    /// Persisted command-palette history loaded from project_state.yaml
91    pub(crate) command_history_persisted: Vec<PersistedHistoryEntry>,
92
93    /// Per-file editor state (folds etc.) — loaded from and saved to disk.
94    editor_state: EditorStateFile,
95    editor_state_path: PathBuf,
96
97    /// Terminal state — tab titles/commands saved to .oo/terminal_state.yaml.
98    terminal_state: TerminalStateStore,
99    terminal_state_path: PathBuf,
100
101    /// Languages detected in the project (e.g. ["rust", "python"]). Filled at
102    /// startup by `init_extensions`.
103    pub(crate) languages: Vec<String>,
104
105    /// Schema registry shared with extensions; packaged schemas are registered
106    /// when the extension is enabled.
107    pub(crate) schema_registry: Arc<RwLock<SchemaRegistry>>,
108
109    /// Loaded WASM extensions. Initialized empty; call `init_extensions` once
110    /// the async op channel is available.
111    pub(crate) extension_manager: ExtensionManager,
112}
113
114impl Project {
115    pub fn new(project_path: PathBuf) -> Result<Self> {
116        // Canonicalize so that strip_prefix works correctly when comparing against
117        // absolute paths (e.g. from terminal link detection or LSP responses).
118        let project_path = project_path.canonicalize().unwrap_or(project_path);
119        let project_settings_path = project_path.join(".oo");
120        let cache_dir = project_settings_path.join("cache");
121
122        let history_path = cache_dir.join("file_history.yaml");
123        let editor_state_path = cache_dir.join("editor_state.yaml");
124        let terminal_state_path = cache_dir.join("terminal_state.yaml");
125        // Project state path in the cache dir
126        let project_state_path = cache_dir.join("project_state.yaml");
127
128        // Ensure cache dir exists and migrate any legacy files from .oo to .oo/cache
129        if let Err(e) = std::fs::create_dir_all(&cache_dir) {
130            eprintln!("warning: could not create cache dir {}: {}", cache_dir.display(), e);
131        }
132        let legacy_history = project_settings_path.join("file_history.yaml");
133        if legacy_history.exists() && !history_path.exists() {
134            let _ = std::fs::copy(&legacy_history, &history_path);
135        }
136        let legacy_editor = project_settings_path.join("editor_state.yaml");
137        if legacy_editor.exists() && !editor_state_path.exists() {
138            let _ = std::fs::copy(&legacy_editor, &editor_state_path);
139        }
140        let legacy_terminal = project_path.join(".oo/terminal_state.yaml");
141        if legacy_terminal.exists() && !terminal_state_path.exists() {
142            let _ = std::fs::copy(&legacy_terminal, &terminal_state_path);
143        }
144        let legacy_project_state = project_settings_path.join("project_state.yaml");
145        if legacy_project_state.exists() && !project_state_path.exists() {
146            let _ = std::fs::copy(&legacy_project_state, &project_state_path);
147        }
148
149        let history = Self::load_history(&history_path);
150        let editor_state = Self::load_editor_state(&editor_state_path);
151        let terminal_state = Self::load_terminal_state(&terminal_state_path);
152
153        // Start debounced async save daemon if runtime available
154        let mut project_state_save_tx: Option<tokio::sync::mpsc::UnboundedSender<Option<PathBuf>>> =
155            None;
156        if tokio::runtime::Handle::try_current().is_ok() {
157            let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Option<PathBuf>>();
158            let ps_path = project_state_path.clone();
159            tokio::spawn(async move {
160                use tokio::time::{Duration, timeout};
161                while let Some(mut pending) = rx.recv().await {
162                    while let Ok(Some(next)) = timeout(Duration::from_millis(500), rx.recv()).await
163                    {
164                        pending = next;
165                    }
166                    let mut ps = ProjectState::default();
167                    // Merge with existing state (preserve command history) if present.
168                    if let Ok(s) = std::fs::read_to_string(&ps_path)
169                        && let Ok(existing) = serde_saphyr::from_str::<ProjectState>(&s) {
170                            ps = existing;
171                        }
172                    ps.last_opened_file = pending.clone();
173                    let path = ps_path.clone();
174                    tokio::task::spawn_blocking(move || {
175                        if let Some(parent) = path.parent() {
176                            let _ = std::fs::create_dir_all(parent);
177                        }
178                        if let Ok(y) = serde_saphyr::to_string(&ps) {
179                            let _ = std::fs::write(&path, y);
180                        }
181                    });
182                }
183            });
184            project_state_save_tx = Some(tx);
185        }
186
187        Ok(Self {
188            project_path,
189            project_settings_path,
190            project_state_path,
191            last_opened_file: None,
192            project_state_save_tx,
193            history,
194            history_path,
195            command_history_persisted: Vec::new(),
196            editor_state,
197            editor_state_path,
198            terminal_state,
199            terminal_state_path,
200            languages: Vec::new(),
201            schema_registry: SchemaRegistry::shared(),
202            extension_manager: ExtensionManager::empty(),
203        })
204    }
205
206    /// Load extensions from the configured extensions directory.
207    /// Must be called after the async op channel (`op_tx`) is available.
208    /// Returns extension default configs (name, yaml) for merging into settings.
209    pub(crate) fn init_extensions(
210        &mut self,
211        op_tx: tokio::sync::mpsc::UnboundedSender<Vec<crate::operation::Operation>>,
212        settings: &crate::settings::Settings,
213        ext_config: &ExtensionConfig,
214    ) -> Vec<(String, String)> {
215        let ext_dir = self.extensions_dir(settings);
216        let langs = detect::detect_project_languages(&self.project_path);
217        self.languages = langs.clone();
218        let (manager, defaults) = ExtensionManager::load_all(&ext_dir, op_tx, langs, ext_config);
219        self.extension_manager = manager;
220        self.extension_manager.detect_project();
221        defaults
222    }
223
224    fn extensions_dir(&self, settings: &crate::settings::Settings) -> PathBuf {
225        let configured = settings.get::<String>("extensions.extensions_dir");
226        if !configured.is_empty() {
227            return PathBuf::from(configured);
228        }
229        // Platform default: ~/.local/share/oo/extensions/ or {FOLDERID_RoamingAppData}\oo\data
230        directories::ProjectDirs::from("com", "cyloncore", "oo")
231            .map(|d| d.data_dir().join("extensions"))
232            .unwrap_or_else(|| PathBuf::from("extensions"))
233    }
234
235    pub(crate) fn has_git_repo(&self) -> bool {
236        self.project_path.join(".git").exists()
237    }
238
239    pub(crate) fn history_entries(&self) -> &[PathBuf] {
240        &self.history.files
241    }
242
243    /// Return absolute paths for all stashed buffers that have unsaved modifications.
244    pub(crate) fn dirty_paths(&self) -> HashSet<PathBuf> {
245        self.editor_state
246            .files
247            .iter()
248            .filter(|(_, state)| state.dirty)
249            .map(|(rel, _)| {
250                self.project_path
251                    .join(rel.replace('/', std::path::MAIN_SEPARATOR_STR))
252            })
253            .collect()
254    }
255
256    /// Record that `path` was opened. Deduplicates.
257    ///
258    /// Project-relative files are stored as relative paths and persisted to
259    /// disk immediately. External files (outside the project root) are stored
260    /// as absolute paths in the in-memory list only — they appear in the
261    /// Recent Files pane for the current session but are not written to disk
262    /// and are purged on exit.
263    ///
264    /// Always succeeds (best-effort; errors are logged).
265    pub(crate) fn history_push(&mut self, path: &Path) {
266        match path.strip_prefix(&self.project_path) {
267            Ok(rel) => {
268                let rel = rel.to_path_buf();
269                // Deduplicate: remove any previous entry for this file (either form).
270                self.history.files.retain(|f| f != &rel && f != path);
271                self.history.files.insert(0, rel);
272                self.history.files.truncate(MAX_HISTORY);
273                if let Err(e) = self.save_history() {
274                    log::error!("Failed to save history: {}", e);
275                }
276            }
277            Err(_) => {
278                // External file: record absolute path in-memory only.
279                self.history.files.retain(|f| f != path);
280                self.history.files.insert(0, path.to_path_buf());
281                self.history.files.truncate(MAX_HISTORY);
282                // Intentionally no disk save: external entries are session-only.
283            }
284        }
285    }
286
287    /// Record the last opened file for this project and schedule a debounced
288    /// async save to the project state cache. This is best-effort and errors
289    /// are logged.
290    pub(crate) fn set_last_opened_file(&mut self, path: Option<PathBuf>) {
291        self.last_opened_file = path.clone();
292        if let Some(tx) = &self.project_state_save_tx {
293            let _ = tx.send(path);
294        } else if let Err(e) = self.save_state() {
295            log::error!("Failed to save project state synchronously: {}", e);
296        }
297    }
298
299    /// Synchronously write the small project state file (.oo/project_state.yaml).
300    pub(crate) fn save_state(&self) -> Result<()> {
301        if let Some(parent) = self.project_state_path.parent() {
302            fs::create_dir_all(parent)?;
303        }
304        let ps = ProjectState {
305            last_opened_file: self.last_opened_file.clone(),
306            command_history: self.command_history_persisted.clone(),
307        };
308        let yaml = serde_saphyr::to_string(&ps)?;
309        fs::write(&self.project_state_path, yaml)?;
310        Ok(())
311    }
312
313    /// Persist command-palette history asynchronously. Accepts the registry history
314    /// (VecDeque) and writes a lightweight serialisable form to `.oo/project_state.yaml`.
315    pub(crate) fn persist_command_history_async(&self, history: &std::collections::VecDeque<crate::commands::HistoryEntry>) {
316        let path = self.project_state_path.clone();
317        let entries: Vec<PersistedHistoryEntry> = history.iter().map(|e| {
318            let args = e.args.iter().map(|(k, v)| {
319                let s = match v {
320                    crate::commands::ArgValue::String(s) => s.clone(),
321                    crate::commands::ArgValue::Bool(b) => b.to_string(),
322                    crate::commands::ArgValue::Int(i) => i.to_string(),
323                    crate::commands::ArgValue::FilePath(p) => p.to_string_lossy().into_owned(),
324                };
325                (k.clone(), s)
326            }).collect::<std::collections::HashMap<String, String>>();
327            PersistedHistoryEntry { id: e.id.to_string(), args, count: Some(e.count) }
328        }).collect();
329
330        tokio::task::spawn_blocking(move || {
331            let mut ps = ProjectState::default();
332            if let Ok(s) = std::fs::read_to_string(&path)
333                && let Ok(existing) = serde_saphyr::from_str::<ProjectState>(&s) {
334                    ps = existing;
335                }
336            ps.command_history = entries;
337            if let Some(parent) = path.parent() {
338                let _ = std::fs::create_dir_all(parent);
339            }
340            if let Ok(y) = serde_saphyr::to_string(&ps) {
341                let _ = std::fs::write(&path, y);
342            }
343        });
344    }
345
346    /// Load the small project state file into memory. Best-effort: missing or
347    /// malformed files are ignored.
348    pub fn restore_state(&mut self) {
349        if let Ok(s) = fs::read_to_string(&self.project_state_path)
350            && let Ok(ps) = serde_saphyr::from_str::<ProjectState>(&s) {
351                self.last_opened_file = ps.last_opened_file;
352                self.command_history_persisted = ps.command_history;
353            }
354    }
355
356    /// Return the persisted command-palette history as a vector of (command_id, args) pairs.
357    pub fn get_persisted_command_history(&self) -> Vec<(String, std::collections::HashMap<String, String>)> {
358        self.command_history_persisted
359            .iter()
360            .map(|e| (e.id.clone(), e.args.clone()))
361            .collect()
362    }
363
364    /// Return a `Buffer` for `path`.
365    ///
366    /// If a suspended buffer is cached, it is restored (cursor, scroll, dirty
367    /// content preserved).  Otherwise the file is read from disk.
368    pub(crate) fn take_buffer(&mut self, path: PathBuf) -> Result<Buffer> {
369        let rel = self.rel_str(&path);
370        if let Some(state) = self.editor_state.files.remove(&rel) {
371            let lines = if state.dirty {
372                state.lines
373            } else {
374                let text = fs::read_to_string(&path)?;
375                if text.is_empty() {
376                    vec![String::new()]
377                } else {
378                    let mut ls: Vec<String> = text.lines().map(|l| l.to_string()).collect();
379                    if text.ends_with('\n') {
380                        ls.push(String::new());
381                    }
382                    ls
383                }
384            };
385            return Ok(Buffer::restore(
386                path,
387                lines,
388                state.selection,
389                state.scroll,
390                state.dirty,
391                state.markers,
392                state.undo_stack,
393                state.redo_stack,
394            ));
395        }
396        Buffer::open(&path)
397    }
398    /// Stash a `Buffer` into the in-memory cache when leaving the editor.
399    /// Also persists fold state to disk immediately.
400    pub(crate) fn stash_buffer(&mut self, buf: Buffer, folds: FoldState) {
401        let abs = match buf.path.as_ref() {
402            Some(p) => p.clone(),
403            None => return, // scratch buffer — nothing to stash
404        };
405        let rel = self.rel_str(&abs);
406        let markers = buf.markers.clone();
407        let cursor = buf.cursor();
408        let selection = buf.selection().or({
409            Some(Selection {
410                anchor: cursor,
411                active: cursor,
412            })
413        });
414        let undo_stack = buf.undo_stack();
415        let redo_stack = buf.redo_stack();
416        let scroll = buf.scroll;
417        let dirty = buf.is_dirty();
418        let lines = buf.into_lines();
419        let state = FileEditorState {
420            folds,
421            lines,
422            cursor,
423            selection,
424            scroll,
425            dirty,
426            markers,
427            undo_stack,
428            redo_stack,
429        };
430        self.editor_state.files.insert(rel, state);
431        let _ = self.save_editor_state();
432    }
433
434    /// Retrieve the persisted fold state for `path`, if any.
435    pub(crate) fn get_fold_state(&self, path: &Path) -> FoldState {
436        let rel = self.rel_str(path);
437        self.editor_state
438            .files
439            .get(&rel)
440            .map(|s| s.folds.clone())
441            .unwrap_or_default()
442    }
443
444    /// Path relative to project root, forward-slash separated.
445    fn rel_str(&self, path: &Path) -> String {
446        path.strip_prefix(&self.project_path)
447            .unwrap_or(path)
448            .to_string_lossy()
449            .replace('\\', "/")
450    }
451
452    fn load_history(path: &Path) -> HistoryStore {
453        fs::read_to_string(path)
454            .ok()
455            .and_then(|s| serde_saphyr::from_str(&s).ok())
456            .unwrap_or_default()
457    }
458
459    fn save_history(&self) -> Result<()> {
460        if let Some(parent) = self.history_path.parent() {
461            fs::create_dir_all(parent)?;
462        }
463        // Only persist project-relative entries. External files (absolute paths)
464        // are session-only and must not be written to disk.
465        let persistent = HistoryStore {
466            files: self.history.files.iter().filter(|p| p.is_relative()).cloned().collect(),
467        };
468        let yaml = serde_saphyr::to_string(&persistent)?;
469        fs::write(&self.history_path, yaml)?;
470        Ok(())
471    }
472
473    fn load_editor_state(path: &Path) -> EditorStateFile {
474        fs::read_to_string(path)
475            .ok()
476            .and_then(|s| serde_saphyr::from_str(&s).ok())
477            .unwrap_or_default()
478    }
479
480    fn save_editor_state(&self) -> Result<()> {
481        if let Some(parent) = self.editor_state_path.parent() {
482            fs::create_dir_all(parent)?;
483        }
484        let yaml = serde_saphyr::to_string(&self.editor_state)?;
485        fs::write(&self.editor_state_path, yaml)?;
486        Ok(())
487    }
488
489    pub(crate) fn get_terminal_state(&self) -> &TerminalStateStore {
490        &self.terminal_state
491    }
492
493    pub(crate) fn save_terminal_state(&mut self, state: &TerminalStateStore) {
494        self.terminal_state.tabs = state.tabs.clone();
495        self.terminal_state.active = state.active;
496        self.terminal_state.next_id = state.next_id;
497        if let Err(e) = self.persist_terminal_state() {
498            log::error!("Failed to save terminal state: {}", e);
499        }
500    }
501
502    fn load_terminal_state(path: &Path) -> TerminalStateStore {
503        fs::read_to_string(path)
504            .ok()
505            .and_then(|s| serde_saphyr::from_str(&s).ok())
506            .unwrap_or_default()
507    }
508
509    fn persist_terminal_state(&self) -> Result<()> {
510        if let Some(parent) = self.terminal_state_path.parent() {
511            fs::create_dir_all(parent)?;
512        }
513        let yaml = serde_saphyr::to_string(&self.terminal_state)?;
514        fs::write(&self.terminal_state_path, yaml)?;
515        Ok(())
516    }
517}