use std::{
collections::{HashMap, HashSet},
fs,
path::{Path, PathBuf},
sync::{Arc, RwLock},
};
use serde::{Deserialize, Serialize};
use crate::editor::{
Position,
buffer::{Buffer, Marker, SerializableSnapshot},
fold::FoldState,
selection::Selection,
};
use crate::extension::{ExtensionConfig, ExtensionManager};
use crate::prelude::*;
use crate::schema::SchemaRegistry;
use crate::views::terminal::TerminalStateStore;
pub(crate) mod detect;
const MAX_HISTORY: usize = 1000;
#[derive(Debug, Default, Serialize, Deserialize)]
struct HistoryStore {
files: Vec<PathBuf>,
}
#[derive(Debug, Default, Serialize, Deserialize, Clone)]
pub(crate) struct PersistedHistoryEntry {
pub id: String,
#[serde(default)]
pub args: std::collections::HashMap<String, String>,
#[serde(default)]
pub count: Option<u32>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct ProjectState {
pub last_opened_file: Option<PathBuf>,
#[serde(default)]
pub command_history: Vec<PersistedHistoryEntry>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct FileEditorState {
folds: FoldState,
#[serde(default)]
lines: Vec<String>,
#[serde(default)]
cursor: Position,
#[serde(default)]
selection: Option<Selection>,
#[serde(default)]
scroll: usize,
#[serde(default)]
dirty: bool,
#[serde(default)]
markers: Vec<Marker>,
#[serde(default)]
undo_stack: Vec<SerializableSnapshot>,
#[serde(default)]
redo_stack: Vec<SerializableSnapshot>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct EditorStateFile {
files: HashMap<String, FileEditorState>,
}
pub struct Project {
pub project_path: PathBuf,
pub(crate) project_settings_path: PathBuf,
project_state_path: PathBuf,
pub(crate) last_opened_file: Option<PathBuf>,
project_state_save_tx: Option<tokio::sync::mpsc::UnboundedSender<Option<PathBuf>>>,
history: HistoryStore,
history_path: PathBuf,
pub(crate) command_history_persisted: Vec<PersistedHistoryEntry>,
editor_state: EditorStateFile,
editor_state_path: PathBuf,
terminal_state: TerminalStateStore,
terminal_state_path: PathBuf,
pub(crate) languages: Vec<String>,
pub(crate) schema_registry: Arc<RwLock<SchemaRegistry>>,
pub(crate) extension_manager: ExtensionManager,
}
impl Project {
pub fn new(project_path: PathBuf) -> Result<Self> {
let project_path = project_path.canonicalize().unwrap_or(project_path);
let project_settings_path = project_path.join(".oo");
let cache_dir = project_settings_path.join("cache");
let history_path = cache_dir.join("file_history.yaml");
let editor_state_path = cache_dir.join("editor_state.yaml");
let terminal_state_path = cache_dir.join("terminal_state.yaml");
let project_state_path = cache_dir.join("project_state.yaml");
if let Err(e) = std::fs::create_dir_all(&cache_dir) {
eprintln!("warning: could not create cache dir {}: {}", cache_dir.display(), e);
}
let legacy_history = project_settings_path.join("file_history.yaml");
if legacy_history.exists() && !history_path.exists() {
let _ = std::fs::copy(&legacy_history, &history_path);
}
let legacy_editor = project_settings_path.join("editor_state.yaml");
if legacy_editor.exists() && !editor_state_path.exists() {
let _ = std::fs::copy(&legacy_editor, &editor_state_path);
}
let legacy_terminal = project_path.join(".oo/terminal_state.yaml");
if legacy_terminal.exists() && !terminal_state_path.exists() {
let _ = std::fs::copy(&legacy_terminal, &terminal_state_path);
}
let legacy_project_state = project_settings_path.join("project_state.yaml");
if legacy_project_state.exists() && !project_state_path.exists() {
let _ = std::fs::copy(&legacy_project_state, &project_state_path);
}
let history = Self::load_history(&history_path);
let editor_state = Self::load_editor_state(&editor_state_path);
let terminal_state = Self::load_terminal_state(&terminal_state_path);
let mut project_state_save_tx: Option<tokio::sync::mpsc::UnboundedSender<Option<PathBuf>>> =
None;
if tokio::runtime::Handle::try_current().is_ok() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Option<PathBuf>>();
let ps_path = project_state_path.clone();
tokio::spawn(async move {
use tokio::time::{Duration, timeout};
while let Some(mut pending) = rx.recv().await {
while let Ok(Some(next)) = timeout(Duration::from_millis(500), rx.recv()).await
{
pending = next;
}
let mut ps = ProjectState::default();
if let Ok(s) = std::fs::read_to_string(&ps_path)
&& let Ok(existing) = serde_saphyr::from_str::<ProjectState>(&s) {
ps = existing;
}
ps.last_opened_file = pending.clone();
let path = ps_path.clone();
tokio::task::spawn_blocking(move || {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(y) = serde_saphyr::to_string(&ps) {
let _ = std::fs::write(&path, y);
}
});
}
});
project_state_save_tx = Some(tx);
}
Ok(Self {
project_path,
project_settings_path,
project_state_path,
last_opened_file: None,
project_state_save_tx,
history,
history_path,
command_history_persisted: Vec::new(),
editor_state,
editor_state_path,
terminal_state,
terminal_state_path,
languages: Vec::new(),
schema_registry: SchemaRegistry::shared(),
extension_manager: ExtensionManager::empty(),
})
}
pub(crate) fn init_extensions(
&mut self,
op_tx: tokio::sync::mpsc::UnboundedSender<Vec<crate::operation::Operation>>,
settings: &crate::settings::Settings,
ext_config: &ExtensionConfig,
) -> Vec<(String, String)> {
let ext_dir = self.extensions_dir(settings);
let langs = detect::detect_project_languages(&self.project_path);
self.languages = langs.clone();
let (manager, defaults) = ExtensionManager::load_all(&ext_dir, op_tx, langs, ext_config);
self.extension_manager = manager;
self.extension_manager.detect_project();
defaults
}
fn extensions_dir(&self, settings: &crate::settings::Settings) -> PathBuf {
let configured = settings.get::<String>("extensions.extensions_dir");
if !configured.is_empty() {
return PathBuf::from(configured);
}
directories::ProjectDirs::from("com", "cyloncore", "oo")
.map(|d| d.data_dir().join("extensions"))
.unwrap_or_else(|| PathBuf::from("extensions"))
}
pub(crate) fn has_git_repo(&self) -> bool {
self.project_path.join(".git").exists()
}
pub(crate) fn history_entries(&self) -> &[PathBuf] {
&self.history.files
}
pub(crate) fn dirty_paths(&self) -> HashSet<PathBuf> {
self.editor_state
.files
.iter()
.filter(|(_, state)| state.dirty)
.map(|(rel, _)| {
self.project_path
.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR))
})
.collect()
}
pub(crate) fn history_push(&mut self, path: &Path) {
match path.strip_prefix(&self.project_path) {
Ok(rel) => {
let rel = rel.to_path_buf();
self.history.files.retain(|f| f != &rel && f != path);
self.history.files.insert(0, rel);
self.history.files.truncate(MAX_HISTORY);
if let Err(e) = self.save_history() {
log::error!("Failed to save history: {}", e);
}
}
Err(_) => {
self.history.files.retain(|f| f != path);
self.history.files.insert(0, path.to_path_buf());
self.history.files.truncate(MAX_HISTORY);
}
}
}
pub(crate) fn set_last_opened_file(&mut self, path: Option<PathBuf>) {
self.last_opened_file = path.clone();
if let Some(tx) = &self.project_state_save_tx {
let _ = tx.send(path);
} else if let Err(e) = self.save_state() {
log::error!("Failed to save project state synchronously: {}", e);
}
}
pub(crate) fn save_state(&self) -> Result<()> {
if let Some(parent) = self.project_state_path.parent() {
fs::create_dir_all(parent)?;
}
let ps = ProjectState {
last_opened_file: self.last_opened_file.clone(),
command_history: self.command_history_persisted.clone(),
};
let yaml = serde_saphyr::to_string(&ps)?;
fs::write(&self.project_state_path, yaml)?;
Ok(())
}
pub(crate) fn persist_command_history_async(&self, history: &std::collections::VecDeque<crate::commands::HistoryEntry>) {
let path = self.project_state_path.clone();
let entries: Vec<PersistedHistoryEntry> = history.iter().map(|e| {
let args = e.args.iter().map(|(k, v)| {
let s = match v {
crate::commands::ArgValue::String(s) => s.clone(),
crate::commands::ArgValue::Bool(b) => b.to_string(),
crate::commands::ArgValue::Int(i) => i.to_string(),
crate::commands::ArgValue::FilePath(p) => p.to_string_lossy().into_owned(),
};
(k.clone(), s)
}).collect::<std::collections::HashMap<String, String>>();
PersistedHistoryEntry { id: e.id.to_string(), args, count: Some(e.count) }
}).collect();
tokio::task::spawn_blocking(move || {
let mut ps = ProjectState::default();
if let Ok(s) = std::fs::read_to_string(&path)
&& let Ok(existing) = serde_saphyr::from_str::<ProjectState>(&s) {
ps = existing;
}
ps.command_history = entries;
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(y) = serde_saphyr::to_string(&ps) {
let _ = std::fs::write(&path, y);
}
});
}
pub fn restore_state(&mut self) {
if let Ok(s) = fs::read_to_string(&self.project_state_path)
&& let Ok(ps) = serde_saphyr::from_str::<ProjectState>(&s) {
self.last_opened_file = ps.last_opened_file;
self.command_history_persisted = ps.command_history;
}
}
pub fn get_persisted_command_history(&self) -> Vec<(String, std::collections::HashMap<String, String>)> {
self.command_history_persisted
.iter()
.map(|e| (e.id.clone(), e.args.clone()))
.collect()
}
pub(crate) fn take_buffer(&mut self, path: PathBuf) -> Result<Buffer> {
let rel = self.rel_str(&path);
if let Some(state) = self.editor_state.files.remove(&rel) {
let lines = if state.dirty {
state.lines
} else {
let text = fs::read_to_string(&path)?;
if text.is_empty() {
vec![String::new()]
} else {
let mut ls: Vec<String> = text.lines().map(|l| l.to_string()).collect();
if text.ends_with('\n') {
ls.push(String::new());
}
ls
}
};
return Ok(Buffer::restore(
path,
lines,
state.selection,
state.scroll,
state.dirty,
state.markers,
state.undo_stack,
state.redo_stack,
));
}
Buffer::open(&path)
}
pub(crate) fn stash_buffer(&mut self, buf: Buffer, folds: FoldState) {
let abs = match buf.path.as_ref() {
Some(p) => p.clone(),
None => return, };
let rel = self.rel_str(&abs);
let markers = buf.markers.clone();
let cursor = buf.cursor();
let selection = buf.selection().or({
Some(Selection {
anchor: cursor,
active: cursor,
})
});
let undo_stack = buf.undo_stack();
let redo_stack = buf.redo_stack();
let scroll = buf.scroll;
let dirty = buf.is_dirty();
let lines = buf.into_lines();
let state = FileEditorState {
folds,
lines,
cursor,
selection,
scroll,
dirty,
markers,
undo_stack,
redo_stack,
};
self.editor_state.files.insert(rel, state);
let _ = self.save_editor_state();
}
pub(crate) fn get_fold_state(&self, path: &Path) -> FoldState {
let rel = self.rel_str(path);
self.editor_state
.files
.get(&rel)
.map(|s| s.folds.clone())
.unwrap_or_default()
}
fn rel_str(&self, path: &Path) -> String {
path.strip_prefix(&self.project_path)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
fn load_history(path: &Path) -> HistoryStore {
fs::read_to_string(path)
.ok()
.and_then(|s| serde_saphyr::from_str(&s).ok())
.unwrap_or_default()
}
fn save_history(&self) -> Result<()> {
if let Some(parent) = self.history_path.parent() {
fs::create_dir_all(parent)?;
}
let persistent = HistoryStore {
files: self.history.files.iter().filter(|p| p.is_relative()).cloned().collect(),
};
let yaml = serde_saphyr::to_string(&persistent)?;
fs::write(&self.history_path, yaml)?;
Ok(())
}
fn load_editor_state(path: &Path) -> EditorStateFile {
fs::read_to_string(path)
.ok()
.and_then(|s| serde_saphyr::from_str(&s).ok())
.unwrap_or_default()
}
fn save_editor_state(&self) -> Result<()> {
if let Some(parent) = self.editor_state_path.parent() {
fs::create_dir_all(parent)?;
}
let yaml = serde_saphyr::to_string(&self.editor_state)?;
fs::write(&self.editor_state_path, yaml)?;
Ok(())
}
pub(crate) fn get_terminal_state(&self) -> &TerminalStateStore {
&self.terminal_state
}
pub(crate) fn save_terminal_state(&mut self, state: &TerminalStateStore) {
self.terminal_state.tabs = state.tabs.clone();
self.terminal_state.active = state.active;
self.terminal_state.next_id = state.next_id;
if let Err(e) = self.persist_terminal_state() {
log::error!("Failed to save terminal state: {}", e);
}
}
fn load_terminal_state(path: &Path) -> TerminalStateStore {
fs::read_to_string(path)
.ok()
.and_then(|s| serde_saphyr::from_str(&s).ok())
.unwrap_or_default()
}
fn persist_terminal_state(&self) -> Result<()> {
if let Some(parent) = self.terminal_state_path.parent() {
fs::create_dir_all(parent)?;
}
let yaml = serde_saphyr::to_string(&self.terminal_state)?;
fs::write(&self.terminal_state_path, yaml)?;
Ok(())
}
}