big refactor
This commit is contained in:
+143
-200
@@ -1,14 +1,14 @@
|
|||||||
use std::{fs, io, time};
|
use std::{fs, io};
|
||||||
|
use std::ops::Deref;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::sync::RwLock;
|
use std::sync::Arc;
|
||||||
use clipboard_rs::Clipboard;
|
use clipboard_rs::Clipboard;
|
||||||
use clipboard_rs::common::RustImage;
|
use clipboard_rs::common::RustImage;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tinyrand::{Rand, Seeded};
|
|
||||||
use crate::fleen_app::FleenError::{RootDirNonexistence, RootDirPopulated, TargetDir};
|
use crate::fleen_app::FleenError::{RootDirNonexistence, RootDirPopulated, TargetDir};
|
||||||
use crate::fleen_app::TreeEntry::{CloseDir, Dir};
|
use crate::fleen_app::TreeEntry::{CloseDir, Dir};
|
||||||
use crate::renderer;
|
use crate::{renderer, utils};
|
||||||
use crate::renderer::{RenderError, RenderOutput};
|
use crate::renderer::{RenderError, RenderOutput};
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
@@ -51,217 +51,63 @@ pub enum FileType {
|
|||||||
File, Dir
|
File, Dir
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct FleenApp {
|
pub struct Site {
|
||||||
root: PathBuf,
|
pub tree: Vec<TreeEntry>,
|
||||||
files_cache: RwLock<Option<Vec<TreeEntry>>>
|
pub root: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FleenApp {
|
impl Site {
|
||||||
pub fn open(root: PathBuf) -> Result<Self, FleenError> {
|
pub fn open(root: &Path) -> Result<Self, FleenError> {
|
||||||
match root.try_exists() {
|
match root.try_exists() {
|
||||||
Ok(true) => Ok(Self { root, files_cache: RwLock::new(None) }),
|
Ok(true) => Ok(Self { root: root.to_path_buf(), tree: read_tree(root)? }),
|
||||||
_ => Err(RootDirNonexistence(root))
|
_ => Err(RootDirNonexistence(root.to_path_buf()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create(root: PathBuf) -> Result<Self, FleenError> {
|
pub fn create(root: &Path) -> Result<Self, FleenError> {
|
||||||
match root.read_dir() {
|
match root.read_dir() {
|
||||||
Ok(mut iter) => {
|
Ok(mut iter) => {
|
||||||
if iter.next().is_some() {
|
if iter.next().is_some() {
|
||||||
Err(RootDirPopulated(root))
|
Err(RootDirPopulated(root.to_path_buf()))
|
||||||
} else {
|
} else {
|
||||||
Self::initialize_site(root.clone()).map_err(|e| FleenError::FileIo(String::from("creating site"), e.to_string()))?;
|
utils::initialize_site(root).map_err(|e| FleenError::FileIo(String::from("creating site"), e.to_string()))?;
|
||||||
Ok(Self { root, files_cache: RwLock::new(None) })
|
Self::open(root)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => {
|
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<Vec<TreeEntry>, FleenError> {
|
||||||
|
let mut entries = vec![];
|
||||||
|
|
||||||
|
fn visit_dir(dir: &Path, entries: &mut Vec<TreeEntry>) {
|
||||||
|
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> {
|
entries.push(Dir(root.to_path_buf()));
|
||||||
fs::create_dir(root.join("_layouts"))?;
|
visit_dir(root, &mut entries);
|
||||||
fs::create_dir(root.join("_scripts"))?;
|
entries.push(CloseDir);
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: This is panicky as hell, make it return a Result
|
Ok(entries)
|
||||||
fn refresh_file_cache(&self, force: bool) {
|
}
|
||||||
if self.files_cache.read().unwrap().is_none() || force {
|
|
||||||
let mut entries = vec![];
|
|
||||||
|
|
||||||
fn visit_dir(dir: &Path, entries: &mut Vec<TreeEntry>) {
|
pub trait SiteActions: Deref<Target=Site> + Clone {
|
||||||
for entry in dir.read_dir().unwrap() {
|
fn build_site(&self, target: &Path) -> Result<(), FleenError> {
|
||||||
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<Item=TreeEntry> {
|
|
||||||
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<PathBuf, FleenError> {
|
|
||||||
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<String, FleenError> {
|
|
||||||
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<Vec<RenderOutput>, 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<RenderOutput>) -> 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> {
|
|
||||||
// Ensure neither the target nor src dirs are ancestors of the other
|
// Ensure neither the target nor src dirs are ancestors of the other
|
||||||
if self.root.ancestors().any(|a| a == target) ||
|
if self.root.ancestors().any(|a| a == target) ||
|
||||||
target.ancestors().any(|a| a == self.root) {
|
target.ancestors().any(|a| a == self.root) {
|
||||||
@@ -288,9 +134,32 @@ impl FleenApp {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn build_and_deploy(&self) -> Result<String, FleenError> {
|
fn compile(&self) -> Result<Vec<RenderOutput>, 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<RenderOutput>) -> 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<String, FleenError> {
|
||||||
let output_dir = tempfile::tempdir().map_err(|_| TargetDir)?;
|
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");
|
let deploy_script_path = self.root.join("_scripts/deploy.sh");
|
||||||
if !deploy_script_path.exists() {
|
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<Site, FleenError> {
|
||||||
|
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<Site, 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()))?;
|
||||||
|
|
||||||
|
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<Site, 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()))?;
|
||||||
|
Ok(Site { root: self.root.to_path_buf(), tree: read_tree(&self.root)? })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_page(&self, path: &String) -> Result<Site, 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()))?;
|
||||||
|
Ok(Site { root: self.root.clone(), tree: read_tree(&self.root)? })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SiteActions for &Site {}
|
||||||
|
impl SiteActions for Arc<Site> {}
|
||||||
|
|
||||||
|
////////////////////////////////////////////
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -332,8 +275,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_compile() {
|
fn test_compile() {
|
||||||
let app = FleenApp::open(PathBuf::from("./testdata")).unwrap();
|
let app = Site::open(&PathBuf::from("./testdata")).unwrap();
|
||||||
let actions = app.compile().unwrap();
|
let actions = (&app).compile().unwrap();
|
||||||
|
|
||||||
// Rendered files in the root that exist
|
// Rendered files in the root that exist
|
||||||
assert!(find_rendered_index(&actions,"index.html").is_some());
|
assert!(find_rendered_index(&actions,"index.html").is_some());
|
||||||
|
|||||||
+32
-385
@@ -2,16 +2,13 @@ mod fleen_app;
|
|||||||
mod renderer;
|
mod renderer;
|
||||||
mod server;
|
mod server;
|
||||||
mod ui_ext;
|
mod ui_ext;
|
||||||
|
mod utils;
|
||||||
|
mod site_ui;
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use eframe::egui::{Button, Context, RichText};
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
use eframe::egui::{Button, Context, Id, RichText};
|
|
||||||
use eframe::{egui, Frame};
|
use eframe::{egui, Frame};
|
||||||
use egui_ltreeview::Action;
|
use site_ui::SiteUi;
|
||||||
use tokio::task::JoinHandle;
|
use crate::fleen_app::{FleenError, Site};
|
||||||
use crate::fleen_app::{FileType, FleenApp, FleenError, TreeEntry};
|
|
||||||
use crate::server::start_server;
|
|
||||||
use crate::ui_ext::{ButtonExtensions, UiExtensions};
|
use crate::ui_ext::{ButtonExtensions, UiExtensions};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -22,403 +19,53 @@ async fn main() {
|
|||||||
.expect("Failed to load icon")
|
.expect("Failed to load icon")
|
||||||
);
|
);
|
||||||
eframe::run_native("Fleen", native_options, Box::new(|_cc| {
|
eframe::run_native("Fleen", native_options, Box::new(|_cc| {
|
||||||
Ok(Box::new(FleenUi::default()))
|
Ok(Box::new(FleenUi(None, None)))
|
||||||
})).expect("Error running application");
|
})).expect("Error running application");
|
||||||
}
|
}
|
||||||
|
|
||||||
enum DialogMode {
|
struct FleenUi(Option<SiteUi>, Option<FleenError>);
|
||||||
NewFile(String),
|
|
||||||
ConfirmDelete(String),
|
|
||||||
RenameFile(String)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TempMessage {
|
fn site_chooser(ctx: &Context, error: &Option<FleenError>) -> Result<Option<Site>, FleenError> {
|
||||||
created: Instant,
|
if let Some(err) = error {
|
||||||
message: String
|
let message = format!("{}", err);
|
||||||
}
|
egui::Window::new("Error").collapsible(false).resizable(false).show(ctx, |ui| {
|
||||||
|
ui.label(message);
|
||||||
struct FleenUi {
|
|
||||||
app: Option<Arc<FleenApp>>,
|
|
||||||
error: Option<FleenError>,
|
|
||||||
message: Option<String>,
|
|
||||||
selected_file: Option<String>,
|
|
||||||
dialog_mode: Option<DialogMode>,
|
|
||||||
server_handle: Option<JoinHandle<()>>,
|
|
||||||
server_port: String,
|
|
||||||
deploy_response: Arc<Mutex<Option<Result<String, FleenError>>>>,
|
|
||||||
deploying: bool,
|
|
||||||
image_message: Option<TempMessage>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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 display(&mut self, ctx: &Context) {
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
let mut just_clicked = false;
|
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| {
|
if ui.add_fill_width(Button::blue("Open site...")).clicked() && let Some(path) = rfd::FileDialog::new().pick_folder() {
|
||||||
let width = ui.available_width() / 3.0 - 5.0;
|
match Site::open(&path) {
|
||||||
let height = ui.available_height() - 120.0;
|
Ok(site) => { return Ok(Some(site)) }
|
||||||
ui.horizontal(|ui| {
|
Err(err) => { return Err(err) }
|
||||||
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()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.add_enabled_ui(self.app.as_ref().unwrap().image_dir_exists() && self.image_message.is_none(), |ui| {
|
if ui.add_fill_width(Button::green("New site...")).clicked() && let Some(path) = rfd::FileDialog::new().pick_folder() {
|
||||||
let label = match &self.image_message {
|
match Site::create(&path) {
|
||||||
Some(TempMessage { message, .. }) => message.as_str(),
|
Ok(site) => { return Ok(Some(site)) }
|
||||||
_ => "Image from clipboard"
|
Err(err) => { return Err(err) }
|
||||||
};
|
|
||||||
|
|
||||||
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() })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
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::<u32>() {
|
|
||||||
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>) {
|
Ok(None)
|
||||||
if let Err(e) = result {
|
}).inner
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl eframe::App for FleenUi {
|
impl eframe::App for FleenUi {
|
||||||
fn update(&mut self, ctx: &Context, _frame: &mut Frame) {
|
fn update(&mut self, ctx: &Context, _frame: &mut Frame) {
|
||||||
if self.deploying && let Ok(mut m) = self.deploy_response.lock() {
|
match &mut self.0 {
|
||||||
if let Some(result) = m.take() {
|
None => {
|
||||||
match result {
|
match site_chooser(ctx, &self.1) {
|
||||||
Err(e) => { self.error = Some(e) }
|
Ok(Some(site)) => { self.0 = Some(SiteUi::from(site))}
|
||||||
Ok(s) => { self.message = Some(s) }
|
Err(e) => { self.1 = Some(e) }
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
self.deploying = false;
|
|
||||||
}
|
}
|
||||||
ctx.request_repaint_after(Duration::from_millis(100));
|
Some(site_ui) => site_ui.display(ctx)
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
+384
@@ -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<Site>,
|
||||||
|
error: Option<FleenError>,
|
||||||
|
message: Option<String>,
|
||||||
|
selected_file: Option<String>,
|
||||||
|
dialog_mode: Option<DialogMode>,
|
||||||
|
server_handle: Option<JoinHandle<()>>,
|
||||||
|
server_port: String,
|
||||||
|
deploy_response: Arc<Mutex<Option<Result<String, FleenError>>>>,
|
||||||
|
deploying: bool,
|
||||||
|
image_message: Option<TempMessage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Site> 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::<u32>() {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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<PathBuf, FleenError> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user