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/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/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        let project_settings_path = project_path.join(".oo");
117
118        let history_path = project_settings_path.join("file_history.yaml");
119        let history = Self::load_history(&history_path);
120
121        let editor_state_path = project_settings_path.join("editor_state.yaml");
122        let editor_state = Self::load_editor_state(&editor_state_path);
123
124        let terminal_state_path = project_path.join(".oo/terminal_state.yaml");
125        let terminal_state = Self::load_terminal_state(&terminal_state_path);
126
127        // Project state path in the cache dir
128        let project_state_path = project_settings_path.join("project_state.yaml");
129
130        // Start debounced async save daemon if runtime available
131        let mut project_state_save_tx: Option<tokio::sync::mpsc::UnboundedSender<Option<PathBuf>>> =
132            None;
133        if tokio::runtime::Handle::try_current().is_ok() {
134            let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Option<PathBuf>>();
135            let ps_path = project_state_path.clone();
136            tokio::spawn(async move {
137                use tokio::time::{Duration, timeout};
138                while let Some(mut pending) = rx.recv().await {
139                    while let Ok(Some(next)) = timeout(Duration::from_millis(500), rx.recv()).await
140                    {
141                        pending = next;
142                    }
143                    let mut ps = ProjectState::default();
144                    // Merge with existing state (preserve command history) if present.
145                    if let Ok(s) = std::fs::read_to_string(&ps_path)
146                        && let Ok(existing) = serde_saphyr::from_str::<ProjectState>(&s) {
147                            ps = existing;
148                        }
149                    ps.last_opened_file = pending.clone();
150                    let path = ps_path.clone();
151                    tokio::task::spawn_blocking(move || {
152                        if let Some(parent) = path.parent() {
153                            let _ = std::fs::create_dir_all(parent);
154                        }
155                        if let Ok(y) = serde_saphyr::to_string(&ps) {
156                            let _ = std::fs::write(&path, y);
157                        }
158                    });
159                }
160            });
161            project_state_save_tx = Some(tx);
162        }
163
164        Ok(Self {
165            project_path,
166            project_settings_path,
167            project_state_path,
168            last_opened_file: None,
169            project_state_save_tx,
170            history,
171            history_path,
172            command_history_persisted: Vec::new(),
173            editor_state,
174            editor_state_path,
175            terminal_state,
176            terminal_state_path,
177            languages: Vec::new(),
178            schema_registry: SchemaRegistry::shared(),
179            extension_manager: ExtensionManager::empty(),
180        })
181    }
182
183    /// Load extensions from the configured extensions directory.
184    /// Must be called after the async op channel (`op_tx`) is available.
185    /// Returns extension default configs (name, yaml) for merging into settings.
186    pub(crate) fn init_extensions(
187        &mut self,
188        op_tx: tokio::sync::mpsc::UnboundedSender<Vec<crate::operation::Operation>>,
189        settings: &crate::settings::Settings,
190        ext_config: &ExtensionConfig,
191    ) -> Vec<(String, String)> {
192        let ext_dir = self.extensions_dir(settings);
193        let langs = detect::detect_project_languages(&self.project_path);
194        self.languages = langs.clone();
195        let (manager, defaults) = ExtensionManager::load_all(&ext_dir, op_tx, langs, ext_config);
196        self.extension_manager = manager;
197        self.extension_manager.detect_project();
198        defaults
199    }
200
201    fn extensions_dir(&self, settings: &crate::settings::Settings) -> PathBuf {
202        let configured = settings.get::<String>("extensions.extensions_dir");
203        if !configured.is_empty() {
204            return PathBuf::from(configured);
205        }
206        // Platform default: ~/.local/share/oo/extensions/ or {FOLDERID_RoamingAppData}\oo\data
207        directories::ProjectDirs::from("com", "cyloncore", "oo")
208            .map(|d| d.data_dir().join("extensions"))
209            .unwrap_or_else(|| PathBuf::from("extensions"))
210    }
211
212    pub(crate) fn has_git_repo(&self) -> bool {
213        self.project_path.join(".git").exists()
214    }
215
216    pub(crate) fn history_entries(&self) -> &[PathBuf] {
217        &self.history.files
218    }
219
220    /// Return absolute paths for all stashed buffers that have unsaved modifications.
221    pub(crate) fn dirty_paths(&self) -> HashSet<PathBuf> {
222        self.editor_state
223            .files
224            .iter()
225            .filter(|(_, state)| state.dirty)
226            .map(|(rel, _)| {
227                self.project_path
228                    .join(rel.replace('/', std::path::MAIN_SEPARATOR_STR))
229            })
230            .collect()
231    }
232
233    /// Record that `path` was opened. Deduplicates.
234    /// Silently ignores save errors (history is best-effort).
235    pub(crate) fn history_push(&mut self, path: &Path) -> Result<()> {
236        let rel = path.strip_prefix(&self.project_path)?.to_path_buf();
237        self.history.files.retain(|f| f != &rel);
238        self.history.files.insert(0, rel);
239        self.history.files.truncate(MAX_HISTORY);
240        if let Err(e) = self.save_history() {
241            log::error!("Failed to save history: {}", e);
242        }
243        Ok(())
244    }
245
246    /// Record the last opened file for this project and schedule a debounced
247    /// async save to the project state cache. This is best-effort and errors
248    /// are logged.
249    pub(crate) fn set_last_opened_file(&mut self, path: Option<PathBuf>) {
250        self.last_opened_file = path.clone();
251        if let Some(tx) = &self.project_state_save_tx {
252            let _ = tx.send(path);
253        } else if let Err(e) = self.save_state() {
254            log::error!("Failed to save project state synchronously: {}", e);
255        }
256    }
257
258    /// Synchronously write the small project state file (.oo/project_state.yaml).
259    pub(crate) fn save_state(&self) -> Result<()> {
260        if let Some(parent) = self.project_state_path.parent() {
261            fs::create_dir_all(parent)?;
262        }
263        let ps = ProjectState {
264            last_opened_file: self.last_opened_file.clone(),
265            command_history: self.command_history_persisted.clone(),
266        };
267        let yaml = serde_saphyr::to_string(&ps)?;
268        fs::write(&self.project_state_path, yaml)?;
269        Ok(())
270    }
271
272    /// Persist command-palette history asynchronously. Accepts the registry history
273    /// (VecDeque) and writes a lightweight serialisable form to `.oo/project_state.yaml`.
274    pub(crate) fn persist_command_history_async(&self, history: &std::collections::VecDeque<crate::commands::HistoryEntry>) {
275        let path = self.project_state_path.clone();
276        let entries: Vec<PersistedHistoryEntry> = history.iter().map(|e| {
277            let args = e.args.iter().map(|(k, v)| {
278                let s = match v {
279                    crate::commands::ArgValue::String(s) => s.clone(),
280                    crate::commands::ArgValue::Bool(b) => b.to_string(),
281                    crate::commands::ArgValue::Int(i) => i.to_string(),
282                    crate::commands::ArgValue::FilePath(p) => p.to_string_lossy().into_owned(),
283                };
284                (k.clone(), s)
285            }).collect::<std::collections::HashMap<String, String>>();
286            PersistedHistoryEntry { id: e.id.to_string(), args, count: Some(e.count) }
287        }).collect();
288
289        tokio::task::spawn_blocking(move || {
290            let mut ps = ProjectState::default();
291            if let Ok(s) = std::fs::read_to_string(&path)
292                && let Ok(existing) = serde_saphyr::from_str::<ProjectState>(&s) {
293                    ps = existing;
294                }
295            ps.command_history = entries;
296            if let Some(parent) = path.parent() {
297                let _ = std::fs::create_dir_all(parent);
298            }
299            if let Ok(y) = serde_saphyr::to_string(&ps) {
300                let _ = std::fs::write(&path, y);
301            }
302        });
303    }
304
305    /// Load the small project state file into memory. Best-effort: missing or
306    /// malformed files are ignored.
307    pub fn restore_state(&mut self) {
308        if let Ok(s) = fs::read_to_string(&self.project_state_path)
309            && let Ok(ps) = serde_saphyr::from_str::<ProjectState>(&s) {
310                self.last_opened_file = ps.last_opened_file;
311                self.command_history_persisted = ps.command_history;
312            }
313    }
314
315    /// Return the persisted command-palette history as a vector of (command_id, args) pairs.
316    pub fn get_persisted_command_history(&self) -> Vec<(String, std::collections::HashMap<String, String>)> {
317        self.command_history_persisted
318            .iter()
319            .map(|e| (e.id.clone(), e.args.clone()))
320            .collect()
321    }
322
323    /// Return a `Buffer` for `path`.
324    ///
325    /// If a suspended buffer is cached, it is restored (cursor, scroll, dirty
326    /// content preserved).  Otherwise the file is read from disk.
327    pub(crate) fn take_buffer(&mut self, path: PathBuf) -> Result<Buffer> {
328        let rel = self.rel_str(&path);
329        if let Some(state) = self.editor_state.files.remove(&rel) {
330            let lines = if state.dirty {
331                state.lines
332            } else {
333                let text = fs::read_to_string(&path)?;
334                if text.is_empty() {
335                    vec![String::new()]
336                } else {
337                    let mut ls: Vec<String> = text.lines().map(|l| l.to_string()).collect();
338                    if text.ends_with('\n') {
339                        ls.push(String::new());
340                    }
341                    ls
342                }
343            };
344            return Ok(Buffer::restore(
345                path,
346                lines,
347                state.selection,
348                state.scroll,
349                state.dirty,
350                state.markers,
351                state.undo_stack,
352                state.redo_stack,
353            ));
354        }
355        Buffer::open(&path)
356    }
357    /// Stash a `Buffer` into the in-memory cache when leaving the editor.
358    /// Also persists fold state to disk immediately.
359    pub(crate) fn stash_buffer(&mut self, buf: Buffer, folds: FoldState) {
360        let abs = match buf.path.as_ref() {
361            Some(p) => p.clone(),
362            None => return, // scratch buffer — nothing to stash
363        };
364        let rel = self.rel_str(&abs);
365        let markers = buf.markers.clone();
366        let lines = buf.lines();
367        let cursor = buf.cursor();
368        let selection = buf.selection().or({
369            Some(Selection {
370                anchor: cursor,
371                active: cursor,
372            })
373        });
374        let undo_stack = buf.undo_stack();
375        let redo_stack = buf.redo_stack();
376        let state = FileEditorState {
377            folds,
378            lines,
379            cursor,
380            selection,
381            scroll: buf.scroll,
382            dirty: buf.is_dirty(),
383            markers,
384            undo_stack,
385            redo_stack,
386        };
387        self.editor_state.files.insert(rel, state);
388        let _ = self.save_editor_state();
389    }
390
391    /// Retrieve the persisted fold state for `path`, if any.
392    pub(crate) fn get_fold_state(&self, path: &Path) -> FoldState {
393        let rel = self.rel_str(path);
394        self.editor_state
395            .files
396            .get(&rel)
397            .map(|s| s.folds.clone())
398            .unwrap_or_default()
399    }
400
401    /// Path relative to project root, forward-slash separated.
402    fn rel_str(&self, path: &Path) -> String {
403        path.strip_prefix(&self.project_path)
404            .unwrap_or(path)
405            .to_string_lossy()
406            .replace('\\', "/")
407    }
408
409    fn load_history(path: &Path) -> HistoryStore {
410        fs::read_to_string(path)
411            .ok()
412            .and_then(|s| serde_saphyr::from_str(&s).ok())
413            .unwrap_or_default()
414    }
415
416    fn save_history(&self) -> Result<()> {
417        if let Some(parent) = self.history_path.parent() {
418            fs::create_dir_all(parent)?;
419        }
420        let yaml = serde_saphyr::to_string(&self.history)?;
421        fs::write(&self.history_path, yaml)?;
422        Ok(())
423    }
424
425    fn load_editor_state(path: &Path) -> EditorStateFile {
426        fs::read_to_string(path)
427            .ok()
428            .and_then(|s| serde_saphyr::from_str(&s).ok())
429            .unwrap_or_default()
430    }
431
432    fn save_editor_state(&self) -> Result<()> {
433        if let Some(parent) = self.editor_state_path.parent() {
434            fs::create_dir_all(parent)?;
435        }
436        let yaml = serde_saphyr::to_string(&self.editor_state)?;
437        fs::write(&self.editor_state_path, yaml)?;
438        Ok(())
439    }
440
441    pub(crate) fn get_terminal_state(&self) -> &TerminalStateStore {
442        &self.terminal_state
443    }
444
445    pub(crate) fn save_terminal_state(&mut self, state: &TerminalStateStore) {
446        self.terminal_state.tabs = state.tabs.clone();
447        self.terminal_state.active = state.active;
448        self.terminal_state.next_id = state.next_id;
449        if let Err(e) = self.persist_terminal_state() {
450            log::error!("Failed to save terminal state: {}", e);
451        }
452    }
453
454    fn load_terminal_state(path: &Path) -> TerminalStateStore {
455        fs::read_to_string(path)
456            .ok()
457            .and_then(|s| serde_saphyr::from_str(&s).ok())
458            .unwrap_or_default()
459    }
460
461    fn persist_terminal_state(&self) -> Result<()> {
462        if let Some(parent) = self.terminal_state_path.parent() {
463            fs::create_dir_all(parent)?;
464        }
465        let yaml = serde_saphyr::to_string(&self.terminal_state)?;
466        fs::write(&self.terminal_state_path, yaml)?;
467        Ok(())
468    }
469}