diff --git a/.vscode/settings.json b/.vscode/settings.json index ffd7239..3964c18 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,8 +6,8 @@ "files.trimFinalNewlines": true, "files.trimTrailingWhitespace": true, "rust-analyzer.cargo.features": [ - "llm", - "native" + "native", + "llm" ], "rust-analyzer.cargo.noDefaultFeatures": true, "rust-analyzer.cargo.allFeatures": false diff --git a/src/editors/asset_editor.rs b/src/editors/asset_editor.rs index 2d16f4b..69829e0 100644 --- a/src/editors/asset_editor.rs +++ b/src/editors/asset_editor.rs @@ -1,6 +1,10 @@ use egui::{TextEdit, vec2}; -use crate::{PROJECT_FOLDER, util}; +use crate::{ + PROJECT_FOLDER, + filesystem::{FILESYSTEM, FsError, LegacyFileSystem}, + util, +}; #[derive(Debug, Clone)] pub struct Asset { @@ -25,15 +29,14 @@ impl Asset { println!("old_path: {old_path:?}"); println!("new_path: {new_path:?}"); - // move from src dir to name path - if let Err(err) = std::fs::rename(&old_path, &new_path) { + if let Err(FsError::Io(err)) = FILESYSTEM.rename(&old_path, &new_path) { match err.kind() { std::io::ErrorKind::NotFound => { let dir = new_path.parent().unwrap(); if !dir.exists() { - std::fs::create_dir_all(dir).unwrap(); + FILESYSTEM.mkdir(dir).unwrap(); } - std::fs::rename(&old_path, &new_path).unwrap(); + FILESYSTEM.rename(&old_path, &new_path).unwrap(); } _ => panic!("Failed to rename file: {err}"), } @@ -73,7 +76,7 @@ impl Asset { ui.separator(); - if let Ok(bytes) = std::fs::read(Self::path(&self.name)) { + if let Ok(bytes) = FILESYSTEM.read_bytes(&Self::path(&self.name)) { let image_source = egui::ImageSource::Bytes { uri: std::borrow::Cow::Owned(self.name.clone()), bytes: bytes.into(), diff --git a/src/editors/content_editor.rs b/src/editors/content_editor.rs index a1114a9..4aaaa65 100644 --- a/src/editors/content_editor.rs +++ b/src/editors/content_editor.rs @@ -1,10 +1,12 @@ +use std::path::Path; + use egui::TextEdit; use egui_commonmark::{CommonMarkCache, CommonMarkViewer}; use serde::{self, Deserialize, Serialize}; use crate::{ - PROJECT_FOLDER, editors::{settings_editor::ProjectSettings, tags::Tag}, + filesystem::{FILESYSTEM, LegacyFileSystem}, util, }; @@ -43,10 +45,7 @@ impl Clone for MainEditor { #[derive(Serialize, Deserialize, Clone, Debug)] pub struct ContentSection { - #[serde(default)] pub title: String, - - #[serde(default)] pub id: String, #[serde(default)] @@ -80,21 +79,16 @@ impl ContentSection { } pub fn save(&mut self) -> Result<(), Box> { - let path = PROJECT_FOLDER - .join("documents") - .join(format!("{}.json", &self.id)); - - let content = serde_json::to_string_pretty(self)?; - std::fs::write(path, content)?; + FILESYSTEM.write( + Path::new(&format!("documents/{id}.json", id = &self.id)), + self.clone(), + )?; self.saved = true; Ok(()) } pub fn load(id: &str) -> Result> { - let path = PROJECT_FOLDER.join("documents").join(format!("{id}.json")); - - let content = std::fs::read_to_string(&path)?; - let mut section: Self = serde_json::from_str(&content)?; + let mut section: Self = FILESYSTEM.read(Path::new(&format!("documents/{id}.json")))?; section.saved = true; section.id = id.to_string(); Ok(section) @@ -175,12 +169,12 @@ impl MainEditor { // delete button if ui.button("Delete").clicked() { - std::fs::remove_file( - PROJECT_FOLDER - .join("documents") - .join(format!("{}.json", self.content.id)), - ) - .unwrap(); + FILESYSTEM + .delete(Path::new(&format!( + "documents/{id}.json", + id = &self.content.id + ))) + .unwrap(); *self = Self::new(); } @@ -195,7 +189,9 @@ impl MainEditor { // assistant toggle #[cfg(feature = "llm")] - ui.checkbox(&mut self.show_ai, "AI Assistant"); + if project.ai_enabled() { + ui.checkbox(&mut self.show_ai, "AI Assistant"); + } // editor toggle ui.checkbox(&mut self.editor_separate_window, "Pop out editor"); @@ -243,7 +239,7 @@ impl MainEditor { ui.separator(); #[cfg(feature = "llm")] - if self.show_ai { + if self.show_ai && project.ai_enabled() { let dialog = &mut self.dialog; dialog.content = self.content.content.clone(); @@ -324,7 +320,7 @@ impl MainEditor { }); } - fn editor_ui(&mut self, ui: &mut egui::Ui, project: &mut ProjectSettings) { + fn editor_ui(&mut self, ui: &mut egui::Ui, _project: &mut ProjectSettings) { let _response = egui::ScrollArea::both() .auto_shrink([false, false]) .id_salt("editor_scroll") @@ -342,7 +338,7 @@ impl MainEditor { ui.set_min_width(max_width as f32); - ui.add( + let response = ui.add( TextEdit::multiline(&mut self.content.content) .id_source("MainEditor_editor") .font(egui::TextStyle::Monospace) @@ -352,6 +348,9 @@ impl MainEditor { .hint_text("Type here...") .desired_width(max_width as f32), ); + if response.changed() { + self.content.saved = false; + } }); }); }); diff --git a/src/editors/note_editor.rs b/src/editors/note_editor.rs index 7232b68..fd76910 100644 --- a/src/editors/note_editor.rs +++ b/src/editors/note_editor.rs @@ -1,13 +1,19 @@ -use std::fs; +use std::path::Path; use egui::TextEdit; use serde::{Deserialize, Serialize}; -use crate::{PROJECT_FOLDER, editors::tags::Tag, util}; +use crate::{ + editors::tags::Tag, + filesystem::{FILESYSTEM, LegacyFileSystem}, + util, +}; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Note { + pub id: String, pub name: String, + #[serde(default)] pub content: String, #[serde(default)] @@ -17,12 +23,14 @@ pub struct Note { pub tags: Vec, #[serde(skip)] - pub id: String, - - #[serde(skip)] + #[serde(default = "default_saved")] pub saved: bool, } +pub fn default_saved() -> bool { + true +} + impl Default for Note { fn default() -> Self { Self { @@ -48,18 +56,15 @@ impl Note { } } - pub fn save(&mut self) -> std::io::Result<()> { + pub fn save(&mut self) -> Result<(), Box> { let id = &self.id; - let path = PROJECT_FOLDER.join("notes").join(format!("{id}.json")); - fs::write(path, serde_json::to_string(&self)?)?; + FILESYSTEM.write(Path::new(&format!("notes/{id}.json")), self.clone())?; self.saved = true; Ok(()) } - pub fn load(id: &str) -> std::io::Result { - let path = PROJECT_FOLDER.join("notes").join(format!("{id}.json")); - let content = fs::read_to_string(path)?; - let mut note: Note = serde_json::from_str(&content)?; + pub fn load(id: &str) -> Result> { + let mut note: Self = FILESYSTEM.read(Path::new(&format!("notes/{id}.json")))?; note.id = id.to_string(); note.saved = true; Ok(note) diff --git a/src/editors/object_editor.rs b/src/editors/object_editor.rs index 79c10f7..367758c 100644 --- a/src/editors/object_editor.rs +++ b/src/editors/object_editor.rs @@ -1,6 +1,7 @@ use core::f32; use egui::{CollapsingHeader, RichText, Sense, TextEdit, Ui, UiBuilder, vec2}; use serde::{Deserialize, Serialize}; +use std::path::Path; use crate::{ PROJECT_FOLDER, RightPanelContent, @@ -8,6 +9,7 @@ use crate::{ tags::Tag, template_editor::{FieldValue, Template}, }, + filesystem::{FILESYSTEM, LegacyFileSystem}, util, }; @@ -81,21 +83,14 @@ impl ObjectInstance { } pub fn save(&mut self) -> Result<(), Box> { - let path = PROJECT_FOLDER - .join("objects") - .join(format!("{}.json", &self.id)); - - let content = serde_json::to_string_pretty(self)?; - std::fs::write(&path, content)?; + let id = &self.id; + FILESYSTEM.write(Path::new(&format!("objects/{id}.json")), self.clone())?; self.saved = true; Ok(()) } pub fn load(id: &str) -> Result> { - let path = PROJECT_FOLDER.join("objects").join(format!("{id}.json")); - - let content = std::fs::read_to_string(&path)?; - let mut instance: ObjectInstance = serde_json::from_str(&content)?; + let mut instance: Self = FILESYSTEM.read(Path::new(&format!("objects/{id}.json")))?; instance.saved = true; Ok(instance) } @@ -137,12 +132,10 @@ impl ObjectInstance { } if ui.button("Delete").clicked() { - std::fs::remove_file( - PROJECT_FOLDER - .join("objects") - .join(format!("{}.json", self.id)), - ) - .unwrap(); + let id = &self.id; + FILESYSTEM + .delete(Path::new(&format!("objects/{id}.json"))) + .expect("Failed to delete object"); *right_panel = Some(RightPanelContent::None); } @@ -274,7 +267,7 @@ impl ObjectInstance { if !value.is_empty() { let path = PROJECT_FOLDER.join("assets").join(&value); - if let Ok(bytes) = std::fs::read(&path) { + if let Ok(bytes) = FILESYSTEM.read_bytes(&path) { let image_source = egui::ImageSource::Bytes { uri: std::borrow::Cow::Owned(path.to_str().unwrap().to_string()), bytes: bytes.into(), diff --git a/src/editors/settings_editor.rs b/src/editors/settings_editor.rs index 6434ac3..e4df7e9 100644 --- a/src/editors/settings_editor.rs +++ b/src/editors/settings_editor.rs @@ -2,9 +2,12 @@ use chrono::NaiveDate; use egui::TextEdit; use egui_extras::DatePickerButton; use serde::{Deserialize, Serialize}; -use std::{io::Read, path::PathBuf, sync::LazyLock}; +use std::path::{Path, PathBuf}; -use crate::{PROJECT_FOLDER, util::saved_status}; +use crate::{ + filesystem::{FILESYSTEM, LegacyFileSystem}, + util::saved_status, +}; #[derive(Serialize, Deserialize, Clone)] pub struct ProjectSettings { @@ -14,6 +17,7 @@ pub struct ProjectSettings { project_description: String, // AI settings + #[cfg(feature = "llm")] pub ai_context: String, // settings @@ -29,17 +33,6 @@ pub struct ProjectSettings { pub saved: bool, } -static GLOBAL_SETTINGS_PATH: LazyLock = - LazyLock::new(|| match std::env::var("XDG_CONFIG_HOME") { - Ok(path) => path + "/worldcoder/settings.json", - Err(_) => { - eprintln!( - "XDG_CONFIG_HOME not set, using default path of ~/.config/worldcoder/settings.json" - ); - "~/.config/worldcoder/settings.json".to_string() - } - }); - impl ProjectSettings { #[allow(dead_code)] pub fn new() -> Self { @@ -47,25 +40,10 @@ impl ProjectSettings { } pub fn load() -> Self { - let project_path = PROJECT_FOLDER.join("project.json"); - - let mut file = if let Ok(file) = std::fs::File::open(project_path) { - file - } else { - return Self::default(); - }; - - let mut contents = String::new(); - file.read_to_string(&mut contents).unwrap(); - if let Ok(mut proj) = serde_json::from_str::(&contents) { + if let Ok(mut proj) = FILESYSTEM.read::(Path::new("project.json")) { proj.saved = true; - - // load global settings proj.global_settings = EditorSettings::load_global(); - - // load local overrides proj.local_overrides = EditorSettings::load(); - proj } else { Self::default() @@ -73,9 +51,9 @@ impl ProjectSettings { } pub fn save(&mut self) { - let project_path = PROJECT_FOLDER.join("project.json"); - let content = serde_json::to_string_pretty(self).unwrap(); - std::fs::write(project_path, content).unwrap(); + FILESYSTEM + .write(Path::new("project.json"), self.clone()) + .unwrap(); self.global_settings.save(); self.local_overrides.save(); @@ -83,7 +61,93 @@ impl ProjectSettings { self.saved = true; } - #[allow(dead_code)] + #[allow(unused)] + fn config_str_override( + label: &str, + field: &mut Option, + default: &str, + ui: &mut egui::Ui, + ) -> bool { + let mut changed = false; + + ui.label(label); + + if let Some(value) = field { + if ui.text_edit_singleline(value).changed() { + changed = true; + }; + + if ui.button("Remove Override").clicked() { + *field = None; + changed = true; + } + } else if ui.button("Override").clicked() { + *field = Some(default.to_string()); + changed = true; + } + + ui.end_row(); + changed + } + + #[allow(unused)] + fn config_bool_override( + label: &str, + field: &mut Option, + default: bool, + ui: &mut egui::Ui, + ) -> bool { + let mut changed = false; + + ui.label(label); + + if let Some(value) = field { + if ui.checkbox(value, "Enable AI").changed() { + changed = true; + }; + + if ui.button("Remove Override").clicked() { + *field = None; + changed = true; + } + } else if ui.button("Override").clicked() { + *field = Some(default); + changed = true; + } + + ui.end_row(); + changed + } + + #[allow(unused)] + fn config_str(field: &mut String, label: &str, ui: &mut egui::Ui) -> bool { + let mut changed = false; + + ui.label(label); + if ui.text_edit_singleline(field).changed() { + changed = true; + } + + ui.end_row(); + + changed + } + + #[allow(unused)] + fn config_bool(label: &str, field: &mut bool, ui: &mut egui::Ui) -> bool { + let mut changed = false; + + ui.label(label); + if ui.checkbox(field, "Enable AI").changed() { + changed = true; + } + + ui.end_row(); + + changed + } + + #[allow(unused)] pub fn ui(&mut self, ui: &mut egui::Ui) { // save state saved_status(ui, self.saved, "N/A", "Project Settings"); @@ -91,6 +155,10 @@ impl ProjectSettings { self.save(); } + if ui.input(|i| i.key_pressed(egui::Key::S) && i.modifiers.ctrl) { + self.save(); + } + ui.separator(); // project settings @@ -99,80 +167,65 @@ impl ProjectSettings { .striped(true) .num_columns(2) .show(ui, |ui| { - ui.label("Project Name"); - ui.text_edit_singleline(&mut self.project_name); - - ui.end_row(); - - ui.label("Project Author"); - ui.text_edit_singleline(&mut self.project_author); - - ui.end_row(); - - ui.label("Project Description"); - ui.text_edit_singleline(&mut self.project_description); - - ui.end_row(); + if Self::config_str(&mut self.project_name, "Project Name", ui) { self.saved = false }; + if Self::config_str(&mut self.project_author, "Project Author", ui) { self.saved = false }; + if Self::config_str(&mut self.project_description, "Project Description", ui) { self.saved = false }; ui.label("Date"); - ui.add(DatePickerButton::new(&mut self.date)); - + if ui.add(DatePickerButton::new(&mut self.date)).changed() { self.saved = false }; ui.end_row(); - ui.label("AI Context Prompt"); - ui.add(TextEdit::multiline(&mut self.ai_context) - .font(egui::TextStyle::Monospace) - .interactive(true) - .frame(false) - .lock_focus(true) - .hint_text("What is this project about? what should the LLM know when generating content for this project?")); - - ui.end_row(); + #[cfg(feature = "llm")] + { + ui.label("AI Context Prompt"); + if ui.add(TextEdit::multiline(&mut self.ai_context) + .font(egui::TextStyle::Monospace) + .interactive(true) + .frame(false) + .lock_focus(true) + .hint_text("What is this project about? what should the LLM know when generating content for this project?")).changed() { self.saved = false }; + ui.end_row(); + } }); ui.separator(); // local settings overrides for editor + ui.heading("Local Overrides"); egui::Grid::new("local overrides") .striped(true) .num_columns(2) .show(ui, |ui| { - ui.label("Enable AI"); - if let Some(ai_enabled) = &mut self.local_overrides.ai_enabled { - ui.checkbox(ai_enabled, "Enable AI"); - if ui.button("Remove Override").clicked() { - self.local_overrides.ai_enabled = None; - } - } else if ui.button("Override").clicked() { - self.local_overrides.ai_enabled = Some(true); + #[cfg(feature = "llm")] + if ProjectSettings::config_str_override( + "LLM API URI", + &mut self.local_overrides.llm_api_uri, + "http://localhost:1234", + ui, + ) { + self.saved = false; } - ui.end_row(); - - ui.label("LLM API URI"); - if let Some(llm_api_uri) = &mut self.local_overrides.llm_api_uri { - ui.text_edit_singleline(llm_api_uri); - if ui.button("Remove Override").clicked() { - self.local_overrides.llm_api_uri = None; - } - } else if ui.button("Override").clicked() { - self.local_overrides.llm_api_uri = Some("http://localhost:1234".to_string()); + #[cfg(feature = "llm")] + if ProjectSettings::config_str_override( + "LLM API Key", + &mut self.local_overrides.llm_api_key, + "1234", + ui, + ) { + self.saved = false; } - ui.end_row(); - - ui.label("LLM API Key"); - if let Some(llm_api_key) = &mut self.local_overrides.llm_api_key { - ui.text_edit_singleline(llm_api_key); - if ui.button("Remove Override").clicked() { - self.local_overrides.llm_api_key = None; - } - } else if ui.button("Override").clicked() { - self.local_overrides.llm_api_key = Some("1234".to_string()); + #[cfg(feature = "llm")] + if ProjectSettings::config_bool_override( + "Enable AI", + &mut self.local_overrides.ai_enabled, + true, + ui, + ) { + self.saved = false; } - - ui.end_row(); }); ui.separator(); @@ -183,23 +236,37 @@ impl ProjectSettings { .striped(true) .num_columns(2) .show(ui, |ui| { - ui.label("Enable AI"); - ui.checkbox(&mut self.global_settings.ai_enabled.unwrap(), "Enable AI"); + #[cfg(feature = "llm")] + if Self::config_bool( + "Enable AI", + self.global_settings.ai_enabled.as_mut().unwrap(), + ui, + ) { + self.saved = false; + } - ui.end_row(); + #[cfg(feature = "llm")] + if Self::config_str( + self.global_settings.llm_api_uri.as_mut().unwrap(), + "LLM API URI", + ui, + ) { + self.saved = false + }; - ui.label("LLM API URI"); - ui.text_edit_singleline(self.global_settings.llm_api_uri.as_mut().unwrap()); - - ui.end_row(); - - ui.label("LLM API Key"); - ui.text_edit_singleline(self.global_settings.llm_api_key.as_mut().unwrap()); - - ui.end_row(); + #[cfg(feature = "llm")] + if Self::config_str( + self.global_settings.llm_api_key.as_mut().unwrap(), + "LLM API Key", + ui, + ) { + self.saved = false + }; }); } + #[cfg(feature = "llm")] + #[allow(unused)] pub fn ai_enabled(&mut self) -> bool { let client = reqwest::blocking::Client::new(); @@ -236,8 +303,9 @@ impl Default for ProjectSettings { project_author: "Your Name".to_string(), project_description: "Description of your project".to_string(), + #[cfg(feature = "llm")] ai_context: "".to_string(), - global_settings: EditorSettings::new(), + global_settings: EditorSettings::default(), local_overrides: EditorSettings::new(), // window state @@ -249,11 +317,15 @@ impl Default for ProjectSettings { #[derive(Serialize, Deserialize, Clone)] pub struct EditorSettings { - pub llm_api_uri: Option, - pub llm_api_key: Option, - pub ai_enabled: Option, pub dark_theme: Option, + #[cfg(feature = "llm")] + pub llm_api_uri: Option, + #[cfg(feature = "llm")] + pub llm_api_key: Option, + #[cfg(feature = "llm")] + pub ai_enabled: Option, + #[serde(skip)] is_global: bool, } @@ -261,8 +333,11 @@ pub struct EditorSettings { impl Default for EditorSettings { fn default() -> Self { Self { + #[cfg(feature = "llm")] llm_api_uri: Some("http://localhost:1234".to_string()), + #[cfg(feature = "llm")] llm_api_key: Some("".to_string()), + #[cfg(feature = "llm")] ai_enabled: Some(true), dark_theme: Some(true), @@ -275,8 +350,11 @@ impl Default for EditorSettings { impl EditorSettings { pub fn new() -> Self { Self { + #[cfg(feature = "llm")] llm_api_uri: None, + #[cfg(feature = "llm")] llm_api_key: None, + #[cfg(feature = "llm")] ai_enabled: None, dark_theme: None, @@ -285,43 +363,34 @@ impl EditorSettings { } pub fn load() -> Self { - let path = PROJECT_FOLDER.join("settings.json"); - let mut file = if let Ok(file) = std::fs::File::open(path) { - file - } else { - return Self::default(); - }; + if let Ok(mut settings) = FILESYSTEM.read::(Path::new("settings.json")) { + settings.is_global = false; + return settings; + } - let mut contents = String::new(); - file.read_to_string(&mut contents).unwrap(); - serde_json::from_str(&contents).unwrap() + Self::new() } pub fn save(&self) { - let content = serde_json::to_string_pretty(self).unwrap(); - let path = if self.is_global { - PathBuf::from(GLOBAL_SETTINGS_PATH.clone()) + FILESYSTEM.config_path() } else { - PROJECT_FOLDER.join("settings.json") + PathBuf::from("settings.json") }; - std::fs::write(path, content).unwrap(); + FILESYSTEM.write(path.as_path(), self.clone()).unwrap() } pub fn load_global() -> Self { - let path = PathBuf::from(GLOBAL_SETTINGS_PATH.clone()); + let path = FILESYSTEM.config_path(); if !path.exists() { - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - let content = serde_json::to_string_pretty(&Self::default()).unwrap(); - std::fs::write(&path, content).unwrap(); - - // return a default config - return Self::default(); + FILESYSTEM.mkdir(path.parent().unwrap()).unwrap(); + FILESYSTEM.write(path.as_path(), Self::default()).unwrap(); } - let content = std::fs::read_to_string(path).unwrap(); - serde_json::from_str(&content).unwrap() + let mut settings = FILESYSTEM.read::(path.as_path()).unwrap(); + settings.is_global = true; + settings } } diff --git a/src/editors/template_editor.rs b/src/editors/template_editor.rs index 15dd77e..7ec7ef1 100644 --- a/src/editors/template_editor.rs +++ b/src/editors/template_editor.rs @@ -2,10 +2,12 @@ use chrono::NaiveDate; use core::fmt; use egui::ScrollArea; use serde::{Deserialize, Serialize}; +use std::path::Path; use crate::{ - PROJECT_FOLDER, RightPanelContent, + RightPanelContent, editors::object_editor::ObjectInstance, + filesystem::{FILESYSTEM, LegacyFileSystem}, util::{self, Error}, }; @@ -171,21 +173,14 @@ impl Default for Template { impl Template { pub fn load(id: &str) -> Result> { - let path = PROJECT_FOLDER.join("templates").join(format!("{id}.json")); - - let content = std::fs::read_to_string(&path)?; - let mut template: Self = serde_json::from_str(&content)?; + let mut template = FILESYSTEM.read::(Path::new(&format!("templates/{id}.json")))?; template.saved = true; Ok(template) } pub fn save(&mut self) -> Result<(), Box> { - let path = PROJECT_FOLDER - .join("templates") - .join(format!("{}.json", &self.id)); - - let content = serde_json::to_string_pretty(self)?; - std::fs::write(path, content)?; + let id = &self.id; + FILESYSTEM.write(Path::new(&format!("templates/{id}.json")), self.clone())?; self.saved = true; Ok(()) } @@ -203,16 +198,6 @@ impl Template { ScrollArea::vertical().show(ui, |ui| { ui.vertical(|ui| { - // ui.group(|ui| { - // ui.horizontal(|ui| { - // if self.saved { - // ui.label(RichText::new("✓ Saved").color(egui::Color32::GREEN)); - // } else { - // ui.label(RichText::new("* Unsaved").color(egui::Color32::YELLOW)); - // } - // ui.label(format!("id: {}", self.id)); - // }); - // }); util::saved_status(ui, self.saved, &self.id, &self.name); // Save/Cancel buttons @@ -231,13 +216,10 @@ impl Template { } if ui.button("Delete").clicked() { - std::fs::remove_file( - PROJECT_FOLDER - .join("templates") - .join(format!("{}.json", self.id)), - ) - .unwrap(); - + let id = &self.id; + FILESYSTEM + .delete(Path::new(&format!("templates/{id}.json"))) + .unwrap(); *new_instance = Some(RightPanelContent::None); } diff --git a/src/filesystem/mod.rs b/src/filesystem/mod.rs new file mode 100644 index 0000000..84def02 --- /dev/null +++ b/src/filesystem/mod.rs @@ -0,0 +1,103 @@ +use std::{ + collections::HashMap, + io, + path::{Path, PathBuf}, + sync::LazyLock, +}; + +use serde::{Serialize, de::DeserializeOwned}; + +#[cfg(feature = "native")] +use crate::PROJECT_FOLDER; +use crate::filesystem::native::NativeFileSystem; + +#[cfg(feature = "native")] +pub mod native; + +#[cfg(feature = "web")] +pub mod web; + +pub static FILESYSTEM: LazyLock = LazyLock::new(|| { + #[cfg(feature = "native")] + return NativeFileSystem::new(PROJECT_FOLDER.clone()); + #[cfg(feature = "web")] + return Box::new(web::WebFileSystem::new()); +}); + +pub trait LegacyFileSystem { + fn read(&self, path: &Path) -> Result; + fn read_bytes(&self, path: &Path) -> Result, FsError>; + fn write(&self, path: &Path, data: T) -> Result<(), FsError>; + fn delete(&self, path: &Path) -> Result<(), FsError>; + fn mkdir(&self, path: &Path) -> Result<(), FsError>; + fn rename(&self, path: &Path, new_path: &Path) -> Result<(), FsError>; + + #[allow(unused)] + fn exists(&self, path: &Path) -> bool; + + fn config_path(&self) -> PathBuf; +} + +// ──────────────────────────────────────────────────────────────── +// Custom error type +// ──────────────────────────────────────────────────────────────── +#[derive(Debug)] +pub enum FsError { + Io(io::Error), + Serde(serde_json::Error), +} + +impl std::fmt::Display for FsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FsError::Io(e) => write!(f, "IO error: {e}"), + FsError::Serde(e) => write!(f, "Serialization error: {e}"), + } + } +} + +impl std::error::Error for FsError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + FsError::Io(e) => Some(e), + FsError::Serde(e) => Some(e), + } + } +} + +// Convert the two underlying error types into our own +impl From for FsError { + fn from(err: io::Error) -> Self { + FsError::Io(err) + } +} +impl From for FsError { + fn from(err: serde_json::Error) -> Self { + FsError::Serde(err) + } +} + +#[allow(dead_code)] +pub struct Id(String); + +#[allow(dead_code)] +pub trait FileSystem { + fn load(&self, id: Id) -> Result; + fn save(&self, id: Id, data: T) -> Result<(), FsError>; + fn mkdir(&self, path: Path) -> Result<(), FsError>; + fn exists(&self, path: Path) -> bool; +} + +#[allow(dead_code)] +pub struct Index { + file_cache: HashMap, + project_root: Directory, +} + +#[allow(dead_code)] +pub struct Directory { + name: String, + id: Id, + children: HashMap, + files: Vec, +} diff --git a/src/filesystem/native.rs b/src/filesystem/native.rs new file mode 100644 index 0000000..5a1af14 --- /dev/null +++ b/src/filesystem/native.rs @@ -0,0 +1,105 @@ +// ──────────────────────────────────────────────────────────────── +// Imports +// ──────────────────────────────────────────────────────────────── +use std::fs; +use std::io::{ErrorKind, Read}; +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use serde::de::DeserializeOwned; + +use crate::filesystem::{FsError, LegacyFileSystem}; + +// ──────────────────────────────────────────────────────────────── +// Concrete implementation +// ──────────────────────────────────────────────────────────────── +/// The concrete file‑system. All paths are interpreted relative to +/// `project_root`. +pub struct NativeFileSystem { + project_root: PathBuf, +} + +impl NativeFileSystem { + /// Create a new instance. + pub fn new(root: impl Into) -> Self { + Self { + project_root: root.into(), + } + } + + /// Resolve the user supplied *relative* path against the project root. + #[inline] + fn full_path(&self, path: &Path) -> PathBuf { + self.project_root.join(path) + } +} + +// ──────────────────────────────────────────────────────────────── +// Implementation of the trait +// ──────────────────────────────────────────────────────────────── +impl LegacyFileSystem for NativeFileSystem { + fn read(&self, path: &Path) -> Result { + let full_path = self.full_path(path); + let file = fs::File::open(full_path).map_err(FsError::Io)?; + serde_json::from_reader(file).map_err(FsError::Serde) + } + + fn read_bytes(&self, path: &Path) -> Result, FsError> { + let full_path = self.full_path(path); + let mut contents = Vec::new(); + fs::File::open(full_path)?.read_to_end(&mut contents)?; + Ok(contents) + } + + fn write(&self, path: &Path, data: T) -> Result<(), FsError> { + let full_path = self.full_path(path); + + // Ensure the parent directory exists. + if let Some(parent) = full_path.parent() { + fs::create_dir_all(parent)?; + } + + let file = fs::File::create(full_path)?; + serde_json::to_writer(file, &data).map_err(FsError::Serde) + } + + fn delete(&self, path: &Path) -> Result<(), FsError> { + let full_path = self.full_path(path); + match fs::remove_file(&full_path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == ErrorKind::IsADirectory => { + // Remove a directory tree. + fs::remove_dir_all(full_path).map_err(FsError::Io) + } + Err(e) => Err(FsError::Io(e)), + } + } + + fn mkdir(&self, path: &Path) -> Result<(), FsError> { + let full_path = self.full_path(path); + fs::create_dir_all(full_path).map_err(FsError::Io) + } + + fn exists(&self, path: &Path) -> bool { + let full_path = self.full_path(path); + full_path.exists() + } + + fn rename(&self, path: &Path, other: &Path) -> Result<(), FsError> { + let full_path = self.full_path(path); + let full_other = self.full_path(other); + fs::rename(full_path, full_other).map_err(FsError::Io) + } + + fn config_path(&self) -> PathBuf { + match std::env::var("HOME") { + Ok(path) => PathBuf::from(path + "/.config/worldcoder/settings.json"), + Err(_) => { + eprintln!( + "XDG_CONFIG_HOME not set, using default path of ~/.config/worldcoder/settings.json" + ); + "~/.config/worldcoder/settings.json".into() + } + } + } +} diff --git a/src/index.rs b/src/index.rs deleted file mode 100644 index 90d8fc3..0000000 --- a/src/index.rs +++ /dev/null @@ -1,125 +0,0 @@ -use std::io::{self, Read, Write}; -use std::path::{Path, PathBuf}; - -/// Platform-agnostic file system operations -trait FileSystem { - fn read_file(&self, path: &Path) -> io::Result>; - fn write_file(&self, path: &Path, contents: &[u8]) -> io::Result<()>; - fn create_dir_all(&self, path: &Path) -> io::Result<()>; - fn read_dir(&self, path: &Path) -> io::Result>; - fn exists(&self, path: &Path) -> bool; -} - -/// Native filesystem implementation -#[cfg(feature = "native")] -struct NativeFileSystem; - -#[cfg(feature = "native")] -impl FileSystem for NativeFileSystem { - fn read_file(&self, path: &Path) -> io::Result> { - std::fs::read(path) - } - - fn write_file(&self, path: &Path, contents: &[u8]) -> io::Result<()> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(path, contents) - } - - fn create_dir_all(&self, path: &Path) -> io::Result<()> { - std::fs::create_dir_all(path) - } - - fn read_dir(&self, path: &Path) -> io::Result> { - Ok(std::fs::read_dir(path)? - .filter_map(Result::ok) - .map(|entry| entry.path()) - .collect()) - } - - fn exists(&self, path: &Path) -> bool { - path.exists() - } -} - -/// Web filesystem implementation -#[cfg(feature = "web")] -struct WebFileSystem; - -#[cfg(feature = "web")] -impl WebFileSystem { - fn new() -> Self { - // Initialize web-specific storage if needed - Self - } -} - -#[cfg(feature = "web")] -impl FileSystem for WebFileSystem { - fn read_file(&self, path: &Path) -> io::Result> { - // In a real implementation, this would use web_sys and IndexedDB - // This is a simplified version that won't actually work - Err(io::Error::new( - io::ErrorKind::Other, - "Web filesystem not implemented", - )) - } - - fn write_file(&self, path: &Path, contents: &[u8]) -> io::Result<()> { - // In a real implementation, this would use web_sys and IndexedDB - // This is a simplified version that won't actually work - Err(io::Error::new( - io::ErrorKind::Other, - "Web filesystem not implemented", - )) - } - - fn create_dir_all(&self, _path: &Path) -> io::Result<()> { - // In web, directories are virtual and created automatically - Ok(()) - } - - fn read_dir(&self, _path: &Path) -> io::Result> { - // In a real implementation, this would list files from IndexedDB - // This is a simplified version that returns an empty list - Ok(Vec::new()) - } - - fn exists(&self, _path: &Path) -> bool { - // In a real implementation, this would check IndexedDB - false - } -} - -#[cfg(feature = "web")] -#[cfg(test)] -mod tests { - use super::*; - use std::fs::File; - use tempfile::tempdir; - - #[test] - fn test_native_fs() { - let temp_dir = tempdir().unwrap(); - let file_path = temp_dir.path().join("test.txt"); - - let fs = NativeFileSystem; - - // Test write and read - let test_data = b"Hello, world!"; - fs.write_file(&file_path, test_data).unwrap(); - let read_data = fs.read_file(&file_path).unwrap(); - assert_eq!(read_data, test_data); - - // Test exists - assert!(fs.exists(&file_path)); - assert!(!fs.exists(&temp_dir.path().join("nonexistent"))); - - // Test create_dir_all and read_dir - let dir_path = temp_dir.path().join("subdir"); - fs.create_dir_all(&dir_path).unwrap(); - let entries = fs.read_dir(temp_dir.path()).unwrap(); - assert_eq!(entries.len(), 2); // Should contain both the file and the subdirectory - } -} diff --git a/src/main.rs b/src/main.rs index 6663a20..c6d4398 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,9 +10,10 @@ mod explorer; #[cfg(feature = "llm")] mod llm_integration; -mod index; mod util; +mod filesystem; + use crate::{ editors::{ asset_editor::Asset, content_editor, note_editor, object_editor::ObjectInstance, diff --git a/~/.config/worldcoder/settings.json b/~/.config/worldcoder/settings.json deleted file mode 100644 index a554f1b..0000000 --- a/~/.config/worldcoder/settings.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "llm_api_uri": "http://localhost:1234", - "llm_api_key": "", - "ai_enabled": true, - "dark_theme": true -}