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 files: Vec<PathBuf>,
29}
30
31#[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#[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#[derive(Debug, Default, Serialize, Deserialize)]
73struct EditorStateFile {
74 files: HashMap<String, FileEditorState>,
75}
76
77pub struct Project {
78 pub project_path: PathBuf,
80 pub(crate) project_settings_path: PathBuf,
82 project_state_path: PathBuf,
84 pub(crate) last_opened_file: Option<PathBuf>,
86 project_state_save_tx: Option<tokio::sync::mpsc::UnboundedSender<Option<PathBuf>>>,
88 history: HistoryStore,
89 history_path: PathBuf,
90 pub(crate) command_history_persisted: Vec<PersistedHistoryEntry>,
92
93 editor_state: EditorStateFile,
95 editor_state_path: PathBuf,
96
97 terminal_state: TerminalStateStore,
99 terminal_state_path: PathBuf,
100
101 pub(crate) languages: Vec<String>,
104
105 pub(crate) schema_registry: Arc<RwLock<SchemaRegistry>>,
108
109 pub(crate) extension_manager: ExtensionManager,
112}
113
114impl Project {
115 pub fn new(project_path: PathBuf) -> Result<Self> {
116 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 let project_state_path = cache_dir.join("project_state.yaml");
127
128 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 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 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 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 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 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 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 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 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 }
284 }
285 }
286
287 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 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 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 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 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 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 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, };
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 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 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 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}