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_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 let project_state_path = project_settings_path.join("project_state.yaml");
129
130 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 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 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 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 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 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 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 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 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 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 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 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 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, };
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 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 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}