Hotreloading of for/if/body, formatted strings, literals, component props, nested rsx, light CLI rewrite, cli TUI (#2258)
Hotreload the contents of for loops, if chains, component bodies, props, attributes, and any literals discovered in rsx! Add a TUI renderer to the CLI. Improve the CLI build system to be async and parallel. Refactor RSX to allow partial expansion of expressions. Merge autofmt implementations for consistency. Merge the representation of elements and components under the hood. Add a diagnostics system for rsx for improved error messages. Drop interprocess and move to websockets for communication between the CLI and the server. Assign IDs to nodes and attributes in a stable way to be used in non compiler contexts. Add hotreloading to any body of component/for loop/if chain/etc. --------- Co-authored-by: Evan Almloff <evanalmloff@gmail.com> Co-authored-by: Liam Mitchell <liamkarlmitchell@gmail.com>
This commit is contained in:
181 files changed
+12755
-10806
No files matched your search
@@ -0,0 +1,167 @@
|
||||
use crate::builder::BuildRequest;
|
||||
use crate::builder::BuildResult;
|
||||
use crate::builder::UpdateBuildProgress;
|
||||
use crate::dioxus_crate::DioxusCrate;
|
||||
use crate::serve::Serve;
|
||||
use crate::Result;
|
||||
use dioxus_cli_config::Platform;
|
||||
use futures_channel::mpsc::UnboundedReceiver;
|
||||
use futures_util::future::OptionFuture;
|
||||
use futures_util::stream::select_all;
|
||||
use futures_util::StreamExt;
|
||||
use std::process::Stdio;
|
||||
use tokio::{
|
||||
process::{Child, Command},
|
||||
task::JoinHandle,
|
||||
};
|
||||
|
||||
/// A handle to ongoing builds and then the spawned tasks themselves
|
||||
pub struct Builder {
|
||||
/// The results of the build
|
||||
build_results: Option<JoinHandle<Result<Vec<BuildResult>>>>,
|
||||
|
||||
/// The progress of the builds
|
||||
build_progress: Vec<(Platform, UnboundedReceiver<UpdateBuildProgress>)>,
|
||||
|
||||
/// The application we are building
|
||||
config: DioxusCrate,
|
||||
|
||||
/// The arguments for the build
|
||||
serve: Serve,
|
||||
|
||||
/// The children of the build process
|
||||
pub children: Vec<(Platform, Child)>,
|
||||
}
|
||||
|
||||
impl Builder {
|
||||
/// Create a new builder
|
||||
pub fn new(config: &DioxusCrate, serve: &Serve) -> Self {
|
||||
let serve = serve.clone();
|
||||
let config = config.clone();
|
||||
Self {
|
||||
build_results: None,
|
||||
build_progress: Vec::new(),
|
||||
config: config.clone(),
|
||||
serve,
|
||||
children: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a new build - killing the current one if it exists
|
||||
pub fn build(&mut self) {
|
||||
self.shutdown();
|
||||
let build_requests =
|
||||
BuildRequest::create(true, &self.config, self.serve.build_arguments.clone());
|
||||
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
|
||||
for build_request in build_requests {
|
||||
let (mut tx, rx) = futures_channel::mpsc::unbounded();
|
||||
self.build_progress
|
||||
.push((build_request.build_arguments.platform(), rx));
|
||||
set.spawn(async move {
|
||||
let res = build_request.build(tx.clone()).await;
|
||||
|
||||
if let Err(err) = &res {
|
||||
let _ = tx.start_send(UpdateBuildProgress {
|
||||
stage: crate::builder::Stage::Finished,
|
||||
update: crate::builder::UpdateStage::Failed(err.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
res
|
||||
});
|
||||
}
|
||||
|
||||
self.build_results = Some(tokio::spawn(async move {
|
||||
let mut all_results = Vec::new();
|
||||
while let Some(result) = set.join_next().await {
|
||||
let res = result.map_err(|err| {
|
||||
crate::Error::Unique(format!("Panic while building project: {err:?}"))
|
||||
})??;
|
||||
|
||||
all_results.push(res);
|
||||
}
|
||||
Ok(all_results)
|
||||
}));
|
||||
}
|
||||
|
||||
/// Wait for any new updates to the builder - either it completed or gave us a message etc
|
||||
pub async fn wait(&mut self) -> Result<BuilderUpdate> {
|
||||
// Wait for build progress
|
||||
let mut next = select_all(
|
||||
self.build_progress
|
||||
.iter_mut()
|
||||
.map(|(platform, rx)| rx.map(move |update| (*platform, update))),
|
||||
);
|
||||
|
||||
let results: OptionFuture<_> = self.build_results.as_mut().into();
|
||||
|
||||
// Wait for the next build result
|
||||
tokio::select! {
|
||||
Some(build_results) = results => {
|
||||
self.build_results = None;
|
||||
|
||||
// If we have a build result, bubble it up to the main loop
|
||||
let build_results = build_results.map_err(|_| crate::Error::Unique("Build join failed".to_string()))??;
|
||||
|
||||
Ok(BuilderUpdate::Ready { results: build_results })
|
||||
}
|
||||
Some((platform, update)) = next.next() => {
|
||||
// If we have a build progress, send it to the screen
|
||||
Ok(BuilderUpdate::Progress { platform, update })
|
||||
}
|
||||
else => {
|
||||
std::future::pending::<()>().await;
|
||||
unreachable!("Pending cannot resolve")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Shutdown the current build process
|
||||
pub(crate) fn shutdown(&mut self) {
|
||||
for (_, mut child) in self.children.drain(..) {
|
||||
// Gracefully shtudown the desktop app
|
||||
// It might have a receiver to do some cleanup stuff
|
||||
if let Some(pid) = child.id() {
|
||||
// on unix, we can send a signal to the process to shut down
|
||||
#[cfg(unix)]
|
||||
{
|
||||
_ = Command::new("kill")
|
||||
.args(["-s", "TERM", &pid.to_string()])
|
||||
.stderr(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.spawn();
|
||||
}
|
||||
|
||||
// on windows, use the `taskkill` command
|
||||
#[cfg(windows)]
|
||||
{
|
||||
_ = Command::new("taskkill")
|
||||
.args(["/F", "/PID", &pid.to_string()])
|
||||
.stderr(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.spawn();
|
||||
}
|
||||
}
|
||||
|
||||
// Todo: add a timeout here to kill the process if it doesn't shut down within a reasonable time
|
||||
_ = child.start_kill();
|
||||
}
|
||||
|
||||
if let Some(tasks) = self.build_results.take() {
|
||||
tasks.abort();
|
||||
}
|
||||
self.build_progress.clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub enum BuilderUpdate {
|
||||
Progress {
|
||||
platform: Platform,
|
||||
update: UpdateBuildProgress,
|
||||
},
|
||||
Ready {
|
||||
results: Vec<BuildResult>,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
use dioxus_core::{internal::HotReloadLiteral, Template};
|
||||
use dioxus_rsx::{
|
||||
hot_reload::{diff_rsx, ChangedRsx},
|
||||
CallBody, HotReloadedTemplate, HotReloadingContext,
|
||||
};
|
||||
use krates::cm::MetadataCommand;
|
||||
use krates::Cmd;
|
||||
pub use std::collections::HashMap;
|
||||
use std::{ffi::OsStr, path::PathBuf};
|
||||
pub use std::{fs, io, path::Path};
|
||||
pub use std::{fs::File, io::Read};
|
||||
use syn::spanned::Spanned;
|
||||
|
||||
pub struct FileMap {
|
||||
pub map: HashMap<PathBuf, CachedSynFile>,
|
||||
|
||||
/// Any errors that occurred while building the FileMap that were not fatal
|
||||
pub errors: Vec<io::Error>,
|
||||
|
||||
pub in_workspace: HashMap<PathBuf, Option<PathBuf>>,
|
||||
|
||||
pub changed_lits: HashMap<String, HotReloadLiteral>,
|
||||
}
|
||||
|
||||
/// A cached file that has been parsed
|
||||
///
|
||||
/// We store the templates found in this file
|
||||
pub struct CachedSynFile {
|
||||
pub raw: String,
|
||||
pub templates: HashMap<&'static str, Template>,
|
||||
}
|
||||
|
||||
impl FileMap {
|
||||
/// Create a new FileMap from a crate directory
|
||||
///
|
||||
/// TODO: this should be created with a gitignore filter
|
||||
pub fn create<Ctx: HotReloadingContext>(path: PathBuf) -> io::Result<FileMap> {
|
||||
Self::create_with_filter::<Ctx>(path, |p| {
|
||||
// skip some stuff we know is large by default
|
||||
p.file_name() == Some(OsStr::new("target"))
|
||||
|| p.file_name() == Some(OsStr::new("node_modules"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new FileMap from a crate directory
|
||||
///
|
||||
/// Takes a filter that when returns true, the file will be filtered out (ie not tracked)
|
||||
/// Note that this is inverted from a typical .filter() method.
|
||||
pub fn create_with_filter<Ctx: HotReloadingContext>(
|
||||
crate_dir: PathBuf,
|
||||
mut filter: impl FnMut(&Path) -> bool,
|
||||
) -> io::Result<FileMap> {
|
||||
let FileMapSearchResult { map, errors } = find_rs_files(crate_dir.clone(), &mut filter);
|
||||
|
||||
let mut map = Self {
|
||||
map,
|
||||
errors,
|
||||
in_workspace: HashMap::new(),
|
||||
changed_lits: HashMap::new(),
|
||||
};
|
||||
|
||||
map.load_assets::<Ctx>(crate_dir.as_path());
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Start watching assets for changes
|
||||
///
|
||||
/// This just diffs every file against itself and populates the tracked assets as it goes
|
||||
pub fn load_assets<Ctx: HotReloadingContext>(&mut self, crate_dir: &Path) {
|
||||
let keys = self.map.keys().cloned().collect::<Vec<_>>();
|
||||
for file in keys {
|
||||
_ = self.update_rsx::<Ctx>(file.as_path(), crate_dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to update the rsx in a file
|
||||
pub fn update_rsx<Ctx: HotReloadingContext>(
|
||||
&mut self,
|
||||
file_path: &Path,
|
||||
crate_dir: &Path,
|
||||
) -> Result<Vec<HotReloadedTemplate>, HotreloadError> {
|
||||
let src = std::fs::read_to_string(file_path)?;
|
||||
|
||||
// If we can't parse the contents we want to pass it off to the build system to tell the user that there's a syntax error
|
||||
let syntax = syn::parse_file(&src).map_err(|_err| HotreloadError::Parse)?;
|
||||
|
||||
let in_workspace = self.child_in_workspace(crate_dir)?;
|
||||
|
||||
// Get the cached file if it exists, otherwise try to create it
|
||||
let Some(old_cached) = self.map.get_mut(file_path) else {
|
||||
// if this is a new file, rebuild the project
|
||||
let mut map = FileMap::create::<Ctx>(crate_dir.to_path_buf())?;
|
||||
|
||||
if let Some(err) = map.errors.pop() {
|
||||
return Err(HotreloadError::Failure(err));
|
||||
}
|
||||
|
||||
// merge the new map into the old map
|
||||
self.map.extend(map.map);
|
||||
|
||||
return Err(HotreloadError::Notreloadable);
|
||||
};
|
||||
|
||||
// If the cached file is not a valid rsx file, rebuild the project, forcing errors
|
||||
// TODO: in theory the error is simply in the RsxCallbody. We could attempt to parse it using partial expansion
|
||||
// And collect out its errors instead of giving up to a full rebuild
|
||||
let old = syn::parse_file(&old_cached.raw).map_err(|_e| HotreloadError::Parse)?;
|
||||
|
||||
let instances = match diff_rsx(&syntax, &old) {
|
||||
// If the changes were just some rsx, we can just update the template
|
||||
//
|
||||
// However... if the changes involved code in the rsx itself, this should actually be a CodeChanged
|
||||
Some(rsx_calls) => rsx_calls,
|
||||
|
||||
// If the changes were some code, we should insert the file into the map and rebuild
|
||||
// todo: not sure we even need to put the cached file into the map, but whatever
|
||||
None => {
|
||||
let cached_file = CachedSynFile {
|
||||
raw: src.clone(),
|
||||
templates: HashMap::new(),
|
||||
};
|
||||
|
||||
self.map.insert(file_path.to_path_buf(), cached_file);
|
||||
return Err(HotreloadError::Notreloadable);
|
||||
}
|
||||
};
|
||||
|
||||
let mut out_templates = vec![];
|
||||
|
||||
for calls in instances.into_iter() {
|
||||
let ChangedRsx { old, new } = calls;
|
||||
|
||||
let old_start = old.span().start();
|
||||
|
||||
let old_parsed = syn::parse2::<CallBody>(old.tokens);
|
||||
let new_parsed = syn::parse2::<CallBody>(new.tokens);
|
||||
let (Ok(old_call_body), Ok(new_call_body)) = (old_parsed, new_parsed) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// if the file!() macro is invoked in a workspace, the path is relative to the workspace root, otherwise it's relative to the crate root
|
||||
// we need to check if the file is in a workspace or not and strip the prefix accordingly
|
||||
let prefix = match in_workspace {
|
||||
Some(ref workspace) => workspace,
|
||||
_ => crate_dir,
|
||||
};
|
||||
|
||||
let Ok(file) = file_path.strip_prefix(prefix) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// We leak the template since templates are a compiletime value
|
||||
// This is not ideal, but also not a huge deal for hot reloading
|
||||
// TODO: we could consider arena allocating the templates and dropping them when the connection is closed
|
||||
let leaked_location = Box::leak(template_location(old_start, file).into_boxed_str());
|
||||
|
||||
// Returns a list of templates that are hotreloadable
|
||||
let hotreload_result = dioxus_rsx::hotreload::HotReloadedTemplate::new::<Ctx>(
|
||||
&old_call_body,
|
||||
&new_call_body,
|
||||
leaked_location,
|
||||
self.changed_lits.clone(),
|
||||
);
|
||||
|
||||
// if the template is not hotreloadable, we need to do a full rebuild
|
||||
let Some(mut results) = hotreload_result else {
|
||||
return Err(HotreloadError::Notreloadable);
|
||||
};
|
||||
|
||||
// self.changed_lits
|
||||
// .extend(std::mem::take(&mut results.changed_lits));
|
||||
|
||||
// Be careful to not send the bad templates
|
||||
results.templates.retain(|template| {
|
||||
// dioxus cannot handle empty templates...
|
||||
if template.roots.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// if the template is the same, don't send it
|
||||
if old_cached.templates.get(template.name) == Some(template) {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Update the most recent idea of the template
|
||||
// This lets us know if the template has changed so we don't need to send it
|
||||
old_cached.templates.insert(template.name, *template);
|
||||
|
||||
true
|
||||
});
|
||||
|
||||
out_templates.push(results);
|
||||
}
|
||||
|
||||
Ok(out_templates)
|
||||
}
|
||||
|
||||
fn child_in_workspace(&mut self, crate_dir: &Path) -> io::Result<Option<PathBuf>> {
|
||||
if let Some(in_workspace) = self.in_workspace.get(crate_dir) {
|
||||
return Ok(in_workspace.clone());
|
||||
}
|
||||
|
||||
let mut cmd = Cmd::new();
|
||||
let manafest_path = crate_dir.join("Cargo.toml");
|
||||
cmd.manifest_path(&manafest_path);
|
||||
let cmd: MetadataCommand = cmd.into();
|
||||
let metadata = cmd
|
||||
.exec()
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::Other, err))?;
|
||||
|
||||
let in_workspace = metadata.workspace_root != crate_dir;
|
||||
let workspace_path = in_workspace.then(|| metadata.workspace_root.into());
|
||||
self.in_workspace
|
||||
.insert(crate_dir.to_path_buf(), workspace_path.clone());
|
||||
Ok(workspace_path)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn template_location(old_start: proc_macro2::LineColumn, file: &Path) -> String {
|
||||
let line = old_start.line;
|
||||
let column = old_start.column + 1;
|
||||
|
||||
// Always ensure the path components are separated by `/`.
|
||||
let path = file
|
||||
.components()
|
||||
.map(|c| c.as_os_str().to_string_lossy())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
|
||||
path
|
||||
+ ":"
|
||||
+ line.to_string().as_str()
|
||||
+ ":"
|
||||
+ column.to_string().as_str()
|
||||
// the byte index doesn't matter, but dioxus needs it
|
||||
+ ":0"
|
||||
}
|
||||
|
||||
struct FileMapSearchResult {
|
||||
map: HashMap<PathBuf, CachedSynFile>,
|
||||
errors: Vec<io::Error>,
|
||||
}
|
||||
|
||||
// todo: we could just steal the mod logic from rustc itself
|
||||
fn find_rs_files(root: PathBuf, filter: &mut impl FnMut(&Path) -> bool) -> FileMapSearchResult {
|
||||
let mut files = HashMap::new();
|
||||
let mut errors = Vec::new();
|
||||
|
||||
if root.is_dir() {
|
||||
let read_dir = match fs::read_dir(root) {
|
||||
Ok(read_dir) => read_dir,
|
||||
Err(err) => {
|
||||
errors.push(err);
|
||||
return FileMapSearchResult { map: files, errors };
|
||||
}
|
||||
};
|
||||
for entry in read_dir.flatten() {
|
||||
let path = entry.path();
|
||||
if !filter(&path) {
|
||||
let FileMapSearchResult {
|
||||
map,
|
||||
errors: child_errors,
|
||||
} = find_rs_files(path, filter);
|
||||
errors.extend(child_errors);
|
||||
files.extend(map);
|
||||
}
|
||||
}
|
||||
} else if root.extension().and_then(|s| s.to_str()) == Some("rs") {
|
||||
if let Ok(mut file) = File::open(root.clone()) {
|
||||
let mut src = String::new();
|
||||
match file.read_to_string(&mut src) {
|
||||
Ok(_) => {
|
||||
let cached_file = CachedSynFile {
|
||||
raw: src.clone(),
|
||||
templates: HashMap::new(),
|
||||
};
|
||||
|
||||
// track assets while we're here
|
||||
files.insert(root, cached_file);
|
||||
}
|
||||
Err(err) => {
|
||||
errors.push(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FileMapSearchResult { map: files, errors }
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum HotreloadError {
|
||||
Failure(io::Error),
|
||||
Parse,
|
||||
Notreloadable,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HotreloadError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Failure(err) => write!(f, "Failed to parse file: {}", err),
|
||||
Self::Parse => write!(f, "Failed to parse file"),
|
||||
Self::Notreloadable => write!(f, "Template is not hotreloadable"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for HotreloadError {
|
||||
fn from(err: io::Error) -> Self {
|
||||
HotreloadError::Failure(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
use crate::cli::serve::Serve;
|
||||
use crate::dioxus_crate::DioxusCrate;
|
||||
use crate::Result;
|
||||
use dioxus_cli_config::Platform;
|
||||
use tokio::task::yield_now;
|
||||
|
||||
mod builder;
|
||||
mod hot_reloading_file_map;
|
||||
mod logs_tab;
|
||||
mod output;
|
||||
mod proxy;
|
||||
mod server;
|
||||
mod watcher;
|
||||
|
||||
use builder::*;
|
||||
use output::*;
|
||||
use server::*;
|
||||
use watcher::*;
|
||||
|
||||
/// For *all* builds the CLI spins up a dedicated webserver, file watcher, and build infrastructure to serve the project.
|
||||
///
|
||||
/// This includes web, desktop, mobile, fullstack, etc.
|
||||
///
|
||||
/// Platform specifics:
|
||||
/// - Web: we need to attach a filesystem server to our devtools webserver to serve the project. We
|
||||
/// want to emulate GithubPages here since most folks are deploying there and expect things like
|
||||
/// basepath to match.
|
||||
/// - Fullstack: We spin up the same dev server but in this case the fullstack server itself needs to
|
||||
/// proxy all dev requests to our dev server
|
||||
/// - Desktop: We spin up the dev server but without a filesystem server.
|
||||
/// - Mobile: Basically the same as desktop.
|
||||
///
|
||||
/// Notes:
|
||||
/// - All filesystem changes are tracked here
|
||||
/// - We send all updates to connected websocket connections. Even desktop connects via the websocket
|
||||
/// - Right now desktop compiles tokio-tungstenite to do the connection but we could in theory reuse
|
||||
/// the websocket logic from the webview for thinner builds.
|
||||
///
|
||||
/// Todos(Jon):
|
||||
/// - I'd love to be able to configure the CLI while it's running so we can change settingaon the fly.
|
||||
/// This would require some light refactoring and potentially pulling in something like ratatui.
|
||||
/// - Build a custom subscriber for logs by tools within this
|
||||
/// - Handle logs from the build engine separately?
|
||||
/// - Consume logs from the wasm for web/fullstack
|
||||
/// - I want us to be able to detect a `server_fn` in the project and then upgrade from a static server
|
||||
/// to a dynamic one on the fly.
|
||||
pub async fn serve_all(serve: Serve, dioxus_crate: DioxusCrate) -> Result<()> {
|
||||
let mut server = Server::start(&serve, &dioxus_crate);
|
||||
let mut watcher = Watcher::start(&dioxus_crate);
|
||||
let mut screen = Output::start(&serve)
|
||||
.await
|
||||
.expect("Failed to open terminal logger");
|
||||
let mut builder = Builder::new(&dioxus_crate, &serve);
|
||||
|
||||
// Start the first build
|
||||
builder.build();
|
||||
|
||||
loop {
|
||||
// Make sure we don't hog the CPU: these loop { select! {} } blocks can starve the executor
|
||||
yield_now().await;
|
||||
|
||||
// Draw the state of the server to the screen
|
||||
screen.render(&serve, &dioxus_crate, &builder, &server, &watcher);
|
||||
|
||||
// And then wait for any updates before redrawing
|
||||
tokio::select! {
|
||||
// rebuild the project or hotreload it
|
||||
_ = watcher.wait() => {
|
||||
if !watcher.pending_changes() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let changed_files = watcher.dequeue_changed_files(&dioxus_crate);
|
||||
|
||||
// if change is hotreloadable, hotreload it
|
||||
// and then send that update to all connected clients
|
||||
if let Some(hr) = watcher.attempt_hot_reload(&dioxus_crate, changed_files) {
|
||||
// Only send a hotreload message for templates and assets - otherwise we'll just get a full rebuild
|
||||
if hr.templates.is_empty() && hr.assets.is_empty() {
|
||||
continue
|
||||
}
|
||||
|
||||
server.send_hotreload(hr).await;
|
||||
} else {
|
||||
// If the change is not binary patchable, rebuild the project
|
||||
// We're going to kick off a new build, interrupting the current build if it's ongoing
|
||||
builder.build();
|
||||
|
||||
// Tell the server to show a loading page for any new requests
|
||||
server.start_build().await;
|
||||
}
|
||||
}
|
||||
|
||||
// reload the page
|
||||
msg = server.wait() => {
|
||||
// Run the server in the background
|
||||
// Waiting for updates here lets us tap into when clients are added/removed
|
||||
if let Some(msg) = msg {
|
||||
screen.new_ws_message(Platform::Web, msg);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle updates from the build engine
|
||||
application = builder.wait() => {
|
||||
// Wait for logs from the build engine
|
||||
// These will cause us to update the screen
|
||||
// We also can check the status of the builds here in case we have multiple ongoing builds
|
||||
match application {
|
||||
Ok(BuilderUpdate::Progress { platform, update }) => {
|
||||
screen.new_build_logs(platform, update);
|
||||
server.update_build_status(screen.build_progress.progress()).await;
|
||||
}
|
||||
Ok(BuilderUpdate::Ready { results }) => {
|
||||
if !results.is_empty() {
|
||||
builder.children.clear();
|
||||
}
|
||||
|
||||
// If we have a build result, open it
|
||||
for build_result in results.iter() {
|
||||
let child = build_result.open(&serve.server_arguments, server.fullstack_address());
|
||||
match child {
|
||||
Ok(Some(child_proc)) => builder.children.push((build_result.platform,child_proc)),
|
||||
Err(_e) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure we immediately capture the stdout/stderr of the executable -
|
||||
// otherwise it'll clobber our terminal output
|
||||
screen.new_ready_app(&mut builder, results);
|
||||
|
||||
// And then finally tell the server to reload
|
||||
server.send_reload().await;
|
||||
},
|
||||
Err(err) => {
|
||||
server.send_build_error(err).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle input from the user using our settings
|
||||
res = screen.wait() => {
|
||||
if res.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run our cleanup logic here - maybe printing as we go?
|
||||
// todo: more printing, logging, error handling in this phase
|
||||
_ = screen.shutdown();
|
||||
_ = server.shutdown().await;
|
||||
builder.shutdown();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,841 @@
|
||||
use crate::{
|
||||
builder::{BuildMessage, MessageType, Stage, UpdateBuildProgress},
|
||||
dioxus_crate::DioxusCrate,
|
||||
};
|
||||
use crate::{
|
||||
builder::{BuildResult, UpdateStage},
|
||||
serve::Serve,
|
||||
};
|
||||
use core::panic;
|
||||
use crossterm::{
|
||||
event::{Event, EventStream, KeyCode, KeyModifiers, MouseEventKind},
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
tty::IsTty,
|
||||
ExecutableCommand,
|
||||
};
|
||||
use dioxus_cli_config::Platform;
|
||||
use dioxus_hot_reload::ClientMsg;
|
||||
use futures_util::{future::select_all, Future, StreamExt};
|
||||
use ratatui::{prelude::*, widgets::*, TerminalOptions, Viewport};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::{HashMap, HashSet},
|
||||
io::{self, stdout},
|
||||
pin::Pin,
|
||||
rc::Rc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt, BufReader, Lines},
|
||||
process::{ChildStderr, ChildStdout},
|
||||
};
|
||||
use tracing::Level;
|
||||
|
||||
use super::{Builder, Server, Watcher};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct BuildProgress {
|
||||
build_logs: HashMap<Platform, ActiveBuild>,
|
||||
}
|
||||
|
||||
impl BuildProgress {
|
||||
pub fn progress(&self) -> f64 {
|
||||
self.build_logs
|
||||
.values()
|
||||
.min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
|
||||
.map(|build| match build.stage {
|
||||
Stage::Initializing => 0.0,
|
||||
Stage::InstallingWasmTooling => 0.0,
|
||||
Stage::Compiling => build.progress,
|
||||
Stage::OptimizingWasm | Stage::OptimizingAssets | Stage::Finished => 1.0,
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Output {
|
||||
term: Rc<RefCell<Option<TerminalBackend>>>,
|
||||
|
||||
// optional since when there's no tty there's no eventstream to read from - just stdin
|
||||
events: Option<EventStream>,
|
||||
|
||||
_rustc_version: String,
|
||||
_rustc_nightly: bool,
|
||||
_dx_version: String,
|
||||
interactive: bool,
|
||||
pub(crate) build_progress: BuildProgress,
|
||||
running_apps: HashMap<Platform, RunningApp>,
|
||||
is_cli_release: bool,
|
||||
platform: Platform,
|
||||
|
||||
num_lines_with_wrapping: u16,
|
||||
term_height: u16,
|
||||
scroll: u16,
|
||||
fly_modal_open: bool,
|
||||
anim_start: Instant,
|
||||
|
||||
tab: Tab,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Clone, Copy)]
|
||||
enum Tab {
|
||||
Console,
|
||||
BuildLog,
|
||||
}
|
||||
|
||||
type TerminalBackend = Terminal<CrosstermBackend<io::Stdout>>;
|
||||
|
||||
impl Output {
|
||||
pub async fn start(cfg: &Serve) -> io::Result<Self> {
|
||||
let interactive = std::io::stdout().is_tty() && cfg.interactive.unwrap_or(true);
|
||||
|
||||
let mut events = None;
|
||||
|
||||
if interactive {
|
||||
enable_raw_mode()?;
|
||||
stdout().execute(EnterAlternateScreen)?;
|
||||
|
||||
// workaround for ci where the terminal is not fully initialized
|
||||
// this stupid bug
|
||||
// https://github.com/crossterm-rs/crossterm/issues/659
|
||||
events = Some(EventStream::new());
|
||||
};
|
||||
|
||||
// set the panic hook to fix the terminal
|
||||
set_fix_term_hook();
|
||||
|
||||
let term: Option<TerminalBackend> = Terminal::with_options(
|
||||
CrosstermBackend::new(stdout()),
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Fullscreen,
|
||||
},
|
||||
)
|
||||
.ok();
|
||||
|
||||
// todo: re-enable rustc version
|
||||
// let rustc_version = rustc_version().await;
|
||||
// let rustc_nightly = rustc_version.contains("nightly") || cfg.target_args.nightly;
|
||||
let _rustc_version = String::from("1.0.0");
|
||||
let _rustc_nightly = false;
|
||||
|
||||
let mut dx_version = String::new();
|
||||
|
||||
dx_version.push_str(env!("CARGO_PKG_VERSION"));
|
||||
|
||||
let is_cli_release = crate::dx_build_info::PROFILE == "release";
|
||||
|
||||
if !is_cli_release {
|
||||
if let Some(hash) = crate::dx_build_info::GIT_COMMIT_HASH_SHORT {
|
||||
let hash = &hash.trim_start_matches('g')[..4];
|
||||
dx_version.push('-');
|
||||
dx_version.push_str(hash);
|
||||
}
|
||||
}
|
||||
|
||||
let platform = cfg.build_arguments.platform.expect("To be resolved by now");
|
||||
|
||||
Ok(Self {
|
||||
term: Rc::new(RefCell::new(term)),
|
||||
events,
|
||||
_rustc_version,
|
||||
_rustc_nightly,
|
||||
_dx_version: dx_version,
|
||||
interactive,
|
||||
is_cli_release,
|
||||
platform,
|
||||
fly_modal_open: false,
|
||||
build_progress: Default::default(),
|
||||
running_apps: HashMap::new(),
|
||||
scroll: 0,
|
||||
term_height: 0,
|
||||
num_lines_with_wrapping: 0,
|
||||
anim_start: Instant::now(),
|
||||
tab: Tab::BuildLog,
|
||||
})
|
||||
}
|
||||
|
||||
/// Wait for either the ctrl_c handler or the next event
|
||||
///
|
||||
/// Why is the ctrl_c handler here?
|
||||
///
|
||||
/// Also tick animations every few ms
|
||||
pub async fn wait(&mut self) -> io::Result<()> {
|
||||
// sorry lord
|
||||
let user_input = match self.events.as_mut() {
|
||||
Some(events) => {
|
||||
let pinned: Pin<Box<dyn Future<Output = Option<Result<Event, _>>>>> =
|
||||
Box::pin(events.next());
|
||||
pinned
|
||||
}
|
||||
None => Box::pin(futures_util::future::pending()) as Pin<Box<dyn Future<Output = _>>>,
|
||||
};
|
||||
|
||||
let has_running_apps = !self.running_apps.is_empty();
|
||||
let next_stdout = self.running_apps.values_mut().map(|app| {
|
||||
let future = async move {
|
||||
let (stdout, stderr) = match &mut app.stdout {
|
||||
Some(stdout) => (stdout.stdout.next_line(), stdout.stderr.next_line()),
|
||||
None => return futures_util::future::pending().await,
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
Ok(Some(line)) = stdout => (app.result.platform, Some(line), None),
|
||||
Ok(Some(line)) = stderr => (app.result.platform, None, Some(line)),
|
||||
else => futures_util::future::pending().await,
|
||||
}
|
||||
};
|
||||
Box::pin(future)
|
||||
});
|
||||
|
||||
let next_stdout = async {
|
||||
if has_running_apps {
|
||||
select_all(next_stdout).await.0
|
||||
} else {
|
||||
futures_util::future::pending().await
|
||||
}
|
||||
};
|
||||
|
||||
let animation_timeout = tokio::time::sleep(Duration::from_millis(300));
|
||||
|
||||
tokio::select! {
|
||||
(platform, stdout, stderr) = next_stdout => {
|
||||
if let Some(stdout) = stdout {
|
||||
self.running_apps.get_mut(&platform).unwrap().stdout.as_mut().unwrap().stdout_line.push_str(&stdout);
|
||||
self.push_log(platform, BuildMessage {
|
||||
level: Level::INFO,
|
||||
message: MessageType::Text(stdout),
|
||||
source: Some("app".to_string()),
|
||||
})
|
||||
}
|
||||
if let Some(stderr) = stderr {
|
||||
self.running_apps.get_mut(&platform).unwrap().stdout.as_mut().unwrap().stderr_line.push_str(&stderr);
|
||||
self.push_log(platform, BuildMessage {
|
||||
level: Level::ERROR,
|
||||
message: MessageType::Text(stderr),
|
||||
source: Some("app".to_string()),
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
event = user_input => {
|
||||
self.handle_events(event.unwrap().unwrap()).await?;
|
||||
// self.handle_input(event.unwrap().unwrap())?;
|
||||
}
|
||||
|
||||
_ = animation_timeout => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn shutdown(&mut self) -> io::Result<()> {
|
||||
// if we're a tty then we need to disable the raw mode
|
||||
if self.interactive {
|
||||
disable_raw_mode()?;
|
||||
stdout().execute(LeaveAlternateScreen)?;
|
||||
self.drain_print_logs();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Emit the build logs as println! statements such that the terminal has the same output as cargo
|
||||
///
|
||||
/// This is used when the terminal is shutdown and we want the build logs in the terminal. Old
|
||||
/// versions of the cli would just eat build logs making debugging issues harder than they needed
|
||||
/// to be.
|
||||
fn drain_print_logs(&mut self) {
|
||||
// todo: print the build info here for the most recent build, and then the logs of the most recent build
|
||||
for (platform, build) in self.build_progress.build_logs.iter() {
|
||||
if build.messages.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
for message in build.messages.iter() {
|
||||
match &message.message {
|
||||
MessageType::Cargo(diagnostic) => {
|
||||
println!(
|
||||
"{platform}: {}",
|
||||
diagnostic.rendered.as_deref().unwrap_or_default()
|
||||
)
|
||||
}
|
||||
MessageType::Text(t) => println!("{platform}: {t}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_input(&mut self, input: Event) -> io::Result<()> {
|
||||
// handle ctrlc
|
||||
if let Event::Key(key) = input {
|
||||
if let KeyCode::Char('c') = key.code {
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return Err(io::Error::new(io::ErrorKind::Interrupted, "Ctrl-C"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Event::Key(key) = input {
|
||||
if let KeyCode::Char('/') = key.code {
|
||||
self.fly_modal_open = !self.fly_modal_open;
|
||||
}
|
||||
}
|
||||
|
||||
match input {
|
||||
Event::Mouse(mouse) if mouse.kind == MouseEventKind::ScrollUp => {
|
||||
self.scroll = self.scroll.saturating_sub(1);
|
||||
}
|
||||
Event::Mouse(mouse) if mouse.kind == MouseEventKind::ScrollDown => {
|
||||
self.scroll += 1;
|
||||
}
|
||||
Event::Key(key) if key.code == KeyCode::Up => {
|
||||
self.scroll = self.scroll.saturating_sub(1);
|
||||
}
|
||||
Event::Key(key) if key.code == KeyCode::Down => {
|
||||
self.scroll += 1;
|
||||
}
|
||||
Event::Key(key) if key.code == KeyCode::Char('r') => {}
|
||||
Event::Key(key) if key.code == KeyCode::Char('o') => {
|
||||
// todo: open the app
|
||||
}
|
||||
Event::Key(key) if key.code == KeyCode::Char('c') => {
|
||||
// clear
|
||||
}
|
||||
Event::Key(key) if key.code == KeyCode::Char('0') => {
|
||||
self.tab = Tab::Console;
|
||||
self.scroll = 0;
|
||||
}
|
||||
Event::Key(key) if key.code == KeyCode::Char('1') => {
|
||||
self.tab = Tab::BuildLog;
|
||||
self.scroll = 0;
|
||||
}
|
||||
Event::Resize(_width, _height) => {
|
||||
// nothing, it should take care of itself
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if self.scroll
|
||||
> self
|
||||
.num_lines_with_wrapping
|
||||
.saturating_sub(self.term_height + 1)
|
||||
{
|
||||
self.scroll = self
|
||||
.num_lines_with_wrapping
|
||||
.saturating_sub(self.term_height + 1);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn new_ws_message(&mut self, platform: Platform, message: axum::extract::ws::Message) {
|
||||
if let axum::extract::ws::Message::Text(text) = message {
|
||||
let msg = serde_json::from_str::<ClientMsg>(text.as_str());
|
||||
match msg {
|
||||
Ok(ClientMsg::Log { level, messages }) => {
|
||||
self.push_log(
|
||||
platform,
|
||||
BuildMessage {
|
||||
level: match level.as_str() {
|
||||
"info" => Level::INFO,
|
||||
"warn" => Level::WARN,
|
||||
"error" => Level::ERROR,
|
||||
"debug" => Level::DEBUG,
|
||||
_ => Level::INFO,
|
||||
},
|
||||
message: MessageType::Text(
|
||||
// todo: the js console is giving us a list of params, not formatted text
|
||||
// we need to translate its styling into our own
|
||||
messages.first().unwrap_or(&String::new()).clone(),
|
||||
),
|
||||
source: Some("app".to_string()),
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
self.push_log(
|
||||
platform,
|
||||
BuildMessage {
|
||||
level: Level::ERROR,
|
||||
source: Some("app".to_string()),
|
||||
message: MessageType::Text(format!("Error parsing message: {err}")),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// todo: re-enable
|
||||
#[allow(unused)]
|
||||
fn is_snapped(&self, _platform: Platform) -> bool {
|
||||
true
|
||||
// let prev_scrol = self
|
||||
// .num_lines_with_wrapping
|
||||
// .saturating_sub(self.term_height);
|
||||
// prev_scrol == self.scroll
|
||||
}
|
||||
|
||||
pub fn scroll_to_bottom(&mut self) {
|
||||
self.scroll = (self.num_lines_with_wrapping).saturating_sub(self.term_height);
|
||||
}
|
||||
|
||||
pub fn push_log(&mut self, platform: Platform, message: BuildMessage) {
|
||||
let snapped = self.is_snapped(platform);
|
||||
|
||||
if let Some(build) = self.build_progress.build_logs.get_mut(&platform) {
|
||||
build.stdout_logs.push(message);
|
||||
}
|
||||
|
||||
if snapped {
|
||||
self.scroll_to_bottom();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_build_logs(&mut self, platform: Platform, update: UpdateBuildProgress) {
|
||||
let snapped = self.is_snapped(platform);
|
||||
|
||||
// when the build is finished, switch to the console
|
||||
if update.stage == Stage::Finished {
|
||||
self.tab = Tab::Console;
|
||||
}
|
||||
|
||||
self.build_progress
|
||||
.build_logs
|
||||
.entry(platform)
|
||||
.or_default()
|
||||
.update(update);
|
||||
|
||||
if snapped {
|
||||
self.scroll_to_bottom();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_ready_app(&mut self, build_engine: &mut Builder, results: Vec<BuildResult>) {
|
||||
for result in results {
|
||||
let out = build_engine
|
||||
.children
|
||||
.iter_mut()
|
||||
.find_map(|(platform, child)| {
|
||||
if platform == &result.platform {
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let stderr = child.stderr.take().unwrap();
|
||||
Some((stdout, stderr))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let platform = result.platform;
|
||||
|
||||
let stdout = out.map(|(stdout, stderr)| RunningAppOutput {
|
||||
stdout: BufReader::new(stdout).lines(),
|
||||
stderr: BufReader::new(stderr).lines(),
|
||||
stdout_line: String::new(),
|
||||
stderr_line: String::new(),
|
||||
});
|
||||
|
||||
let app = RunningApp { result, stdout };
|
||||
|
||||
self.running_apps.insert(platform, app);
|
||||
|
||||
// Finish the build progress for the platform that just finished building
|
||||
if let Some(build) = self.build_progress.build_logs.get_mut(&platform) {
|
||||
build.stage = Stage::Finished;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(
|
||||
&mut self,
|
||||
_opts: &Serve,
|
||||
_config: &DioxusCrate,
|
||||
_build_engine: &Builder,
|
||||
server: &Server,
|
||||
_watcher: &Watcher,
|
||||
) {
|
||||
// just drain the build logs
|
||||
if !self.interactive {
|
||||
self.drain_print_logs();
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep the animation track in terms of 100ms frames - the frame should be a number between 0 and 10
|
||||
// todo: we want to use this somehow to animate things...
|
||||
let elapsed = self.anim_start.elapsed().as_millis() as f32;
|
||||
let num_frames = elapsed / 100.0;
|
||||
let _frame_step = (num_frames % 10.0) as usize;
|
||||
|
||||
_ = self
|
||||
.term
|
||||
.clone()
|
||||
.borrow_mut()
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.draw(|frame| {
|
||||
// a layout that has a title with stats about the program and then the actual console itself
|
||||
let body = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(
|
||||
[
|
||||
// Title
|
||||
Constraint::Length(1),
|
||||
// Body
|
||||
Constraint::Min(0),
|
||||
]
|
||||
.as_ref(),
|
||||
)
|
||||
.split(frame.size());
|
||||
|
||||
// Split the body into a left and a right
|
||||
let console = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Fill(1), Constraint::Length(14)].as_ref())
|
||||
.split(body[1]);
|
||||
|
||||
let listening_len = "listening at http://127.0.0.1:8080".len() + 3;
|
||||
let listening_len = if listening_len > body[0].width as usize {
|
||||
0
|
||||
} else {
|
||||
listening_len
|
||||
};
|
||||
|
||||
let header = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints(
|
||||
[
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(listening_len as u16),
|
||||
]
|
||||
.as_ref(),
|
||||
)
|
||||
.split(body[0]);
|
||||
|
||||
// // Render a border for the header
|
||||
// frame.render_widget(Block::default().borders(Borders::BOTTOM), body[0]);
|
||||
|
||||
// Render the metadata
|
||||
let mut spans = vec![
|
||||
Span::from(if self.is_cli_release { "dx" } else { "dx-dev" }).green(),
|
||||
Span::from(" ").green(),
|
||||
Span::from("serve").green(),
|
||||
Span::from(" | ").white(),
|
||||
Span::from(self.platform.to_string()).green(),
|
||||
Span::from(" | ").white(),
|
||||
];
|
||||
|
||||
// If there is build progress, display that next to the platform
|
||||
if !self.build_progress.build_logs.is_empty() {
|
||||
if self
|
||||
.build_progress
|
||||
.build_logs
|
||||
.values()
|
||||
.any(|b| b.failed.is_some())
|
||||
{
|
||||
spans.push(Span::from("build failed ❌").red());
|
||||
} else {
|
||||
spans.push(Span::from("status: ").green());
|
||||
let build = self
|
||||
.build_progress
|
||||
.build_logs
|
||||
.values()
|
||||
.min_by(|a, b| a.partial_cmp(b).unwrap())
|
||||
.unwrap();
|
||||
spans.extend_from_slice(&build.spans(Rect::new(
|
||||
0,
|
||||
0,
|
||||
build.max_layout_size(),
|
||||
1,
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
frame.render_widget(Paragraph::new(Line::from(spans)).left_aligned(), header[0]);
|
||||
|
||||
// Split apart the body into a center and a right side
|
||||
// We only want to show the sidebar if there's enough space
|
||||
if listening_len > 0 {
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::from("listening at ").dark_gray(),
|
||||
Span::from(format!("http://{}", server.ip).as_str()).gray(),
|
||||
])),
|
||||
header[1],
|
||||
);
|
||||
}
|
||||
|
||||
// Draw the tabs in the right region of the console
|
||||
// First draw the left border
|
||||
frame.render_widget(
|
||||
Paragraph::new(vec![
|
||||
{
|
||||
let mut line = Line::from(" [0] console").dark_gray();
|
||||
if self.tab == Tab::Console {
|
||||
line.style = Style::default().fg(Color::LightYellow);
|
||||
}
|
||||
line
|
||||
},
|
||||
{
|
||||
let mut line = Line::from(" [1] build").dark_gray();
|
||||
if self.tab == Tab::BuildLog {
|
||||
line.style = Style::default().fg(Color::LightYellow);
|
||||
}
|
||||
line
|
||||
},
|
||||
Line::from(" ").gray(),
|
||||
Line::from(" [/] more").gray(),
|
||||
Line::from(" [r] reload").gray(),
|
||||
Line::from(" [r] clear").gray(),
|
||||
Line::from(" [o] open").gray(),
|
||||
Line::from(" [h] hide").gray(),
|
||||
])
|
||||
.left_aligned()
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::LEFT | Borders::TOP)
|
||||
.border_set(symbols::border::Set {
|
||||
top_left: symbols::line::NORMAL.horizontal_down,
|
||||
..symbols::border::PLAIN
|
||||
}),
|
||||
),
|
||||
console[1],
|
||||
);
|
||||
|
||||
// We're going to assemble a text buffer directly and then let the paragraph widgets
|
||||
// handle the wrapping and scrolling
|
||||
let mut paragraph_text: Text<'_> = Text::default();
|
||||
|
||||
for platform in self.build_progress.build_logs.keys() {
|
||||
let build = self.build_progress.build_logs.get(platform).unwrap();
|
||||
|
||||
let msgs = match self.tab {
|
||||
Tab::Console => &build.stdout_logs,
|
||||
Tab::BuildLog => &build.messages,
|
||||
};
|
||||
|
||||
for span in msgs.iter() {
|
||||
use ansi_to_tui::IntoText;
|
||||
match &span.message {
|
||||
MessageType::Text(line) => {
|
||||
for line in line.lines() {
|
||||
let text = line.into_text().unwrap_or_default();
|
||||
for line in text.lines {
|
||||
let mut out_line = vec![Span::from("[app] ").dark_gray()];
|
||||
for span in line.spans {
|
||||
out_line.push(span);
|
||||
}
|
||||
let newline = Line::from(out_line);
|
||||
paragraph_text.push_line(newline);
|
||||
}
|
||||
}
|
||||
}
|
||||
MessageType::Cargo(diagnostic) => {
|
||||
let diagnostic = diagnostic.rendered.as_deref().unwrap_or_default();
|
||||
|
||||
for line in diagnostic.lines() {
|
||||
paragraph_text.extend(line.into_text().unwrap_or_default());
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let paragraph = Paragraph::new(paragraph_text)
|
||||
.left_aligned()
|
||||
.wrap(Wrap { trim: false });
|
||||
|
||||
self.term_height = console[0].height;
|
||||
self.num_lines_with_wrapping = paragraph.line_count(console[0].width) as u16;
|
||||
|
||||
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
|
||||
.begin_symbol(None)
|
||||
.end_symbol(None)
|
||||
.track_symbol(None)
|
||||
.thumb_symbol("▐");
|
||||
|
||||
let mut scrollbar_state = ScrollbarState::new(
|
||||
self.num_lines_with_wrapping
|
||||
.saturating_sub(self.term_height) as usize,
|
||||
)
|
||||
.position(self.scroll as usize);
|
||||
|
||||
let paragraph = paragraph.scroll((self.scroll, 0));
|
||||
paragraph
|
||||
.block(Block::new().borders(Borders::TOP))
|
||||
.render(console[0], frame.buffer_mut());
|
||||
|
||||
// and the scrollbar, those are separate widgets
|
||||
frame.render_stateful_widget(
|
||||
scrollbar,
|
||||
console[0].inner(Margin {
|
||||
// todo: dont use margin - just push down the body based on its top border
|
||||
// using an inner vertical margin of 1 unit makes the scrollbar inside the block
|
||||
vertical: 1,
|
||||
horizontal: 0,
|
||||
}),
|
||||
&mut scrollbar_state,
|
||||
);
|
||||
|
||||
// render the fly modal
|
||||
self.render_fly_modal(frame, console[0]);
|
||||
});
|
||||
}
|
||||
|
||||
async fn handle_events(&mut self, event: Event) -> io::Result<()> {
|
||||
let mut events = vec![event];
|
||||
|
||||
// Collect all the events within the next 10ms in one stream
|
||||
loop {
|
||||
let next = self.events.as_mut().unwrap().next();
|
||||
tokio::select! {
|
||||
msg = next => events.push(msg.unwrap().unwrap()),
|
||||
_ = tokio::time::sleep(Duration::from_millis(1)) => break
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce events within the same frame
|
||||
let mut handled = HashSet::new();
|
||||
for event in events {
|
||||
if !handled.contains(&event) {
|
||||
self.handle_input(event.clone())?;
|
||||
handled.insert(event);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_fly_modal(&mut self, frame: &mut Frame, area: Rect) {
|
||||
if !self.fly_modal_open {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a frame slightly smaller than the area
|
||||
let panel = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Fill(1)].as_ref())
|
||||
.split(area)[0];
|
||||
|
||||
// Wipe the panel
|
||||
frame.render_widget(Clear, panel);
|
||||
frame.render_widget(Block::default().borders(Borders::ALL), panel);
|
||||
|
||||
let modal = Paragraph::new(
|
||||
"Hello world!\nHello world!\nHello world!\nHello world!\nHello world!\n",
|
||||
)
|
||||
.alignment(Alignment::Center);
|
||||
frame.render_widget(modal, panel);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, PartialEq)]
|
||||
pub struct ActiveBuild {
|
||||
stage: Stage,
|
||||
messages: Vec<BuildMessage>,
|
||||
stdout_logs: Vec<BuildMessage>,
|
||||
progress: f64,
|
||||
failed: Option<String>,
|
||||
}
|
||||
|
||||
impl ActiveBuild {
|
||||
fn update(&mut self, update: UpdateBuildProgress) {
|
||||
match update.update {
|
||||
UpdateStage::Start => {
|
||||
self.stage = update.stage;
|
||||
self.progress = 0.0;
|
||||
self.failed = None;
|
||||
}
|
||||
UpdateStage::AddMessage(message) => {
|
||||
self.messages.push(message);
|
||||
}
|
||||
UpdateStage::SetProgress(progress) => {
|
||||
self.progress = progress;
|
||||
}
|
||||
UpdateStage::Failed(failed) => {
|
||||
self.stage = Stage::Finished;
|
||||
self.failed = Some(failed.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spans(&self, area: Rect) -> Vec<Span> {
|
||||
let mut spans = Vec::new();
|
||||
|
||||
let message = match self.stage {
|
||||
Stage::Initializing => "initializing... ",
|
||||
Stage::InstallingWasmTooling => "installing wasm tools... ",
|
||||
Stage::Compiling => "compiling... ",
|
||||
Stage::OptimizingWasm => "optimizing wasm... ",
|
||||
Stage::OptimizingAssets => "optimizing assets... ",
|
||||
Stage::Finished => "finished! 🎉 ",
|
||||
};
|
||||
let progress = format!("{}%", (self.progress * 100.0) as u8);
|
||||
|
||||
if area.width >= self.max_layout_size() {
|
||||
spans.push(Span::from(message).light_yellow());
|
||||
|
||||
if self.stage != Stage::Finished {
|
||||
spans.push(Span::from(progress).white());
|
||||
}
|
||||
} else {
|
||||
spans.push(Span::from(progress).white());
|
||||
}
|
||||
|
||||
spans
|
||||
}
|
||||
|
||||
fn max_layout_size(&self) -> u16 {
|
||||
let progress_size = 4;
|
||||
let stage_size = self.stage.to_string().len() as u16;
|
||||
let brace_size = 2;
|
||||
|
||||
progress_size + stage_size + brace_size
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for ActiveBuild {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(
|
||||
self.stage
|
||||
.cmp(&other.stage)
|
||||
.then(self.progress.partial_cmp(&other.progress).unwrap()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn set_fix_term_hook() {
|
||||
let original_hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
_ = disable_raw_mode();
|
||||
_ = stdout().execute(LeaveAlternateScreen);
|
||||
original_hook(info);
|
||||
}));
|
||||
}
|
||||
|
||||
// todo: re-enable
|
||||
#[allow(unused)]
|
||||
async fn rustc_version() -> String {
|
||||
tokio::process::Command::new("rustc")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.await
|
||||
.ok()
|
||||
.map(|o| o.stdout)
|
||||
.and_then(|o| {
|
||||
let out = String::from_utf8(o).unwrap();
|
||||
out.split_ascii_whitespace().nth(1).map(|v| v.to_string())
|
||||
})
|
||||
.unwrap_or_else(|| "<unknown>".to_string())
|
||||
}
|
||||
|
||||
pub struct RunningApp {
|
||||
result: BuildResult,
|
||||
stdout: Option<RunningAppOutput>,
|
||||
}
|
||||
|
||||
struct RunningAppOutput {
|
||||
stdout: Lines<BufReader<ChildStdout>>,
|
||||
stderr: Lines<BufReader<ChildStderr>>,
|
||||
stdout_line: String,
|
||||
stderr_line: String,
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
use crate::{Error, Result};
|
||||
use dioxus_cli_config::WebProxyConfig;
|
||||
|
||||
use anyhow::{anyhow, Context};
|
||||
use axum::body::Body as MyBody;
|
||||
use axum::body::Body;
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
routing::{any, MethodRouter},
|
||||
Router,
|
||||
};
|
||||
use hyper::{Request, Response, Uri};
|
||||
use hyper_util::{
|
||||
client::legacy::{self, connect::HttpConnector},
|
||||
rt::TokioExecutor,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ProxyClient {
|
||||
inner: legacy::Client<hyper_rustls::HttpsConnector<HttpConnector>, MyBody>,
|
||||
url: Uri,
|
||||
}
|
||||
|
||||
impl ProxyClient {
|
||||
fn new(url: Uri) -> Self {
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
let https = hyper_rustls::HttpsConnectorBuilder::new()
|
||||
.with_native_roots()
|
||||
.unwrap()
|
||||
.https_or_http()
|
||||
.enable_http1()
|
||||
.build();
|
||||
Self {
|
||||
inner: legacy::Client::builder(TokioExecutor::new()).build(https),
|
||||
url,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send(&self, mut req: Request<MyBody>) -> Result<Response<hyper::body::Incoming>> {
|
||||
let mut uri_parts = req.uri().clone().into_parts();
|
||||
uri_parts.authority = self.url.authority().cloned();
|
||||
uri_parts.scheme = self.url.scheme().cloned();
|
||||
*req.uri_mut() = Uri::from_parts(uri_parts).context("Invalid URI parts")?;
|
||||
self.inner
|
||||
.request(req)
|
||||
.await
|
||||
.map_err(|err| crate::error::Error::Other(anyhow!(err)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Add routes to the router handling the specified proxy config.
|
||||
///
|
||||
/// We will proxy requests directed at either:
|
||||
///
|
||||
/// - the exact path of the proxy config's backend URL, e.g. /api
|
||||
/// - the exact path with a trailing slash, e.g. /api/
|
||||
/// - any subpath of the backend URL, e.g. /api/foo/bar
|
||||
pub fn add_proxy(mut router: Router, proxy: &WebProxyConfig) -> Result<Router> {
|
||||
let url: Uri = proxy.backend.parse()?;
|
||||
let path = url.path().to_string();
|
||||
let trimmed_path = path.trim_start_matches('/');
|
||||
|
||||
if trimmed_path.is_empty() {
|
||||
return Err(crate::Error::ProxySetupError(format!(
|
||||
"Proxy backend URL must have a non-empty path, e.g. {}/api instead of {}",
|
||||
proxy.backend.trim_end_matches('/'),
|
||||
proxy.backend
|
||||
)));
|
||||
}
|
||||
|
||||
let method_router = proxy_to(url, false, handle_proxy_error);
|
||||
|
||||
// api/*path
|
||||
router = router.route(
|
||||
&format!("/{}/*path", trimmed_path.trim_end_matches('/')),
|
||||
method_router.clone(),
|
||||
);
|
||||
|
||||
// /api/
|
||||
router = router.route(
|
||||
&format!("/{}/", trimmed_path.trim_end_matches('/')),
|
||||
method_router.clone(),
|
||||
);
|
||||
|
||||
// /api
|
||||
router = router.route(
|
||||
&format!("/{}", trimmed_path.trim_end_matches('/')),
|
||||
method_router,
|
||||
);
|
||||
|
||||
Ok(router)
|
||||
}
|
||||
|
||||
pub(crate) fn proxy_to(
|
||||
url: Uri,
|
||||
nocache: bool,
|
||||
handle_error: fn(Error) -> Response<Body>,
|
||||
) -> MethodRouter {
|
||||
let client = ProxyClient::new(url);
|
||||
|
||||
any(move |mut req: Request<MyBody>| async move {
|
||||
// Prevent request loops
|
||||
if req.headers().get("x-proxied-by-dioxus").is_some() {
|
||||
return Err(Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::from(
|
||||
"API is sharing a loopback with the dev server. Try setting a different port on the API config.",
|
||||
))
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
req.headers_mut().insert(
|
||||
"x-proxied-by-dioxus",
|
||||
"true".parse().expect("header value is valid"),
|
||||
);
|
||||
|
||||
if nocache {
|
||||
crate::serve::insert_no_cache_headers(req.headers_mut());
|
||||
}
|
||||
|
||||
client.send(req).await.map_err(handle_error)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_proxy_error(e: Error) -> axum::http::Response<axum::body::Body> {
|
||||
tracing::error!("Proxy error: {}", e);
|
||||
axum::http::Response::builder()
|
||||
.status(axum::http::StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.body(axum::body::Body::from(format!(
|
||||
"Proxy connection failed: {:#?}",
|
||||
e
|
||||
)))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
|
||||
use super::*;
|
||||
|
||||
use axum_server::{Handle, Server};
|
||||
|
||||
async fn setup_servers(mut config: WebProxyConfig) -> String {
|
||||
let backend_router =
|
||||
Router::new().route(
|
||||
"/*path",
|
||||
any(|request: axum::extract::Request| async move {
|
||||
format!("backend: {}", request.uri())
|
||||
}),
|
||||
);
|
||||
|
||||
// The API backend server
|
||||
let backend_handle_handle = Handle::new();
|
||||
let backend_handle_handle_ = backend_handle_handle.clone();
|
||||
tokio::spawn(async move {
|
||||
Server::bind("127.0.0.1:0".parse().unwrap())
|
||||
.handle(backend_handle_handle_)
|
||||
.serve(backend_router.into_make_service())
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
// Set the user's config to this dummy API we just built so we can test it
|
||||
let backend_addr = backend_handle_handle.listening().await.unwrap();
|
||||
config.backend = format!("http://{}{}", backend_addr, config.backend);
|
||||
|
||||
// Now set up our actual filesystem server
|
||||
let router = super::add_proxy(Router::new(), &config);
|
||||
let server_handle_handle = Handle::new();
|
||||
let server_handle_handle_ = server_handle_handle.clone();
|
||||
tokio::spawn(async move {
|
||||
Server::bind("127.0.0.1:0".parse().unwrap())
|
||||
.handle(server_handle_handle_)
|
||||
.serve(router.unwrap().into_make_service())
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
// Expose *just* the fileystem web server's address
|
||||
server_handle_handle.listening().await.unwrap().to_string()
|
||||
}
|
||||
|
||||
async fn test_proxy_requests(path: String) {
|
||||
let config = WebProxyConfig {
|
||||
// Normally this would be an absolute URL including scheme/host/port,
|
||||
// but in these tests we need to let the OS choose the port so tests
|
||||
// don't conflict, so we'll concatenate the final address and this
|
||||
// path together.
|
||||
// So in day to day usage, use `http://localhost:8000/api` instead!
|
||||
backend: path,
|
||||
};
|
||||
|
||||
let server_addr = setup_servers(config).await;
|
||||
|
||||
assert_eq!(
|
||||
reqwest::get(format!("http://{}/api", server_addr))
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap(),
|
||||
"backend: /api"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
reqwest::get(format!("http://{}/api/", server_addr))
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap(),
|
||||
"backend: /api/"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
reqwest::get(format!("http://{server_addr}/api/subpath"))
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap(),
|
||||
"backend: /api/subpath"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_proxy() {
|
||||
test_proxy_requests("/api".to_string()).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_proxy_trailing_slash() {
|
||||
test_proxy_requests("/api/".to_string()).await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_proxy_empty_path() {
|
||||
let config = WebProxyConfig {
|
||||
backend: "http://localhost:8000".to_string(),
|
||||
};
|
||||
let router = super::add_proxy(Router::new(), &config);
|
||||
match router.unwrap_err() {
|
||||
crate::Error::ProxySetupError(e) => {
|
||||
assert_eq!(
|
||||
e,
|
||||
"Proxy backend URL must have a non-empty path, e.g. http://localhost:8000/api instead of http://localhost:8000"
|
||||
);
|
||||
}
|
||||
e => panic!("Unexpected error type: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
use crate::dioxus_crate::DioxusCrate;
|
||||
use crate::serve::Serve;
|
||||
use crate::{Error, Result};
|
||||
use axum::extract::{Request, State};
|
||||
use axum::middleware::{self, Next};
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{
|
||||
ws::{Message, WebSocket},
|
||||
Extension, WebSocketUpgrade,
|
||||
},
|
||||
http::{
|
||||
header::{HeaderName, HeaderValue, CACHE_CONTROL, EXPIRES, PRAGMA},
|
||||
Method, Response, StatusCode,
|
||||
},
|
||||
response::IntoResponse,
|
||||
routing::{get, get_service},
|
||||
Router,
|
||||
};
|
||||
use axum_server::tls_rustls::RustlsConfig;
|
||||
use dioxus_cli_config::{Platform, WebHttpsConfig};
|
||||
use dioxus_hot_reload::{DevserverMsg, HotReloadMsg};
|
||||
use futures_channel::mpsc::{UnboundedReceiver, UnboundedSender};
|
||||
use futures_util::{stream::FuturesUnordered, StreamExt};
|
||||
use hyper::header::ACCEPT;
|
||||
use hyper::HeaderMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::TcpListener;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
use std::{
|
||||
convert::Infallible,
|
||||
fs, io,
|
||||
net::{IpAddr, SocketAddr},
|
||||
process::Command,
|
||||
};
|
||||
use tokio::task::JoinHandle;
|
||||
use tower::ServiceBuilder;
|
||||
use tower_http::{
|
||||
cors::{Any, CorsLayer},
|
||||
services::fs::{ServeDir, ServeFileSystemResponseBody},
|
||||
ServiceBuilderExt,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", content = "data")]
|
||||
enum Status {
|
||||
Building { progress: f64 },
|
||||
BuildError { error: String },
|
||||
Ready,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SharedStatus(Arc<RwLock<Status>>);
|
||||
|
||||
impl SharedStatus {
|
||||
fn new(status: Status) -> Self {
|
||||
Self(Arc::new(RwLock::new(status)))
|
||||
}
|
||||
|
||||
fn set(&self, status: Status) {
|
||||
*self.0.write().unwrap() = status;
|
||||
}
|
||||
|
||||
fn get(&self) -> Status {
|
||||
self.0.read().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Server {
|
||||
pub hot_reload_sockets: Vec<WebSocket>,
|
||||
pub build_status_sockets: Vec<WebSocket>,
|
||||
pub ip: SocketAddr,
|
||||
pub new_hot_reload_sockets: UnboundedReceiver<WebSocket>,
|
||||
pub new_build_status_sockets: UnboundedReceiver<WebSocket>,
|
||||
_server_task: JoinHandle<Result<()>>,
|
||||
/// We proxy (not hot reloading) fullstack requests to this port
|
||||
pub fullstack_port: Option<u16>,
|
||||
build_status: SharedStatus,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn start(serve: &Serve, cfg: &DioxusCrate) -> Self {
|
||||
let (hot_reload_sockets_tx, hot_reload_sockets_rx) = futures_channel::mpsc::unbounded();
|
||||
let (build_status_sockets_tx, build_status_sockets_rx) = futures_channel::mpsc::unbounded();
|
||||
let build_status = SharedStatus::new(Status::Building { progress: 0.0 });
|
||||
|
||||
let addr = serve.server_arguments.address.address();
|
||||
let start_browser = serve.server_arguments.open.unwrap_or_default();
|
||||
|
||||
// If we're serving a fullstack app, we need to find a port to proxy to
|
||||
let fullstack_port = if matches!(
|
||||
serve.build_arguments.platform(),
|
||||
Platform::Fullstack | Platform::StaticGeneration
|
||||
) {
|
||||
get_available_port(addr.ip())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let fullstack_address = fullstack_port.map(|port| SocketAddr::new(addr.ip(), port));
|
||||
|
||||
let router = setup_router(
|
||||
serve,
|
||||
cfg,
|
||||
hot_reload_sockets_tx,
|
||||
build_status_sockets_tx,
|
||||
fullstack_address,
|
||||
build_status.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Actually just start the server, cloning in a few bits of config
|
||||
let web_config = cfg.dioxus_config.web.https.clone();
|
||||
let base_path = cfg.dioxus_config.web.app.base_path.clone();
|
||||
let _server_task = tokio::spawn(async move {
|
||||
let web_config = web_config.clone();
|
||||
// HTTPS
|
||||
// Before console info so it can stop if mkcert isn't installed or fails
|
||||
// todo: this is the only async thing here - might be nice to
|
||||
let rustls: Option<RustlsConfig> = get_rustls(&web_config).await.unwrap();
|
||||
|
||||
// Open the browser
|
||||
if start_browser {
|
||||
open_browser(base_path, addr, rustls.is_some());
|
||||
}
|
||||
|
||||
// Start the server with or without rustls
|
||||
if let Some(rustls) = rustls {
|
||||
axum_server::bind_rustls(addr, rustls)
|
||||
.serve(router.into_make_service())
|
||||
.await?
|
||||
} else {
|
||||
// Create a TCP listener bound to the address
|
||||
axum::serve(
|
||||
tokio::net::TcpListener::bind(&addr).await?,
|
||||
router.into_make_service(),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
Self {
|
||||
hot_reload_sockets: Default::default(),
|
||||
build_status_sockets: Default::default(),
|
||||
new_hot_reload_sockets: hot_reload_sockets_rx,
|
||||
new_build_status_sockets: build_status_sockets_rx,
|
||||
_server_task,
|
||||
ip: addr,
|
||||
fullstack_port,
|
||||
build_status,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_build_status(&mut self) {
|
||||
let mut i = 0;
|
||||
while i < self.build_status_sockets.len() {
|
||||
let socket = &mut self.build_status_sockets[i];
|
||||
if send_build_status_to(&self.build_status, socket)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
self.build_status_sockets.remove(i);
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_build(&mut self) {
|
||||
self.build_status.set(Status::Building { progress: 0.0 });
|
||||
self.send_build_status().await;
|
||||
}
|
||||
|
||||
pub async fn update_build_status(&mut self, progress: f64) {
|
||||
if !matches!(self.build_status.get(), Status::Building { .. }) {
|
||||
return;
|
||||
}
|
||||
self.build_status.set(Status::Building { progress });
|
||||
self.send_build_status().await;
|
||||
}
|
||||
|
||||
pub async fn send_hotreload(&mut self, reload: HotReloadMsg) {
|
||||
let msg = DevserverMsg::HotReload(reload);
|
||||
let msg = serde_json::to_string(&msg).unwrap();
|
||||
|
||||
// Send the changes to any connected clients
|
||||
let mut i = 0;
|
||||
while i < self.hot_reload_sockets.len() {
|
||||
let socket = &mut self.hot_reload_sockets[i];
|
||||
if socket.send(Message::Text(msg.clone())).await.is_err() {
|
||||
self.hot_reload_sockets.remove(i);
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for new clients to be connected and then save them
|
||||
pub async fn wait(&mut self) -> Option<Message> {
|
||||
let mut new_hot_reload_socket = self.new_hot_reload_sockets.next();
|
||||
let mut new_build_status_socket = self.new_build_status_sockets.next();
|
||||
let mut new_message = self
|
||||
.hot_reload_sockets
|
||||
.iter_mut()
|
||||
.enumerate()
|
||||
.map(|(idx, socket)| async move { (idx, socket.next().await) })
|
||||
.collect::<FuturesUnordered<_>>();
|
||||
|
||||
tokio::select! {
|
||||
new_hot_reload_socket = &mut new_hot_reload_socket => {
|
||||
if let Some(new_socket) = new_hot_reload_socket {
|
||||
drop(new_message);
|
||||
self.hot_reload_sockets.push(new_socket);
|
||||
return None;
|
||||
} else {
|
||||
panic!("Could not receive a socket - the devtools could not boot - the port is likely already in use");
|
||||
}
|
||||
}
|
||||
new_build_status_socket = &mut new_build_status_socket => {
|
||||
if let Some(mut new_socket) = new_build_status_socket {
|
||||
drop(new_message);
|
||||
// Update the socket with the current status
|
||||
if send_build_status_to(&self.build_status, &mut new_socket).await.is_ok() {
|
||||
self.build_status_sockets.push(new_socket);
|
||||
}
|
||||
return None;
|
||||
} else {
|
||||
panic!("Could not receive a socket - the devtools could not boot - the port is likely already in use");
|
||||
}
|
||||
}
|
||||
Some((idx, message)) = new_message.next() => {
|
||||
match message {
|
||||
Some(Ok(message)) => return Some(message),
|
||||
_ => {
|
||||
drop(new_message);
|
||||
_ = self.hot_reload_sockets.remove(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn send_build_error(&mut self, error: Error) {
|
||||
let error = error.to_string();
|
||||
self.build_status.set(Status::BuildError {
|
||||
error: ansi_to_html::convert(&error).unwrap_or(error),
|
||||
});
|
||||
self.send_build_status().await;
|
||||
}
|
||||
|
||||
pub async fn send_reload(&mut self) {
|
||||
self.build_status.set(Status::Ready);
|
||||
self.send_build_status().await;
|
||||
for socket in self.hot_reload_sockets.iter_mut() {
|
||||
_ = socket
|
||||
.send(Message::Text(
|
||||
serde_json::to_string(&DevserverMsg::FullReload).unwrap(),
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a shutdown message to all connected clients
|
||||
pub async fn send_shutdown(&mut self) {
|
||||
for socket in self.hot_reload_sockets.iter_mut() {
|
||||
_ = socket
|
||||
.send(Message::Text(
|
||||
serde_json::to_string(&DevserverMsg::Shutdown).unwrap(),
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn shutdown(&mut self) {
|
||||
self.send_shutdown().await;
|
||||
for socket in self.hot_reload_sockets.drain(..) {
|
||||
_ = socket.close().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the address the fullstack server should run on if we're serving a fullstack app
|
||||
pub fn fullstack_address(&self) -> Option<SocketAddr> {
|
||||
self.fullstack_port
|
||||
.map(|port| SocketAddr::new(self.ip.ip(), port))
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets up and returns a router
|
||||
///
|
||||
/// Steps include:
|
||||
/// - Setting up cors
|
||||
/// - Setting up the proxy to the endpoint specified in the config
|
||||
/// - Setting up the file serve service
|
||||
/// - Setting up the websocket endpoint for devtools
|
||||
fn setup_router(
|
||||
serve: &Serve,
|
||||
config: &DioxusCrate,
|
||||
hot_reload_sockets: UnboundedSender<WebSocket>,
|
||||
build_status_sockets: UnboundedSender<WebSocket>,
|
||||
fullstack_address: Option<SocketAddr>,
|
||||
build_status: SharedStatus,
|
||||
) -> Result<Router> {
|
||||
let mut router = Router::new();
|
||||
let platform = serve.build_arguments.platform();
|
||||
|
||||
// Setup proxy for the endpoint specified in the config
|
||||
for proxy_config in config.dioxus_config.web.proxy.iter() {
|
||||
router = super::proxy::add_proxy(router, proxy_config)?;
|
||||
}
|
||||
|
||||
// Setup base path redirection
|
||||
if let Some(base_path) = config.dioxus_config.web.app.base_path.clone() {
|
||||
let base_path = format!("/{}", base_path.trim_matches('/'));
|
||||
router = Router::new()
|
||||
.nest(&base_path, router)
|
||||
.fallback(get(move || async move {
|
||||
format!("Outside of the base path: {}", base_path)
|
||||
}));
|
||||
}
|
||||
|
||||
// server the dir if it's web, otherwise let the fullstack server itself handle it
|
||||
match platform {
|
||||
Platform::Web => {
|
||||
// Route file service to output the .wasm and assets if this is a web build
|
||||
router = router.fallback(build_serve_dir(serve, config));
|
||||
}
|
||||
Platform::Fullstack | Platform::StaticGeneration => {
|
||||
// For fullstack and static generation, forward all requests to the server
|
||||
let address = fullstack_address.unwrap();
|
||||
|
||||
router = router.fallback(super::proxy::proxy_to(
|
||||
format!("http://{address}").parse().unwrap(),
|
||||
true,
|
||||
|error| {
|
||||
Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.body(Body::from(format!(
|
||||
"Backend connection failed. The backend is likely still starting up. Please try again in a few seconds. Error: {:#?}",
|
||||
error
|
||||
)))
|
||||
.unwrap()
|
||||
},
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Setup middleware to intercept html requests if the build status is "Building"
|
||||
router = router.layer(middleware::from_fn_with_state(
|
||||
build_status,
|
||||
build_status_middleware,
|
||||
));
|
||||
|
||||
// Setup websocket endpoint - and pass in the extension layer immediately after
|
||||
router = router.nest(
|
||||
"/_dioxus",
|
||||
Router::new()
|
||||
.route(
|
||||
"/",
|
||||
get(
|
||||
|ws: WebSocketUpgrade, ext: Extension<UnboundedSender<WebSocket>>| async move {
|
||||
ws.on_upgrade(move |socket| async move { _ = ext.0.unbounded_send(socket) })
|
||||
},
|
||||
),
|
||||
)
|
||||
.layer(Extension(hot_reload_sockets))
|
||||
.route(
|
||||
"/build_status",
|
||||
get(
|
||||
|ws: WebSocketUpgrade, ext: Extension<UnboundedSender<WebSocket>>| async move {
|
||||
ws.on_upgrade(move |socket| async move { _ = ext.0.unbounded_send(socket) })
|
||||
},
|
||||
),
|
||||
)
|
||||
.layer(Extension(build_status_sockets)),
|
||||
);
|
||||
|
||||
// Setup cors
|
||||
router = router.layer(
|
||||
CorsLayer::new()
|
||||
// allow `GET` and `POST` when accessing the resource
|
||||
.allow_methods([Method::GET, Method::POST])
|
||||
// allow requests from any origin
|
||||
.allow_origin(Any)
|
||||
.allow_headers(Any),
|
||||
);
|
||||
|
||||
Ok(router)
|
||||
}
|
||||
|
||||
fn build_serve_dir(serve: &Serve, cfg: &DioxusCrate) -> axum::routing::MethodRouter {
|
||||
static CORS_UNSAFE: (HeaderValue, HeaderValue) = (
|
||||
HeaderValue::from_static("unsafe-none"),
|
||||
HeaderValue::from_static("unsafe-none"),
|
||||
);
|
||||
|
||||
static CORS_REQUIRE: (HeaderValue, HeaderValue) = (
|
||||
HeaderValue::from_static("require-corp"),
|
||||
HeaderValue::from_static("same-origin"),
|
||||
);
|
||||
|
||||
let (coep, coop) = match serve.server_arguments.cross_origin_policy {
|
||||
true => CORS_REQUIRE.clone(),
|
||||
false => CORS_UNSAFE.clone(),
|
||||
};
|
||||
|
||||
let out_dir = cfg.out_dir();
|
||||
let index_on_404 = cfg.dioxus_config.web.watcher.index_on_404;
|
||||
|
||||
get_service(
|
||||
ServiceBuilder::new()
|
||||
.override_response_header(
|
||||
HeaderName::from_static("cross-origin-embedder-policy"),
|
||||
coep,
|
||||
)
|
||||
.override_response_header(HeaderName::from_static("cross-origin-opener-policy"), coop)
|
||||
.and_then({
|
||||
let out_dir = out_dir.clone();
|
||||
move |response| async move { Ok(no_cache(index_on_404, &out_dir, response)) }
|
||||
})
|
||||
.service(ServeDir::new(out_dir)),
|
||||
)
|
||||
.handle_error(|error: Infallible| async move {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Unhandled internal error: {}", error),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn no_cache(
|
||||
index_on_404: bool,
|
||||
out_dir: &Path,
|
||||
response: Response<ServeFileSystemResponseBody>,
|
||||
) -> Response<Body> {
|
||||
// By default we just decompose into the response
|
||||
let mut response = response.into_response();
|
||||
|
||||
// If there's a 404 and we're supposed to index on 404, upgrade that failed request to the index.html
|
||||
// We migth want to isnert a header here saying we *did* that but oh well
|
||||
if response.status() == StatusCode::NOT_FOUND && index_on_404 {
|
||||
let body = Body::from(std::fs::read_to_string(out_dir.join("index.html")).unwrap());
|
||||
|
||||
response = Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.body(body)
|
||||
.unwrap();
|
||||
};
|
||||
|
||||
insert_no_cache_headers(response.headers_mut());
|
||||
response
|
||||
}
|
||||
|
||||
pub fn insert_no_cache_headers(headers: &mut HeaderMap) {
|
||||
headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache"));
|
||||
headers.insert(PRAGMA, HeaderValue::from_static("no-cache"));
|
||||
headers.insert(EXPIRES, HeaderValue::from_static("0"));
|
||||
}
|
||||
|
||||
/// Returns an enum of rustls config
|
||||
pub async fn get_rustls(web_config: &WebHttpsConfig) -> Result<Option<RustlsConfig>> {
|
||||
if web_config.enabled != Some(true) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let (cert_path, key_path) = match web_config.mkcert {
|
||||
Some(true) => get_rustls_with_mkcert(web_config)?,
|
||||
_ => get_rustls_without_mkcert(web_config)?,
|
||||
};
|
||||
|
||||
Ok(Some(
|
||||
RustlsConfig::from_pem_file(cert_path, key_path).await?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn get_rustls_with_mkcert(web_config: &WebHttpsConfig) -> Result<(String, String)> {
|
||||
const DEFAULT_KEY_PATH: &str = "ssl/key.pem";
|
||||
const DEFAULT_CERT_PATH: &str = "ssl/cert.pem";
|
||||
|
||||
// Get paths to store certs, otherwise use ssl/item.pem
|
||||
let key_path = web_config
|
||||
.key_path
|
||||
.clone()
|
||||
.unwrap_or(DEFAULT_KEY_PATH.to_string());
|
||||
|
||||
let cert_path = web_config
|
||||
.cert_path
|
||||
.clone()
|
||||
.unwrap_or(DEFAULT_CERT_PATH.to_string());
|
||||
|
||||
// Create ssl directory if using defaults
|
||||
if key_path == DEFAULT_KEY_PATH && cert_path == DEFAULT_CERT_PATH {
|
||||
_ = fs::create_dir("ssl");
|
||||
}
|
||||
|
||||
let cmd = Command::new("mkcert")
|
||||
.args([
|
||||
"-install",
|
||||
"-key-file",
|
||||
&key_path,
|
||||
"-cert-file",
|
||||
&cert_path,
|
||||
"localhost",
|
||||
"::1",
|
||||
"127.0.0.1",
|
||||
])
|
||||
.spawn();
|
||||
|
||||
match cmd {
|
||||
Err(e) => {
|
||||
match e.kind() {
|
||||
io::ErrorKind::NotFound => tracing::error!("mkcert is not installed. See https://github.com/FiloSottile/mkcert#installation for installation instructions."),
|
||||
e => tracing::error!("an error occurred while generating mkcert certificates: {}", e.to_string()),
|
||||
};
|
||||
return Err("failed to generate mkcert certificates".into());
|
||||
}
|
||||
Ok(mut cmd) => {
|
||||
cmd.wait()?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok((cert_path, key_path))
|
||||
}
|
||||
|
||||
pub fn get_rustls_without_mkcert(web_config: &WebHttpsConfig) -> Result<(String, String)> {
|
||||
// get paths to cert & key
|
||||
if let (Some(key), Some(cert)) = (web_config.key_path.clone(), web_config.cert_path.clone()) {
|
||||
Ok((cert, key))
|
||||
} else {
|
||||
// missing cert or key
|
||||
Err("https is enabled but cert or key path is missing".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the browser to the address
|
||||
pub(crate) fn open_browser(base_path: Option<String>, address: SocketAddr, https: bool) {
|
||||
let protocol = if https { "https" } else { "http" };
|
||||
let base_path = match base_path.as_deref() {
|
||||
Some(base_path) => format!("/{}", base_path.trim_matches('/')),
|
||||
None => "".to_owned(),
|
||||
};
|
||||
_ = open::that(format!("{protocol}://{address}{base_path}"));
|
||||
}
|
||||
|
||||
fn get_available_port(address: IpAddr) -> Option<u16> {
|
||||
(8000..9000).find(|port| TcpListener::bind((address, *port)).is_ok())
|
||||
}
|
||||
|
||||
/// Middleware that intercepts html requests if the status is "Building" and returns a loading page instead
|
||||
async fn build_status_middleware(
|
||||
state: State<SharedStatus>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> axum::response::Response {
|
||||
// If the request is for html, and the status is "Building", return the loading page instead of the contents of the response
|
||||
let accepts = request.headers().get(ACCEPT);
|
||||
let accepts_html = accepts
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|v| v.contains("text/html"));
|
||||
|
||||
if let Some(true) = accepts_html {
|
||||
let status = state.get();
|
||||
if status != Status::Ready {
|
||||
let html = include_str!("../../assets/loading.html");
|
||||
return axum::response::Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.body(Body::from(html))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
async fn send_build_status_to(
|
||||
build_status: &SharedStatus,
|
||||
socket: &mut WebSocket,
|
||||
) -> Result<(), axum::Error> {
|
||||
let msg = serde_json::to_string(&build_status.get()).unwrap();
|
||||
socket.send(Message::Text(msg)).await
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::dioxus_crate::DioxusCrate;
|
||||
use crate::serve::hot_reloading_file_map::FileMap;
|
||||
use dioxus_hot_reload::HotReloadMsg;
|
||||
use dioxus_html::HtmlCtx;
|
||||
use futures_channel::mpsc::{UnboundedReceiver, UnboundedSender};
|
||||
use futures_util::StreamExt;
|
||||
use ignore::gitignore::Gitignore;
|
||||
use notify::{event::ModifyKind, EventKind, RecommendedWatcher};
|
||||
|
||||
/// This struct stores the file watcher and the filemap for the project.
|
||||
///
|
||||
/// This is where we do workspace discovery and recursively listen for changes in Rust files and asset
|
||||
/// directories.
|
||||
pub struct Watcher {
|
||||
_tx: UnboundedSender<notify::Event>,
|
||||
rx: UnboundedReceiver<notify::Event>,
|
||||
_last_update_time: i64,
|
||||
_watcher: RecommendedWatcher,
|
||||
queued_events: Vec<notify::Event>,
|
||||
file_map: FileMap,
|
||||
ignore: Gitignore,
|
||||
}
|
||||
|
||||
impl Watcher {
|
||||
pub fn start(config: &DioxusCrate) -> Self {
|
||||
let (tx, rx) = futures_channel::mpsc::unbounded();
|
||||
|
||||
// Extend the watch path to include:
|
||||
// - the assets directory - this is so we can hotreload CSS and other assets by default
|
||||
// - the Cargo.toml file - this is so we can hotreload the project if the user changes dependencies
|
||||
// - the Dioxus.toml file - this is so we can hotreload the project if the user changes the Dioxus config
|
||||
let mut allow_watch_path = config.dioxus_config.web.watcher.watch_path.clone();
|
||||
allow_watch_path.push(config.dioxus_config.application.asset_dir.clone());
|
||||
allow_watch_path.push("Cargo.toml".to_string().into());
|
||||
allow_watch_path.push("Dioxus.toml".to_string().into());
|
||||
allow_watch_path.dedup();
|
||||
|
||||
let crate_dir = config.crate_dir();
|
||||
let gitignore_file_path = crate_dir.join(".gitignore");
|
||||
let mut builder = ignore::gitignore::GitignoreBuilder::new(gitignore_file_path);
|
||||
let excluded_paths = vec![
|
||||
".git",
|
||||
".github",
|
||||
".vscode",
|
||||
"target",
|
||||
"node_modules",
|
||||
"dist",
|
||||
".dioxus",
|
||||
];
|
||||
for path in excluded_paths {
|
||||
builder.add(path);
|
||||
}
|
||||
let ignore = builder.build().unwrap();
|
||||
|
||||
// Create the file watcher
|
||||
let mut watcher = notify::recommended_watcher({
|
||||
let tx = tx.clone();
|
||||
move |info: notify::Result<notify::Event>| {
|
||||
if let Ok(e) = info {
|
||||
match e.kind {
|
||||
|
||||
// An event emitted when the metadata of a file or folder is changed.
|
||||
EventKind::Modify(ModifyKind::Data(_)) |
|
||||
EventKind::Create(_) |
|
||||
EventKind::Remove(_) => {
|
||||
_ = tx.unbounded_send(e);
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("Failed to create file watcher.\nEnsure you have the required permissions to watch the specified directories.");
|
||||
|
||||
// Watch the specified paths
|
||||
// todo: make sure we don't double-watch paths if they're nested
|
||||
for sub_path in allow_watch_path {
|
||||
let path = &config.crate_dir().join(sub_path);
|
||||
|
||||
// If the path is ignored, don't watch it
|
||||
if ignore.matched(path, path.is_dir()).is_ignore() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mode = notify::RecursiveMode::Recursive;
|
||||
|
||||
use notify::Watcher;
|
||||
if let Err(err) = watcher.watch(path, mode) {
|
||||
tracing::warn!("Failed to watch path: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Probe the entire project looking for our rsx calls
|
||||
// Whenever we get an update from the file watcher, we'll try to hotreload against this file map
|
||||
let file_map = FileMap::create_with_filter::<HtmlCtx>(config.crate_dir(), |path| {
|
||||
ignore.matched(path, path.is_dir()).is_ignore()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
Self {
|
||||
_tx: tx,
|
||||
rx,
|
||||
_watcher: watcher,
|
||||
file_map,
|
||||
ignore,
|
||||
queued_events: Vec::new(),
|
||||
_last_update_time: chrono::Local::now().timestamp(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A cancel safe handle to the file watcher
|
||||
///
|
||||
/// todo: this should be simpler logic?
|
||||
pub async fn wait(&mut self) {
|
||||
// Pull off any queued events in succession
|
||||
while let Ok(Some(event)) = self.rx.try_next() {
|
||||
self.queued_events.push(event);
|
||||
}
|
||||
|
||||
if !self.queued_events.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// If there are no queued events, wait for the next event
|
||||
if let Some(event) = self.rx.next().await {
|
||||
self.queued_events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Deques changed files from the event queue, doing the proper intelligent filtering
|
||||
pub fn dequeue_changed_files(&mut self, config: &DioxusCrate) -> Vec<PathBuf> {
|
||||
let mut all_mods: Vec<PathBuf> = vec![];
|
||||
|
||||
// Decompose the events into a list of all the files that have changed
|
||||
for event in self.queued_events.drain(..) {
|
||||
// We only care about modify/crate/delete events
|
||||
match event.kind {
|
||||
EventKind::Modify(ModifyKind::Data(_)) => {}
|
||||
EventKind::Modify(ModifyKind::Name(_)) => {}
|
||||
EventKind::Create(_) => {}
|
||||
EventKind::Remove(_) => {}
|
||||
_ => continue,
|
||||
}
|
||||
|
||||
for path in event.paths {
|
||||
all_mods.push(path.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let mut modified_files = vec![];
|
||||
|
||||
// For the non-rust files, we want to check if it's an asset file
|
||||
// This would mean the asset lives somewhere under the /assets directory or is referenced by magnanis in the linker
|
||||
// todo: mg integration here
|
||||
let _asset_dir = config
|
||||
.dioxus_config
|
||||
.application
|
||||
.asset_dir
|
||||
.canonicalize()
|
||||
.ok();
|
||||
|
||||
for path in all_mods.iter() {
|
||||
if path.extension().is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Workaround for notify and vscode-like editor:
|
||||
// when edit & save a file in vscode, there will be two notifications,
|
||||
// the first one is a file with empty content.
|
||||
// filter the empty file notification to avoid false rebuild during hot-reload
|
||||
if let Ok(metadata) = std::fs::metadata(path) {
|
||||
if metadata.len() == 0 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// If the extension is a backup file, or a hidden file, ignore it completely (no rebuilds)
|
||||
if is_backup_file(path.to_path_buf()) {
|
||||
tracing::trace!("Ignoring backup file: {:?}", path);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If the path is ignored, don't watch it
|
||||
if self.ignore.matched(path, path.is_dir()).is_ignore() {
|
||||
continue;
|
||||
}
|
||||
|
||||
modified_files.push(path.clone());
|
||||
}
|
||||
|
||||
modified_files
|
||||
}
|
||||
|
||||
pub fn attempt_hot_reload(
|
||||
&mut self,
|
||||
config: &DioxusCrate,
|
||||
modified_files: Vec<PathBuf>,
|
||||
) -> Option<HotReloadMsg> {
|
||||
// If we have any changes to the rust files, we need to update the file map
|
||||
let crate_dir = config.crate_dir();
|
||||
let mut templates = vec![];
|
||||
|
||||
// Prepare the hotreload message we need to send
|
||||
let mut edited_rust_files = Vec::new();
|
||||
let mut assets = Vec::new();
|
||||
let mut unknown_files = Vec::new();
|
||||
|
||||
for path in modified_files {
|
||||
// for various assets that might be linked in, we just try to hotreloading them forcefully
|
||||
// That is, unless they appear in an include! macro, in which case we need to a full rebuild....
|
||||
let Some(ext) = path.extension().and_then(|v| v.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match ext {
|
||||
"rs" => edited_rust_files.push(path),
|
||||
_ if path.starts_with("assets") => assets.push(path),
|
||||
_ => unknown_files.push(path),
|
||||
}
|
||||
}
|
||||
|
||||
for rust_file in edited_rust_files {
|
||||
let hotreloaded_templates = self
|
||||
.file_map
|
||||
.update_rsx::<HtmlCtx>(&rust_file, &crate_dir)
|
||||
.ok()?;
|
||||
|
||||
templates.extend(hotreloaded_templates);
|
||||
}
|
||||
|
||||
Some(HotReloadMsg {
|
||||
templates,
|
||||
assets,
|
||||
unknown_files,
|
||||
})
|
||||
}
|
||||
|
||||
/// Ensure the changes we've received from the queue are actually legit changes to either assets or
|
||||
/// rust code. We don't care about changes otherwise, unless we get a signle elsewhere to do a full rebuild
|
||||
pub fn pending_changes(&mut self) -> bool {
|
||||
!self.queued_events.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_backup_file(path: PathBuf) -> bool {
|
||||
// If there's a tilde at the end of the file, it's a backup file
|
||||
if let Some(name) = path.file_name() {
|
||||
if let Some(name) = name.to_str() {
|
||||
if name.ends_with('~') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if the file is hidden, it's a backup file
|
||||
if let Some(name) = path.file_name() {
|
||||
if let Some(name) = name.to_str() {
|
||||
if name.starts_with('.') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_backup_file() {
|
||||
assert!(is_backup_file(PathBuf::from("examples/test.rs~")));
|
||||
assert!(is_backup_file(PathBuf::from("examples/.back")));
|
||||
assert!(is_backup_file(PathBuf::from("test.rs~")));
|
||||
assert!(is_backup_file(PathBuf::from(".back")));
|
||||
|
||||
assert!(!is_backup_file(PathBuf::from("val.rs")));
|
||||
assert!(!is_backup_file(PathBuf::from(
|
||||
"/Users/jonkelley/Development/Tinkering/basic_05_example/src/lib.rs"
|
||||
)));
|
||||
assert!(!is_backup_file(PathBuf::from("exmaples/val.rs")));
|
||||
}
|
||||
Reference in new issue
Block a user