diff --git a/src/fleen_app.rs b/src/fleen_app.rs index 3d40142..3b65f0c 100644 --- a/src/fleen_app.rs +++ b/src/fleen_app.rs @@ -1,14 +1,14 @@ -use std::{fs, io, time}; +use std::{fs, io}; +use std::ops::Deref; use std::path::{Path, PathBuf}; use std::process::Command; -use std::sync::RwLock; +use std::sync::Arc; use clipboard_rs::Clipboard; use clipboard_rs::common::RustImage; use thiserror::Error; -use tinyrand::{Rand, Seeded}; use crate::fleen_app::FleenError::{RootDirNonexistence, RootDirPopulated, TargetDir}; use crate::fleen_app::TreeEntry::{CloseDir, Dir}; -use crate::renderer; +use crate::{renderer, utils}; use crate::renderer::{RenderError, RenderOutput}; #[derive(Error, Debug)] @@ -51,217 +51,63 @@ pub enum FileType { File, Dir } -pub struct FleenApp { - root: PathBuf, - files_cache: RwLock>> +pub struct Site { + pub tree: Vec, + pub root: PathBuf, } -impl FleenApp { - pub fn open(root: PathBuf) -> Result { +impl Site { + pub fn open(root: &Path) -> Result { match root.try_exists() { - Ok(true) => Ok(Self { root, files_cache: RwLock::new(None) }), - _ => Err(RootDirNonexistence(root)) + Ok(true) => Ok(Self { root: root.to_path_buf(), tree: read_tree(root)? }), + _ => Err(RootDirNonexistence(root.to_path_buf())) } } - pub fn create(root: PathBuf) -> Result { + pub fn create(root: &Path) -> Result { match root.read_dir() { Ok(mut iter) => { if iter.next().is_some() { - Err(RootDirPopulated(root)) + Err(RootDirPopulated(root.to_path_buf())) } else { - Self::initialize_site(root.clone()).map_err(|e| FleenError::FileIo(String::from("creating site"), e.to_string()))?; - Ok(Self { root, files_cache: RwLock::new(None) }) + utils::initialize_site(root).map_err(|e| FleenError::FileIo(String::from("creating site"), e.to_string()))?; + Self::open(root) } } Err(_) => { - Err(RootDirNonexistence(root)) + Err(RootDirNonexistence(root.to_path_buf())) + } + } + } +} + +// TODO: This is panicky as hell, make it return a Result +fn read_tree(root: &Path) -> Result, FleenError> { + let mut entries = vec![]; + + fn visit_dir(dir: &Path, entries: &mut Vec) { + for entry in dir.read_dir().unwrap() { + let path = entry.unwrap().path(); + if path.file_name().unwrap().to_str().unwrap().starts_with('.') { continue } + if path.is_file() { + entries.push(TreeEntry::File(path)) + } else if path.is_dir() { + entries.push(Dir(path.clone())); + visit_dir(&path, entries); + entries.push(CloseDir) } } } - fn initialize_site(root: PathBuf) -> Result<(), io::Error> { - fs::create_dir(root.join("_layouts"))?; - fs::create_dir(root.join("_scripts"))?; - fs::create_dir(root.join("assets"))?; - fs::create_dir(root.join("images"))?; - fs::write(root.join("_layouts/default.html"), include_str!("../templates/default_layout.html"))?; - fs::write(root.join("_scripts/deploy.sh"), include_str!("../templates/deploy.sh"))?; - fs::write(root.join("assets/.keep"), "")?; - fs::write(root.join("images/.keep"), "")?; - Ok(()) - } + entries.push(Dir(root.to_path_buf())); + visit_dir(root, &mut entries); + entries.push(CloseDir); - // TODO: This is panicky as hell, make it return a Result - fn refresh_file_cache(&self, force: bool) { - if self.files_cache.read().unwrap().is_none() || force { - let mut entries = vec![]; + Ok(entries) +} - fn visit_dir(dir: &Path, entries: &mut Vec) { - for entry in dir.read_dir().unwrap() { - let path = entry.unwrap().path(); - if path.file_name().unwrap().to_str().unwrap().starts_with('.') { continue } - if path.is_file() { - entries.push(TreeEntry::File(path)) - } else if path.is_dir() { - entries.push(Dir(path.clone())); - visit_dir(&path, entries); - entries.push(CloseDir) - } - } - } - - entries.push(Dir(self.root.clone())); - visit_dir(&self.root, &mut entries); - entries.push(CloseDir); - - self.files_cache.write().unwrap().replace(entries); - } - } - - pub fn file_tree_entries(&self) -> impl IntoIterator { - self.refresh_file_cache(false); - // We need to clone these because the rwlock owns them - self.files_cache.read().unwrap().clone().expect("Can't happen because we just refreshed the cache") - } - - pub fn open_filename(&self, filename: &str) -> Result<(), FleenError> { - // TODO this doesn't work for html files. We really want to open things in a platform-dependent way - // - md, html, all other text, should open in gedit on linux or the user's preferred editor on mac - // - images should open in an image viewer preferably - // - dirs should open in a file browser - // - on mac we can open -t to force a text editor - // - on linux we should maybe allow a FLEEN_EDITOR env var, defaulting to EDITOR if missing? - Command::new("open").arg(filename).spawn().map_err(|err| { - FleenError::FileIo(filename.to_owned(), err.to_string()) - })?; - Ok(()) - } - - pub fn open_server(&self, port: &str) { - // If this doesn't work, not like I can do much about it. - let _ = Command::new("open").arg(format!("http://localhost:{}", port)).spawn(); - } - - pub fn create_page(&self, file_type: FileType, name: &str, parent: Option<&String>) -> Result<(), FleenError> { - let mut target = match parent { - Some(s) => PathBuf::from(s), - None => self.root.clone() - }; - if matches!(file_type, FileType::Dir) { - while target.is_file() { target.pop(); } - } - - target.push(name); - if target.exists() { - return Err(FleenError::FileExists(target)) - } - - let contents = if name.ends_with(".md") { - include_str!("../templates/markdown_template.md") - } else if name.ends_with(".html") { - include_str!("../templates/default_layout.html") - } else { - "" - }; - - match file_type { - FileType::File => fs::write(target.clone(), contents), - FileType::Dir => fs::create_dir(target.clone()) - }.map_err(|err| FleenError::FileCreate(target.clone(), err.to_string()))?; - - self.refresh_file_cache(true); - if file_type == FileType::File { - self.open_filename(target.to_string_lossy().as_ref())? - } - Ok(()) - } - - pub fn delete_page(&self, path: &String) -> Result<(), FleenError> { - let target = PathBuf::from(path); - if target.is_dir() { - fs::remove_dir_all(target) - } else { - fs::remove_file(target) - }.map_err(|err| FleenError::FileIo(path.clone(), err.to_string()))?; - self.refresh_file_cache(true); - Ok(()) - } - - pub fn rename_page(&self, target: &String, new_name: &str) -> Result<(), FleenError> { - let path = PathBuf::from(target); - let mut new_path = path.clone(); - new_path.set_file_name(new_name); - fs::rename(path, new_path).map_err(|err| FleenError::FileIo(target.clone(), err.to_string()))?; - self.refresh_file_cache(true); - Ok(()) - } - - pub fn root_path(&self) -> String { - self.root.to_string_lossy().to_string() - } - - pub fn image_dir_exists(&self) -> bool { - self.root.join("images").is_dir() - } - - pub fn unique_image_name(&self) -> Result { - if !self.image_dir_exists() { return Err(FleenError::NoImageDir) } - let mut rng = tinyrand::StdRand::seed(time::SystemTime::now().duration_since(time::UNIX_EPOCH).unwrap().as_secs()); - loop { - let fname = format!("image_{}.png", Self::random_name(&mut rng)); - let path = self.root.join("images").join(fname); - if !path.exists() { - return Ok(path) - } - } - } - - fn random_name(rng: &mut tinyrand::StdRand) -> String { - let mut s = String::new(); - let chs = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f']; - for _ in 0..8 { - let n = rng.next_lim_usize(chs.len()); - s.push(chs[n]); - } - s - } - - pub fn paste_image(&self) -> Result { - let c = clipboard_rs::ClipboardContext::new().map_err(|_| FleenError::NoClipboardImage)?; - let img = c.get_image().map_err(|_| FleenError::NoClipboardImage)?; - let target_path = self.unique_image_name()?; - img.save_to_path(target_path.to_str().unwrap()).map_err(|e| FleenError::FileCreate(target_path.clone(), e.to_string()))?; - self.refresh_file_cache(true); - let uri = format!("/images/{}", target_path.file_name().unwrap().to_str().unwrap()); - let _ = c.set_text(format!("![]({})", uri)); - Ok("Image saved!".to_string()) - } - - pub fn compile(&self) -> Result, FleenError> { - let mut sources = vec![]; // The list of renderoutputs we need to perform - - // Traverse a directory - fn visit_dir(dir: &Path, root: &Path, sources: &mut Vec) -> Result<(), RenderError> { - // Root is the app root. Dir is the directory path within the app root, like "assets". - // File is the filename (or child dir name) within the dir, so, root+dir+file is an - // absolute path - for entry in root.join(dir).read_dir().unwrap() { - let file = PathBuf::from(entry.unwrap().file_name()); - sources.push(renderer::file_render(dir.join(&file), root)?); - if root.join(dir).join(&file).is_dir() { - // root + dir + file is a child directory, so we want to recurse... - // into dir + file. - visit_dir(&dir.join(&file), root, sources)? - } - } - Ok(()) - } - visit_dir(Path::new(""), &self.root, &mut sources)?; - Ok(sources) - } - - pub fn build_site(&self, target: &Path) -> Result<(), FleenError> { +pub trait SiteActions: Deref + Clone { + fn build_site(&self, target: &Path) -> Result<(), FleenError> { // Ensure neither the target nor src dirs are ancestors of the other if self.root.ancestors().any(|a| a == target) || target.ancestors().any(|a| a == self.root) { @@ -288,9 +134,32 @@ impl FleenApp { Ok(()) } - pub async fn build_and_deploy(&self) -> Result { + fn compile(&self) -> Result, FleenError> { + let mut sources = vec![]; // The list of renderoutputs we need to perform + + // Traverse a directory + fn visit_dir(dir: &Path, root: &Path, sources: &mut Vec) -> Result<(), RenderError> { + // Root is the app root. Dir is the directory path within the app root, like "assets". + // File is the filename (or child dir name) within the dir, so, root+dir+file is an + // absolute path + for entry in root.join(dir).read_dir().unwrap() { + let file = PathBuf::from(entry.unwrap().file_name()); + sources.push(renderer::file_render(dir.join(&file), root)?); + if root.join(dir).join(&file).is_dir() { + // root + dir + file is a child directory, so we want to recurse... + // into dir + file. + visit_dir(&dir.join(&file), root, sources)? + } + } + Ok(()) + } + visit_dir(Path::new(""), &self.root, &mut sources)?; + Ok(sources) + } + + async fn build_and_deploy(&self) -> Result { let output_dir = tempfile::tempdir().map_err(|_| TargetDir)?; - self.build_site(output_dir.path())?; // Attempt to build the site somewhere + self.clone().build_site(output_dir.path())?; // Attempt to build the site somewhere let deploy_script_path = self.root.join("_scripts/deploy.sh"); if !deploy_script_path.exists() { @@ -309,8 +178,82 @@ impl FleenApp { } } } + + /// Return whether the images/ directory actually exists for this site + fn image_dir_exists(&self) -> bool { + self.root.join("images").is_dir() + } + + fn paste_image(&self) -> Result { + let c = clipboard_rs::ClipboardContext::new().map_err(|_| FleenError::NoClipboardImage)?; + if !self.image_dir_exists() { return Err(FleenError::NoImageDir) } + let img = c.get_image().map_err(|_| FleenError::NoClipboardImage)?; + let target_path = utils::unique_image_name(&self.root.join("images"))?; + img.save_to_path(target_path.to_str().unwrap()).map_err(|e| FleenError::FileCreate(target_path.clone(), e.to_string()))?; + let new_tree = read_tree(&self.root)?; + let uri = format!("/images/{}", target_path.file_name().unwrap().to_str().unwrap()); + let _ = c.set_text(format!("![]({})", uri)); + Ok(Site { root: self.root.to_path_buf(), tree: new_tree }) + } + + fn create_page(&self, file_type: FileType, name: &str, parent: Option<&String>) -> Result { + let mut target = match parent { + Some(s) => PathBuf::from(s), + None => self.root.clone() + }; + if matches!(file_type, FileType::Dir) { + while target.is_file() { target.pop(); } + } + + target.push(name); + if target.exists() { + return Err(FleenError::FileExists(target)) + } + + let contents = if name.ends_with(".md") { + include_str!("../templates/markdown_template.md") + } else if name.ends_with(".html") { + include_str!("../templates/default_layout.html") + } else { + "" + }; + + match file_type { + FileType::File => fs::write(target.clone(), contents), + FileType::Dir => fs::create_dir(target.clone()) + }.map_err(|err| FleenError::FileCreate(target.clone(), err.to_string()))?; + + let new_tree = read_tree(&self.root)?; + if file_type == FileType::File { + utils::open_filename(target.to_string_lossy().as_ref())? + } + Ok(Site { root: self.root.to_path_buf(), tree: new_tree }) + } + + fn rename_page(&self, target: &String, new_name: &str) -> Result { + let path = PathBuf::from(target); + let mut new_path = path.clone(); + new_path.set_file_name(new_name); + fs::rename(path, new_path).map_err(|err| FleenError::FileIo(target.clone(), err.to_string()))?; + Ok(Site { root: self.root.to_path_buf(), tree: read_tree(&self.root)? }) + } + + fn delete_page(&self, path: &String) -> Result { + let target = PathBuf::from(path); + if target.is_dir() { + fs::remove_dir_all(target) + } else { + fs::remove_file(target) + }.map_err(|err| FleenError::FileIo(path.clone(), err.to_string()))?; + Ok(Site { root: self.root.clone(), tree: read_tree(&self.root)? }) + } } +impl SiteActions for &Site {} +impl SiteActions for Arc {} + +//////////////////////////////////////////// + #[cfg(test)] mod tests { use super::*; @@ -332,8 +275,8 @@ mod tests { #[test] fn test_compile() { - let app = FleenApp::open(PathBuf::from("./testdata")).unwrap(); - let actions = app.compile().unwrap(); + let app = Site::open(&PathBuf::from("./testdata")).unwrap(); + let actions = (&app).compile().unwrap(); // Rendered files in the root that exist assert!(find_rendered_index(&actions,"index.html").is_some()); diff --git a/src/main.rs b/src/main.rs index 9be38d8..10e89cb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,16 +2,13 @@ mod fleen_app; mod renderer; mod server; mod ui_ext; +mod utils; +mod site_ui; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; -use eframe::egui::{Button, Context, Id, RichText}; +use eframe::egui::{Button, Context, RichText}; use eframe::{egui, Frame}; -use egui_ltreeview::Action; -use tokio::task::JoinHandle; -use crate::fleen_app::{FileType, FleenApp, FleenError, TreeEntry}; -use crate::server::start_server; +use site_ui::SiteUi; +use crate::fleen_app::{FleenError, Site}; use crate::ui_ext::{ButtonExtensions, UiExtensions}; #[tokio::main] @@ -22,403 +19,53 @@ async fn main() { .expect("Failed to load icon") ); eframe::run_native("Fleen", native_options, Box::new(|_cc| { - Ok(Box::new(FleenUi::default())) + Ok(Box::new(FleenUi(None, None))) })).expect("Error running application"); } -enum DialogMode { - NewFile(String), - ConfirmDelete(String), - RenameFile(String) -} +struct FleenUi(Option, Option); -struct TempMessage { - created: Instant, - message: String -} - -struct FleenUi { - app: Option>, - error: Option, - message: Option, - selected_file: Option, - dialog_mode: Option, - server_handle: Option>, - server_port: String, - deploy_response: Arc>>>, - deploying: bool, - image_message: Option, -} - -impl Default for FleenUi { - fn default() -> Self { - Self { - app: None, - error: None, - message: None, - selected_file: None, - dialog_mode: None, - server_handle: None, - deploy_response: Mutex::new(None).into(), - deploying: false, - image_message: None, - server_port: "3000".to_string(), - } - } -} - -impl FleenUi { - fn site_chooser(&mut self, ctx: &Context) { - egui::CentralPanel::default().show(ctx, |ui| { - let title = egui::Label::new(RichText::new("Select a site to manage").size(20.0)); - ui.vertical_centered(|ui| ui.add(title)); - - if ui.add_fill_width(Button::blue("Open site...")).clicked() && let Some(path) = rfd::FileDialog::new().pick_folder() { - match FleenApp::open(path.clone()) { - Ok(app) => { - self.app = Some(app.into()); - } - Err(err) => { self.error = Some(err) } - } - } - - if ui.add_fill_width(Button::green("New site...")).clicked() && let Some(path) = rfd::FileDialog::new().pick_folder() { - match FleenApp::create(path.clone()) { - Ok(app) => { - self.app = Some(app.into()); - self.server_handle = Some(tokio::spawn(start_server(path, 3000))) - } - Err(err) => { self.error = Some(err) } - } - } +fn site_chooser(ctx: &Context, error: &Option) -> Result, FleenError> { + if let Some(err) = error { + let message = format!("{}", err); + egui::Window::new("Error").collapsible(false).resizable(false).show(ctx, |ui| { + ui.label(message); }); } - fn display(&mut self, ctx: &Context) { - let mut just_clicked = false; + egui::CentralPanel::default().show(ctx, |ui| { + let title = egui::Label::new(RichText::new("Select a site to manage").size(20.0)); + ui.vertical_centered(|ui| ui.add(title)); - egui::CentralPanel::default().show(ctx, |ui| { - let width = ui.available_width() / 3.0 - 5.0; - let height = ui.available_height() - 120.0; - ui.horizontal(|ui| { - ui.column(width, |ui| { - egui::ScrollArea::new([true, true]) - .auto_shrink([false, false]) - .min_scrolled_height(height) - .show(ui, |ui| self.tree_view(ui)); - just_clicked = self.tree_buttons(ui); - }); - ui.column(width, |ui| self.server_controls(ui)); - ui.column(width, |ui| { - ui.add_enabled_ui(!self.deploying, |ui| { - let label = if self.deploying { - "Deploying..." - } else { - "Build and Deploy" - }; - if ui.add_fill_width(Button::green(label)).clicked() { - self.build_and_deploy(); - } - }); - - if ui.add_fill_width(Button::blue("Build site...")).clicked() && - let Some(path) = rfd::FileDialog::new().pick_folder() { - match self.app.as_ref().as_ref().unwrap().build_site(&path) { - Ok(()) => { self.message = Some("Site built successfully".to_string()) } - Err(err) => { self.error = Some(err) } - } - } - }) - }); - }); - - match self.dialog_mode { - Some(DialogMode::NewFile(_)) => self.new_file_dialog(ctx, just_clicked), - Some(DialogMode::ConfirmDelete(_)) => self.confirm_delete_dialog(ctx), - Some(DialogMode::RenameFile(_)) => self.rename_dialog(ctx, just_clicked), - None => {} - } - } - - fn build_and_deploy(&mut self) { - self.deploying = true; - let mutex = self.deploy_response.clone(); - let app = self.app.as_ref().unwrap().clone(); - tokio::spawn(async move { - let result = app.build_and_deploy().await; - if let Ok(mut m) = mutex.lock() { - *m = Some(result); - } - }); - } - - fn tree_view(&mut self, ui: &mut egui::Ui) { - let tv = egui_ltreeview::TreeView::new(Id::from("tree")) - .allow_multi_selection(false) - .allow_drag_and_drop(false); - let (_, actions) = tv.show(ui, |builder| { - for entry in self.app.clone().as_mut().unwrap().file_tree_entries().into_iter() { - match entry { - TreeEntry::File(p) => builder.leaf(id_for_path(&p), label_for_path(&p)), - TreeEntry::Dir(p) => { builder.dir(id_for_path(&p), label_for_path(&p)); }, - TreeEntry::CloseDir => builder.close_dir() - } - } - }); - - for action in actions { - match action { - Action::SetSelected(files) => { - self.selected_file = files.first().cloned() - } - Action::Activate(activate) => { - for fname in activate.selected { - self.handle_error(self.app.as_ref().unwrap().open_filename(&fname)) - } - } - _ => {} - } - } - } - - fn tree_buttons(&mut self, ui: &mut egui::Ui) -> bool { - let mut just_clicked = false; - - if ui.add_fill_width(egui::Button::new("Open")).clicked() && - let Some(fname) = &self.selected_file { - self.handle_error(self.app.as_ref().unwrap().open_filename(fname)) - } - - let new_btn = Button::green("New page"); - if ui.add_fill_width(new_btn).clicked() { - just_clicked = true; - self.dialog_mode = Some(DialogMode::NewFile(String::new())); - } - - let rename_btn = Button::new("Rename"); - let delete_btn = Button::red("Delete"); - if self.root_selected() || self.selected_file.is_none() { - ui.add_enabled_ui(false, |ui| { - ui.add_fill_width(rename_btn); - ui.add_fill_width(delete_btn); - }); - } else if let Some(selected) = &self.selected_file { - if ui.add_fill_width(rename_btn).clicked() { - self.dialog_mode = Some(DialogMode::RenameFile(label_for_path(&PathBuf::from(&selected)))); - just_clicked = true; - } - if ui.add_fill_width(delete_btn).clicked() { - self.dialog_mode = Some(DialogMode::ConfirmDelete(selected.clone())); + if ui.add_fill_width(Button::blue("Open site...")).clicked() && let Some(path) = rfd::FileDialog::new().pick_folder() { + match Site::open(&path) { + Ok(site) => { return Ok(Some(site)) } + Err(err) => { return Err(err) } } } - ui.add_enabled_ui(self.app.as_ref().unwrap().image_dir_exists() && self.image_message.is_none(), |ui| { - let label = match &self.image_message { - Some(TempMessage { message, .. }) => message.as_str(), - _ => "Image from clipboard" - }; - - if ui.add_fill_width(Button::blue(label)).clicked() { - self.image_message = match self.app.as_ref().unwrap().paste_image() { - Ok(message) => Some(TempMessage { message, created: Instant::now() }), - Err(e) => Some(TempMessage { message: e.to_string(), created: Instant::now() }) - } + if ui.add_fill_width(Button::green("New site...")).clicked() && let Some(path) = rfd::FileDialog::new().pick_folder() { + match Site::create(&path) { + Ok(site) => { return Ok(Some(site)) } + Err(err) => { return Err(err) } } - }); - just_clicked - } - - fn server_controls(&mut self, ui: &mut egui::Ui) { - ui.vertical(|ui| { - ui.label("Port"); - let open_button = Button::new(format!("Open http://localhost:{}", self.server_port)); - let port_editor = egui::TextEdit::singleline(&mut self.server_port); - - if let Some(join_handle) = &self.server_handle { - ui.add_enabled_ui(false, |ui| ui.add_fill_width(port_editor)); - let stop_btn = Button::red("Stop server"); - if ui.add_fill_width(stop_btn).clicked() { - join_handle.abort(); - self.server_handle = None; - } - if ui.add_fill_width(open_button).clicked() { - self.app.as_ref().unwrap().open_server(self.server_port.as_str()); - } - } else { - ui.add(port_editor); - let start_btn = Button::green("Start server"); - if let Ok(port_num) = self.server_port.parse::() { - if ui.add_fill_width(start_btn).clicked() { - let path = PathBuf::from(self.app.as_ref().unwrap().root_path()); - self.server_handle = Some(tokio::spawn(start_server(path, port_num))) - } - } else { - ui.add_enabled_ui(false, |ui| ui.add_fill_width(start_btn)); - } - ui.add_enabled_ui(false, |ui| ui.add_fill_width(open_button)); - } - }); - } - - fn new_file_dialog(&mut self, ctx: &Context, just_clicked: bool) { - egui::Window::new("New page").collapsible(false).resizable(false).show(ctx, |ui| { - ui.label("Name"); - // If we press enter on the text field, it emits lost_focus. We can't just create the file based - // on that, because it's not the only thing that causes it, though. So instead we'll just focus the - // create file button - //let fname = match &mut self.dialog_mode { Some(DialogMode::NewFile(s)) => s, _ => unreachable!() }; - let Some(DialogMode::NewFile(fname)) = &mut self.dialog_mode else { unreachable!() }; - let name_field = egui::TextEdit::singleline(fname); - let resp = ui.add(name_field); - let enter_key = resp.lost_focus(); - - // We want to focus the text field if we just opened the dialog, but egui doesn't currently - // offer a way to do that. However, we can pass in a bool for if the button that opens the - // dialog was just clicked, and use that to tell whether we should grab focus. It's only true - // the exact frame that the dialog was opened, which is what we're looking for. - if just_clicked { resp.request_focus() } - - ui.horizontal(|ui| { - let mut make_thing = |file_type: FileType| { - let Some(DialogMode::NewFile(fname)) = &self.dialog_mode else { unreachable!() }; - let app = self.app.as_ref().unwrap(); - let r = app.create_page(file_type, - fname, - self.selected_file.as_ref()); - if r.is_err() { - self.handle_error(r); - } else { - self.dialog_mode = None; // Close the dialog, we're done - } - }; - - let btn = ui.button("New file"); - if enter_key { btn.request_focus() } - if btn.clicked() { make_thing(FileType::File) } - if ui.button("New directory").clicked() { make_thing(FileType::Dir) } - if ui.button("Cancel").clicked() { - self.dialog_mode = None - } - }) - }); - } - - fn rename_dialog(&mut self, ctx: &Context, just_clicked: bool) { - egui::Window::new("Rename").collapsible(false).resizable(false).show(ctx, |ui| { - ui.label("New name"); - let Some(DialogMode::RenameFile(fname)) = &mut self.dialog_mode else { unreachable!() }; - let name_field = egui::TextEdit::singleline(fname); - let resp = ui.add(name_field); - let enter_key = resp.lost_focus(); - if just_clicked { resp.request_focus() } // See new_file_dialog - - ui.horizontal(|ui| { - let btn = ui.button("Rename"); - if enter_key { btn.request_focus() } - if btn.clicked() { - let Some(DialogMode::RenameFile(fname)) = &self.dialog_mode else { unreachable!() }; - let app = self.app.as_ref().unwrap(); - let r = app.rename_page(self.selected_file.as_ref().unwrap(), fname); - if r.is_err() { - self.handle_error(r); - } else { - self.dialog_mode = None; // Close the dialog, we're done - } - } - if ui.button("Cancel").clicked() { - self.dialog_mode = None - } - }) - }); - } - - fn confirm_delete_dialog(&mut self, ctx: &Context) { - let (mut del, mut cancel) = (false, false); - let Some(DialogMode::ConfirmDelete(fname)) = &self.dialog_mode else { unreachable!() }; - egui::Window::new("Are you sure?").collapsible(false).resizable(false).show(ctx, |ui| { - let file = label_for_path(&PathBuf::from(fname)); - ui.heading(format!("Really delete {}?", file)); - ui.horizontal(|ui| { - let btn = Button::red("Yep, I'm sure"); - del = ui.add(btn).clicked(); - cancel = ui.button("Cancel").clicked(); - }) - }); - - if del { - let r = self.app.as_ref().unwrap().delete_page(&fname.clone()); - self.dialog_mode = None; - if r.is_err() { self.handle_error(r); } - self.selected_file = None; - } else if cancel { - self.dialog_mode = None } - } - fn handle_error(&mut self, result: Result<(), FleenError>) { - if let Err(e) = result { - self.error = Some(e) - } - } - - fn root_selected(&self) -> bool { - if let Some(path) = &self.selected_file { - path == &self.app.as_ref().unwrap().root_path() - } else { - false - } - } + Ok(None) + }).inner } impl eframe::App for FleenUi { fn update(&mut self, ctx: &Context, _frame: &mut Frame) { - if self.deploying && let Ok(mut m) = self.deploy_response.lock() { - if let Some(result) = m.take() { - match result { - Err(e) => { self.error = Some(e) } - Ok(s) => { self.message = Some(s) } + match &mut self.0 { + None => { + match site_chooser(ctx, &self.1) { + Ok(Some(site)) => { self.0 = Some(SiteUi::from(site))} + Err(e) => { self.1 = Some(e) } + _ => {} } - self.deploying = false; } - ctx.request_repaint_after(Duration::from_millis(100)); - } - - match &mut self.app { - None => self.site_chooser(ctx), - Some(_) => self.display(ctx) - } - - if let Some(err) = &self.error { - let message = format!("{}", err); - egui::Window::new("Error").collapsible(false).resizable(false).show(ctx, |ui| { - ui.label(message); - if ui.button("I see").clicked() { - self.error = None - } - }); - } - - if let Some(message) = &self.message { - let message = message.clone(); - egui::Window::new("FYI").collapsible(false).resizable(false).show(ctx, |ui| { - ui.label(message); - if ui.button("Thanks!").clicked() { - self.message = None - } - }); - } - - if let Some(TempMessage { created, .. }) = &self.image_message - && *created < Instant::now() - Duration::from_secs(2) { - self.image_message = None + Some(site_ui) => site_ui.display(ctx) } } } - -fn label_for_path(path: &Path) -> String { - path.file_name().unwrap().to_string_lossy().to_string() -} - -fn id_for_path(path: &Path) -> String { - path.to_string_lossy().to_string() -} \ No newline at end of file diff --git a/src/site_ui.rs b/src/site_ui.rs new file mode 100644 index 0000000..0d72eb7 --- /dev/null +++ b/src/site_ui.rs @@ -0,0 +1,384 @@ +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use eframe::egui; +use eframe::egui::{Button, Context, Id}; +use egui_ltreeview::Action; +use tokio::task::JoinHandle; +use crate::utils; +use crate::fleen_app::{FileType, FleenError, Site, SiteActions, TreeEntry}; +use crate::server::start_server; +use crate::ui_ext::{ButtonExtensions, UiExtensions}; +use crate::utils::{open_filename, open_server}; + +pub struct SiteUi { + site: Arc, + error: Option, + message: Option, + selected_file: Option, + dialog_mode: Option, + server_handle: Option>, + server_port: String, + deploy_response: Arc>>>, + deploying: bool, + image_message: Option, +} + +impl From for SiteUi { + fn from(value: Site) -> Self { + Self { + site: Arc::new(value), + error: None, + message: None, + selected_file: None, + dialog_mode: None, + server_handle: None, + server_port: "3000".to_string(), + deploy_response: Arc::new(Mutex::new(None)), + deploying: false, + image_message: None, + } + } +} + +impl SiteUi { + pub fn display(&mut self, ctx: &Context) { + self.check_deploy_status(ctx); + self.error_dialog(ctx); + self.message_dialog(ctx); + self.temp_message(); + + let mut just_clicked = false; + + egui::CentralPanel::default().show(ctx, |ui| { + let width = ui.available_width() / 3.0 - 5.0; + let height = ui.available_height() - 120.0; + ui.horizontal(|ui| { + ui.column(width, |ui| { + egui::ScrollArea::new([true, true]) + .auto_shrink([false, false]) + .min_scrolled_height(height) + .show(ui, |ui| self.tree_view(ui)); + just_clicked = self.tree_buttons(ui); + }); + ui.column(width, |ui| self.server_controls(ui)); + ui.column(width, |ui| { + ui.add_enabled_ui(!self.deploying, |ui| { + let label = if self.deploying { + "Deploying..." + } else { + "Build and Deploy" + }; + if ui.add_fill_width(Button::green(label)).clicked() { + self.build_and_deploy(); + } + }); + + if ui.add_fill_width(Button::blue("Build site...")).clicked() && + let Some(path) = rfd::FileDialog::new().pick_folder() { + match self.site.build_site(&path) { + Ok(()) => { self.message = Some("Site built successfully".to_string()) } + Err(err) => { self.error = Some(err) } + } + } + }) + }); + }); + + match self.dialog_mode { + Some(DialogMode::NewFile(_)) => self.new_file_dialog(ctx, just_clicked), + Some(DialogMode::ConfirmDelete(_)) => self.confirm_delete_dialog(ctx), + Some(DialogMode::RenameFile(_)) => self.rename_dialog(ctx, just_clicked), + None => {} + } + } + + fn check_deploy_status(&mut self, ctx: &Context) { + if self.deploying && let Ok(mut m) = self.deploy_response.lock() { + if let Some(result) = m.take() { + match result { + Err(e) => { self.error = Some(e) } + Ok(s) => { self.message = Some(s) } + } + self.deploying = false; + } + ctx.request_repaint_after(Duration::from_millis(100)); + } + } + + /// If there's an error dialog, display it + fn error_dialog(&mut self, ctx: &Context) { + if let Some(err) = &self.error { + let message = format!("{}", err); + egui::Window::new("Error").collapsible(false).resizable(false).show(ctx, |ui| { + ui.label(message); + if ui.button("I see").clicked() { + self.error = None + } + }); + } + } + + /// If there's an informational dialog, display it + fn message_dialog(&mut self, ctx: &Context) { + if let Some(message) = &self.message { + let message = message.clone(); + egui::Window::new("FYI").collapsible(false).resizable(false).show(ctx, |ui| { + ui.label(message); + if ui.button("Thanks!").clicked() { + self.message = None + } + }); + } + } + + /// A temporary message (replaces the label on the image button) + fn temp_message(&mut self) { + if let Some(TempMessage { created, .. }) = &self.image_message + && *created < Instant::now() - Duration::from_secs(2) { + self.image_message = None + } + } + + fn build_and_deploy(&mut self) { + self.deploying = true; + let mutex = self.deploy_response.clone(); + let site = self.site.clone(); + tokio::spawn(async move { + let result = site.build_and_deploy().await; + if let Ok(mut m) = mutex.lock() { + *m = Some(result); + } + }); + } + + fn tree_view(&mut self, ui: &mut egui::Ui) { + let tv = egui_ltreeview::TreeView::new(Id::from("tree")) + .allow_multi_selection(false) + .allow_drag_and_drop(false); + let (_, actions) = tv.show(ui, |builder| { + for entry in self.site.tree.iter() { + match entry { + TreeEntry::File(p) => builder.leaf(utils::id_for_path(p), utils::label_for_path(p)), + TreeEntry::Dir(p) => { builder.dir(utils::id_for_path(p), utils::label_for_path(p)); }, + TreeEntry::CloseDir => builder.close_dir() + } + } + }); + + for action in actions { + match action { + Action::SetSelected(files) => { + self.selected_file = files.first().cloned() + } + Action::Activate(activate) => { + for fname in activate.selected { + if let Err(e) = open_filename(&fname) { self.error = Some(e) } + } + } + _ => {} + } + } + } + + fn tree_buttons(&mut self, ui: &mut egui::Ui) -> bool { + let mut just_clicked = false; + + if ui.add_fill_width(egui::Button::new("Open")).clicked() && + let Some(fname) = &self.selected_file && + let Err(e) = open_filename(fname) { + self.error = Some(e) + } + + let new_btn = Button::green("New page"); + if ui.add_fill_width(new_btn).clicked() { + just_clicked = true; + self.dialog_mode = Some(DialogMode::NewFile(String::new())); + } + + let rename_btn = Button::new("Rename"); + let delete_btn = Button::red("Delete"); + if self.root_selected() || self.selected_file.is_none() { + ui.add_enabled_ui(false, |ui| { + ui.add_fill_width(rename_btn); + ui.add_fill_width(delete_btn); + }); + } else if let Some(selected) = &self.selected_file { + if ui.add_fill_width(rename_btn).clicked() { + self.dialog_mode = Some(DialogMode::RenameFile(utils::label_for_path(&PathBuf::from(&selected)))); + just_clicked = true; + } + if ui.add_fill_width(delete_btn).clicked() { + self.dialog_mode = Some(DialogMode::ConfirmDelete(selected.clone())); + } + } + + ui.add_enabled_ui(self.site.image_dir_exists() && self.image_message.is_none(), |ui| { + let label = match &self.image_message { + Some(TempMessage { message, .. }) => message.as_str(), + _ => "Image from clipboard" + }; + + if ui.add_fill_width(Button::blue(label)).clicked() { + self.image_message = match self.site.paste_image() { + Ok(new_site) => { + self.site = Arc::new(new_site); + Some(TempMessage { message: "Image saved!".to_string(), created: Instant::now() }) + }, + Err(e) => Some(TempMessage { message: e.to_string(), created: Instant::now() }) + } + } + }); + just_clicked + } + + fn server_controls(&mut self, ui: &mut egui::Ui) { + ui.vertical(|ui| { + ui.label("Port"); + let open_button = Button::new(format!("Open http://localhost:{}", self.server_port)); + let port_editor = egui::TextEdit::singleline(&mut self.server_port); + + if let Some(join_handle) = &self.server_handle { + ui.add_enabled_ui(false, |ui| ui.add_fill_width(port_editor)); + let stop_btn = Button::red("Stop server"); + if ui.add_fill_width(stop_btn).clicked() { + join_handle.abort(); + self.server_handle = None; + } + if ui.add_fill_width(open_button).clicked() { + open_server(self.server_port.as_str()); + } + } else { + ui.add(port_editor); + let start_btn = Button::green("Start server"); + if let Ok(port_num) = self.server_port.parse::() { + if ui.add_fill_width(start_btn).clicked() { + let path = self.site.root.to_path_buf(); + self.server_handle = Some(tokio::spawn(start_server(path, port_num))) + } + } else { + ui.add_enabled_ui(false, |ui| ui.add_fill_width(start_btn)); + } + ui.add_enabled_ui(false, |ui| ui.add_fill_width(open_button)); + } + }); + } + + fn new_file_dialog(&mut self, ctx: &Context, just_clicked: bool) { + egui::Window::new("New page").collapsible(false).resizable(false).show(ctx, |ui| { + ui.label("Name"); + // If we press enter on the text field, it emits lost_focus. We can't just create the file based + // on that, because it's not the only thing that causes it, though. So instead we'll just focus the + // create file button + //let fname = match &mut self.dialog_mode { Some(DialogMode::NewFile(s)) => s, _ => unreachable!() }; + let Some(DialogMode::NewFile(fname)) = &mut self.dialog_mode else { unreachable!() }; + let name_field = egui::TextEdit::singleline(fname); + let resp = ui.add(name_field); + let enter_key = resp.lost_focus(); + + // We want to focus the text field if we just opened the dialog, but egui doesn't currently + // offer a way to do that. However, we can pass in a bool for if the button that opens the + // dialog was just clicked, and use that to tell whether we should grab focus. It's only true + // the exact frame that the dialog was opened, which is what we're looking for. + if just_clicked { resp.request_focus() } + + ui.horizontal(|ui| { + let mut make_thing = |file_type: FileType| { + let Some(DialogMode::NewFile(fname)) = &self.dialog_mode else { unreachable!() }; + let r = + self.site.create_page(file_type, + fname, + self.selected_file.as_ref()); + match r { + Ok(new_site) => { + self.site = Arc::new(new_site); + self.dialog_mode = None; + } + Err(e) => { self.error = Some(e) } + } + }; + + let btn = ui.button("New file"); + if enter_key { btn.request_focus() } + if btn.clicked() { make_thing(FileType::File) } + if ui.button("New directory").clicked() { make_thing(FileType::Dir) } + if ui.button("Cancel").clicked() { + self.dialog_mode = None + } + }) + }); + } + + fn rename_dialog(&mut self, ctx: &Context, just_clicked: bool) { + egui::Window::new("Rename").collapsible(false).resizable(false).show(ctx, |ui| { + ui.label("New name"); + let Some(DialogMode::RenameFile(fname)) = &mut self.dialog_mode else { unreachable!() }; + let name_field = egui::TextEdit::singleline(fname); + let resp = ui.add(name_field); + let enter_key = resp.lost_focus(); + if just_clicked { resp.request_focus() } // See new_file_dialog + + ui.horizontal(|ui| { + let btn = ui.button("Rename"); + if enter_key { btn.request_focus() } + if btn.clicked() { + let Some(DialogMode::RenameFile(fname)) = &self.dialog_mode else { unreachable!() }; + match self.site.rename_page(self.selected_file.as_ref().unwrap(), fname) { + Ok(new_site) => { + self.site = Arc::new(new_site); + self.dialog_mode = None; // Close the dialog, we're done + } + Err(e) => { self.error = Some(e) } + } + } + if ui.button("Cancel").clicked() { + self.dialog_mode = None + } + }) + }); + } + + fn confirm_delete_dialog(&mut self, ctx: &Context) { + let (mut del, mut cancel) = (false, false); + let Some(DialogMode::ConfirmDelete(fname)) = &self.dialog_mode else { unreachable!() }; + egui::Window::new("Are you sure?").collapsible(false).resizable(false).show(ctx, |ui| { + let file = utils::label_for_path(&PathBuf::from(fname)); + ui.heading(format!("Really delete {}?", file)); + ui.horizontal(|ui| { + let btn = Button::red("Yep, I'm sure"); + del = ui.add(btn).clicked(); + cancel = ui.button("Cancel").clicked(); + }) + }); + + if del { + match self.site.delete_page(fname) { + Ok(new_site) => { self.site = Arc::new(new_site) } + Err(e) => { self.error = Some(e) } + } + self.dialog_mode = None; + self.selected_file = None; + } else if cancel { + self.dialog_mode = None + } + } + + fn root_selected(&self) -> bool { + if let Some(path) = &self.selected_file { + *path == self.site.root + } else { + false + } + } +} + +enum DialogMode { + NewFile(String), + ConfirmDelete(String), + RenameFile(String) +} + +struct TempMessage { + created: Instant, + message: String +} \ No newline at end of file diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..fa04634 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,64 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::{fs, io, time}; +use tinyrand::{Rand, Seeded}; +use crate::fleen_app::FleenError; + +pub fn initialize_site(root: &Path) -> Result<(), io::Error> { + fs::create_dir(root.join("_layouts"))?; + fs::create_dir(root.join("_scripts"))?; + fs::create_dir(root.join("assets"))?; + fs::create_dir(root.join("images"))?; + fs::write(root.join("_layouts/default.html"), include_str!("../templates/default_layout.html"))?; + fs::write(root.join("_scripts/deploy.sh"), include_str!("../templates/deploy.sh"))?; + fs::write(root.join("assets/.keep"), "")?; + fs::write(root.join("images/.keep"), "")?; + Ok(()) +} + +pub fn open_filename(filename: &str) -> Result<(), FleenError> { + // TODO this doesn't work for html files. We really want to open things in a platform-dependent way + // - md, html, all other text, should open in gedit on linux or the user's preferred editor on mac + // - images should open in an image viewer preferably + // - dirs should open in a file browser + // - on mac we can open -t to force a text editor + // - on linux we should maybe allow a FLEEN_EDITOR env var, defaulting to EDITOR if missing? + Command::new("open").arg(filename).spawn().map_err(|err| { + FleenError::FileIo(filename.to_owned(), err.to_string()) + })?; + Ok(()) +} + +pub fn unique_image_name(image_dir: &Path) -> Result { + let mut rng = tinyrand::StdRand::seed(time::SystemTime::now().duration_since(time::UNIX_EPOCH).unwrap().as_secs()); + loop { + let fname = format!("image_{}.png", random_name(&mut rng)); + let path = image_dir.join(fname); + if !path.exists() { + return Ok(path) + } + } +} + +fn random_name(rng: &mut tinyrand::StdRand) -> String { + let mut s = String::new(); + let chs = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f']; + for _ in 0..8 { + let n = rng.next_lim_usize(chs.len()); + s.push(chs[n]); + } + s +} + +pub fn open_server(port: &str) { + // If this doesn't work, not like I can do much about it. + let _ = Command::new("open").arg(format!("http://localhost:{}", port)).spawn(); +} + +pub fn label_for_path(path: &Path) -> String { + path.file_name().unwrap().to_string_lossy().to_string() +} + +pub fn id_for_path(path: &Path) -> String { + path.to_string_lossy().to_string() +} \ No newline at end of file