DX components command (#4669)

* add component subcommand

* add dx component add, remove and list commands

* handle rust dependencies

* handle component dependencies

* fetch remote registries

* sort components before listing them

* remove local dependency

* move te component manifest into the dioxus workspace

* add additional instalation instructions the first time the component
module is created

* fix mod name

* fix clippy

* improve docs and move to the main branch of components

* fix typos

* more typos

* undo const-serialize changes

* remove a few unwraps

* Allow adding multiple components at once and add a `--all` flag

* add options to the config for the base path, global asset path and
default registry

* component_path -> component_dir

* component_dir is always relative to the crate root

* fix clippy

* fix request imports

* wip: code style changes

* rename to "components" instead of "component"

* bump lock

---------

Co-authored-by: Jonathan Kelley <jkelleyrtp@gmail.com>
This commit is contained in:
Evan AlmloffandJonathan Kelley authored and GitHub committed 2025-10-20 17:20:46 -04:00
1 parent b3966c3509
commit 79613f9db9
18 files changed
+1101 -17

No files matched your search

Generated
+39 -3
View File
@@ -5333,6 +5333,7 @@ dependencies = [
"dioxus-cli-config",
"dioxus-cli-opt",
"dioxus-cli-telemetry",
"dioxus-component-manifest",
"dioxus-core",
"dioxus-core-types",
"dioxus-devtools-types",
@@ -5351,6 +5352,7 @@ dependencies = [
"fs2",
"futures-channel",
"futures-util",
"git2",
"handlebars",
"headers",
"html_parser",
@@ -5496,6 +5498,15 @@ dependencies = [
"uuid",
]
[[package]]
name = "dioxus-component-manifest"
version = "0.7.0-rc.2"
dependencies = [
"schemars 1.0.4",
"serde",
"serde_json",
]
[[package]]
name = "dioxus-config-macro"
version = "0.7.0-rc.2"
@@ -14694,10 +14705,23 @@ checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0"
dependencies = [
"dyn-clone",
"ref-cast",
"schemars_derive",
"serde",
"serde_json",
]
[[package]]
name = "schemars_derive"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33d020396d1d138dc19f1165df7545479dcd58d93810dc5d646a16e55abefa80"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 2.0.104",
]
[[package]]
name = "scoped-tls"
version = "1.0.1"
@@ -15052,15 +15076,27 @@ dependencies = [
]
[[package]]
name = "serde_json"
version = "1.0.140"
name = "serde_derive_internals"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.104",
]
[[package]]
name = "serde_json"
version = "1.0.145"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
"serde_core",
]
[[package]]
+2
View File
@@ -69,6 +69,7 @@ members = [
"packages/native-dom",
"packages/asset-resolver",
"packages/depinfo",
"packages/component-manifest",
# CLI harnesses, all included
"packages/cli-harnesses/*",
@@ -182,6 +183,7 @@ dioxus-native = { path = "packages/native", version = "0.7.0-rc.2" }
dioxus-native-dom = { path = "packages/native-dom", version = "0.7.0-rc.2" }
dioxus-asset-resolver = { path = "packages/asset-resolver", version = "0.7.0-rc.2" }
dioxus-config-macros = { path = "packages/config-macros", version = "0.7.0-rc.2" }
dioxus-component-manifest = { path = "packages/component-manifest", version = "0.7.0-rc.2" }
generational-box = { path = "packages/generational-box", version = "0.7.0-rc.2" }
lazy-js-bundle = { path = "packages/lazy-js-bundle", version = "0.7.0-rc.2" }
+2
View File
@@ -27,6 +27,7 @@ wasm-split-cli = { workspace = true }
depinfo = { workspace = true }
subsecond-types = { workspace = true }
dioxus-cli-telemetry = { workspace = true }
dioxus-component-manifest = { workspace = true }
clap = { workspace = true, features = ["derive", "cargo"] }
convert_case = { workspace = true }
@@ -78,6 +79,7 @@ dunce = { workspace = true }
dirs = { workspace = true }
reqwest = { workspace = true, features = ["rustls-tls", "trust-dns", "json"] }
tower = { workspace = true }
git2 = "0.20.2"
# path lookup
which = { version = "8.0.0" }
+3 -7
View File
@@ -1,6 +1,6 @@
use crate::{
serve::WebServer, BuildArtifacts, BuildRequest, BuildStage, BuilderUpdate, BundleFormat,
ProgressRx, ProgressTx, Result, RustcArgs, StructuredOutput,
serve::WebServer, verbosity_or_default, BuildArtifacts, BuildRequest, BuildStage,
BuilderUpdate, BundleFormat, ProgressRx, ProgressTx, Result, RustcArgs, StructuredOutput,
};
use anyhow::{bail, Context};
use dioxus_cli_opt::process_file_to;
@@ -489,11 +489,7 @@ impl AppBuilder {
));
}
if crate::VERBOSITY
.get()
.map(|f| f.verbose)
.unwrap_or_default()
{
if verbosity_or_default().verbose {
envs.push(("RUST_BACKTRACE".into(), "1".to_string()));
}
+1 -1
View File
@@ -2567,7 +2567,7 @@ impl BuildRequest {
cargo_args.push(self.executable_name().to_string());
// Set offline/locked/frozen
let lock_opts = crate::VERBOSITY.get().cloned().unwrap_or_default();
let lock_opts = crate::verbosity_or_default();
if lock_opts.frozen {
cargo_args.push("--frozen".to_string());
}
+821
View File
@@ -0,0 +1,821 @@
use std::{
collections::{HashMap, HashSet},
ops::Deref,
path::{Path, PathBuf},
};
use crate::{verbosity_or_default, DioxusConfig, Result, StructuredOutput, Workspace};
use anyhow::{bail, Context};
use clap::Parser;
use dioxus_component_manifest::{
component_manifest_schema, CargoDependency, Component, ComponentDependency,
};
use git2::Repository;
use serde::{Deserialize, Serialize};
use tokio::{process::Command, task::JoinSet};
use tracing::debug;
#[derive(Clone, Debug, Parser)]
pub enum ComponentCommand {
/// Add a component from a registry
Add {
#[clap(flatten)]
component: ComponentArgs,
/// The registry to use
#[clap(flatten)]
registry: Option<ComponentRegistry>,
/// Overwrite the component if it already exists
#[clap(long)]
force: bool,
},
/// Remove a component
Remove {
#[clap(flatten)]
component: ComponentArgs,
/// The registry to use
#[clap(flatten)]
registry: Option<ComponentRegistry>,
},
/// Update a component registry
Update {
/// The registry to update
#[clap(flatten)]
registry: Option<RemoteComponentRegistry>,
},
/// List available components in a registry
List {
/// The registry to list components in
#[clap(flatten)]
registry: Option<ComponentRegistry>,
},
/// Clear the component registry cache
Clean,
/// Print the schema for component manifests
Schema,
}
/// Arguments for a component and component module location
#[derive(Clone, Debug, Parser, Serialize)]
pub struct ComponentArgs {
/// The components to add or remove
#[clap(required_unless_present = "all", value_delimiter = ',')]
components: Vec<String>,
/// The location of the component module in your project (default: src/components)
#[clap(long)]
module_path: Option<PathBuf>,
/// The location of the global assets in your project (default: assets)
#[clap(long)]
global_assets_path: Option<PathBuf>,
/// Include all components in the registry
#[clap(long)]
all: bool,
}
impl ComponentCommand {
/// Run the component command
pub async fn run(self) -> Result<StructuredOutput> {
match self {
// List all components in the registry
Self::List { registry } => {
let config = Self::resolve_config().await?;
let registry = Self::resolve_registry(registry, &config)?;
let mut components = registry.read_components().await?;
components.sort_by_key(|c| c.name.clone());
for component in components {
println!("- {}: {}", component.name, component.description);
}
}
// Add a component to the managed component module
Self::Add {
component: component_args,
registry,
force,
} => {
// Resolve the config
let config = Self::resolve_config().await?;
// Resolve the registry
let registry = Self::resolve_registry(registry, &config)?;
// Read all components from the registry
let components = registry.read_components().await?;
let mode = if force {
ComponentExistsBehavior::Overwrite
} else {
ComponentExistsBehavior::Error
};
// Find the requested components
let components = if component_args.all {
components
} else {
component_args
.components
.iter()
.map(|component| find_component(&components, component))
.collect::<Result<Vec<_>>>()?
};
// Find and initialize the components module if it doesn't exist
let components_root =
components_root(component_args.module_path.as_deref(), &config)?;
let new_components_module =
ensure_components_module_exists(&components_root).await?;
// Recursively add dependencies
// A map of the components that have been added or are queued to be added
let mut required_components = HashMap::new();
required_components.extend(components.iter().cloned().map(|c| (c, mode)));
// A stack of components to process
let mut queued_components = components;
while let Some(queued_component) = queued_components.pop() {
for dependency in &queued_component.component_dependencies {
let (registry, name) = match dependency {
ComponentDependency::Builtin(name) => {
(ComponentRegistry::default(), name)
}
ComponentDependency::ThirdParty { name, git, rev } => (
ComponentRegistry {
remote: RemoteComponentRegistry {
git: Some(git.clone()),
rev: rev.clone(),
},
path: None,
},
name,
),
};
let registry_components = registry.read_components().await?;
let dependency_component = find_component(&registry_components, name)?;
if required_components
.insert(
dependency_component.clone(),
ComponentExistsBehavior::Return,
)
.is_none()
{
queued_components.push(dependency_component);
}
}
}
// Then collect all required rust dependencies
let mut rust_dependencies = HashSet::new();
for component in required_components.keys() {
rust_dependencies.extend(component.cargo_dependencies.iter().cloned());
}
// And add them to Cargo.toml
Self::add_rust_dependencies(&rust_dependencies).await?;
// Once we have all required components, add them
for (component, mode) in required_components {
add_component(
component_args.global_assets_path.as_deref(),
component_args.module_path.as_deref(),
&component,
mode,
&config,
)
.await?;
}
// If we created a new components module, print instructions about the final setup steps required
if new_components_module {
println!(
"Created new components module at {}.",
components_root.display()
);
println!("To finish setting up components, you will need to:");
println!("- manually reference the module by adding `mod components;` to your `main.rs` file");
if registry.is_default() {
println!("- add a reference to `asset!(\"/assets/dx-components-theme.css\")` as a stylesheet in your app");
}
}
}
// Update the remote component registry
Self::Update { registry } => {
let config = Self::resolve_config().await?;
registry
.unwrap_or(config.components.registry.remote)
.update()
.await?;
}
// Remove a component from the managed component module
Self::Remove {
component,
registry,
} => {
Self::remove_component(&component, registry).await?;
}
// Clear the component registry cache
Self::Clean => {
_ = tokio::fs::remove_dir_all(&Workspace::component_cache_dir()).await;
}
// Print the schema for component manifests
Self::Schema => {
let schema = component_manifest_schema();
println!(
"{}",
serde_json::to_string_pretty(&schema).unwrap_or_default()
);
}
}
Ok(StructuredOutput::Success)
}
/// Remove a component from the managed component module
async fn remove_component(
component_args: &ComponentArgs,
registry: Option<ComponentRegistry>,
) -> Result<()> {
let config = Self::resolve_config().await?;
let registry = Self::resolve_registry(registry, &config)?;
let components_root = components_root(component_args.module_path.as_deref(), &config)?;
// Find the requested components
let components = if component_args.all {
registry
.read_components()
.await?
.into_iter()
.map(|c| c.component.name)
.collect()
} else {
component_args.components.clone()
};
for component_name in components {
// Remove the component module
_ = tokio::fs::remove_dir_all(&components_root.join(&component_name)).await;
// Remove the module from the components mod.rs
let mod_rs_path = components_root.join("mod.rs");
let mod_rs_content = tokio::fs::read_to_string(&mod_rs_path)
.await
.with_context(|| format!("Failed to read {}", mod_rs_path.display()))?;
let mod_line = format!("pub mod {};\n", component_name);
let new_mod_rs_content = mod_rs_content.replace(&mod_line, "");
tokio::fs::write(&mod_rs_path, new_mod_rs_content)
.await
.with_context(|| format!("Failed to write to {}", mod_rs_path.display()))?;
}
Ok(())
}
/// Load the config
async fn resolve_config() -> Result<DioxusConfig> {
let workspace = Workspace::current().await?;
let crate_package = workspace.find_main_package(None)?;
Ok(workspace
.load_dioxus_config(crate_package)?
.unwrap_or_default())
}
/// Resolve a registry from the config if none is provided
fn resolve_registry(
registry: Option<ComponentRegistry>,
config: &DioxusConfig,
) -> Result<ComponentRegistry> {
if let Some(registry) = registry {
return Ok(registry);
}
Ok(config.components.registry.clone())
}
/// Add any rust dependencies required for a component
async fn add_rust_dependencies(dependencies: &HashSet<CargoDependency>) -> Result<()> {
for dep in dependencies {
let status = Command::from(dep.add_command())
.status()
.await
.with_context(|| {
format!(
"Failed to run command to add dependency {} to Cargo.toml",
dep.name()
)
})?;
if !status.success() {
bail!("Failed to add dependency {} to Cargo.toml", dep.name());
}
}
Ok(())
}
}
/// Arguments for the default or custom remote registry
/// If both values are None, the default registry will be used
#[derive(Clone, Debug, Parser, Default, Serialize, Deserialize)]
pub struct RemoteComponentRegistry {
/// The url of the the component registry
#[arg(long)]
git: Option<String>,
/// The revision of the the component registry
#[arg(long)]
rev: Option<String>,
}
impl RemoteComponentRegistry {
/// Resolve the path to the component registry, downloading the remote registry if needed
async fn resolve(&self) -> Result<PathBuf> {
// If a git url is provided use that (plus optional rev)
// Otherwise use the built-in registry
let (git, rev) = self.resolve_or_default();
let repo_dir = Workspace::component_cache_path(&git, rev.as_deref());
// If the repo already exists, use it otherwise clone it
if !repo_dir.exists() {
// If offline, we cannot download the registry
if verbosity_or_default().offline {
bail!("Cannot download component registry '{}' while offline", git);
}
// Make sure the parent directory exists
tokio::fs::create_dir_all(&repo_dir).await?;
tokio::task::spawn_blocking({
let git = git.clone();
let repo_dir = repo_dir.clone();
move || {
println!("Downloading {git}...");
// Clone the repo
let repo = Repository::clone(&git, repo_dir)?;
// If a rev is provided, checkout that rev
if let Some(rev) = &rev {
Self::checkout_rev(&repo, &git, rev)?;
}
anyhow::Ok(())
}
})
.await??;
}
Ok(repo_dir)
}
/// Update the component registry by fetching the latest changes from the remote
async fn update(&self) -> Result<()> {
let (git, rev) = self.resolve_or_default();
// Make sure the repo is cloned
let path = self.resolve().await?;
// Open the repo and update it
tokio::task::spawn_blocking({
let path = path.clone();
move || {
let repo = Repository::open(path)?;
let mut remote = repo.find_remote("origin")?;
// Fetch all remote branches with the same name as local branches
remote.fetch(&["refs/heads/*:refs/heads/*"], None, None)?;
// If a rev is provided, checkout that rev
if let Some(rev) = &rev {
Self::checkout_rev(&repo, &git, rev)?;
}
anyhow::Ok(())
}
})
.await??;
Ok(())
}
/// If a git url is provided use that (plus optional rev)
/// Otherwise use the built-in registry
fn resolve_or_default(&self) -> (String, Option<String>) {
if let Some(git) = &self.git {
(git.clone(), self.rev.clone())
} else {
("https://github.com/dioxuslabs/components".into(), None)
}
}
/// Checkout the given rev in the given repo
fn checkout_rev(repo: &Repository, git: &str, rev: &str) -> Result<()> {
let (object, reference) = repo
.revparse_ext(rev)
.with_context(|| format!("Failed to find revision '{}' in '{}'", rev, git))?;
repo.checkout_tree(&object, None)?;
if let Some(gref) = reference {
if let Some(name) = gref.name() {
repo.set_head(name)?;
}
} else {
repo.set_head_detached(object.id())?;
}
Ok(())
}
}
/// Arguments for a component registry
/// Either a path to a local directory or a remote git repo (with optional rev)
#[derive(Clone, Debug, Parser, Default, Serialize, Deserialize)]
pub struct ComponentRegistry {
/// The remote repo args
#[clap(flatten)]
#[serde(flatten)]
remote: RemoteComponentRegistry,
/// The path to the components directory
#[arg(long)]
path: Option<String>,
}
impl ComponentRegistry {
/// Resolve the path to the component registry, downloading the remote registry if needed
async fn resolve(&self) -> Result<PathBuf> {
// If a path is provided, use that
if let Some(path) = &self.path {
return Ok(PathBuf::from(path));
}
// Otherwise use the remote/default registry
self.remote.resolve().await
}
/// Read all components that are part of this registry
async fn read_components(&self) -> Result<Vec<ResolvedComponent>> {
let path = self.resolve().await?;
let root = read_component(&path).await?;
let mut components = discover_components(root).await?;
// Filter out any virtual components with members
components.retain(|c| c.members.is_empty());
Ok(components)
}
/// Check if this is the default registry
fn is_default(&self) -> bool {
self.path.is_none() && self.remote.git.is_none() && self.remote.rev.is_none()
}
}
/// A component that has been downloaded and resolved at a specific path
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct ResolvedComponent {
path: PathBuf,
component: Component,
}
impl ResolvedComponent {
/// Get the absolute paths to members of this component
fn member_paths(&self) -> Vec<PathBuf> {
self.component
.members
.iter()
.map(|m| self.path.join(m))
.collect()
}
}
impl Deref for ResolvedComponent {
type Target = Component;
fn deref(&self) -> &Self::Target {
&self.component
}
}
// Find a component by name in a list of components
fn find_component(components: &[ResolvedComponent], component: &str) -> Result<ResolvedComponent> {
components
.iter()
.find(|c| c.name == component)
.cloned()
.ok_or_else(|| anyhow::anyhow!("Component '{}' not found in registry", component))
}
/// Get the path to the components module, defaulting to src/components
fn components_root(module_path: Option<&Path>, config: &DioxusConfig) -> Result<PathBuf> {
if let Some(module_path) = module_path {
return Ok(PathBuf::from(module_path));
}
let root = Workspace::crate_root_from_path()?;
if let Some(component_path) = &config.components.components_dir {
return Ok(root.join(component_path));
}
Ok(root.join("src").join("components"))
}
/// Get the path to the global assets directory, defaulting to assets
async fn global_assets_root(assets_path: Option<&Path>, config: &DioxusConfig) -> Result<PathBuf> {
if let Some(assets_path) = assets_path {
return Ok(PathBuf::from(assets_path));
}
if let Some(asset_dir) = &config.application.asset_dir {
return Ok(asset_dir.clone());
}
let root = Workspace::crate_root_from_path()?;
Ok(root.join("assets"))
}
/// How should we handle the component if it already exists
#[derive(Clone, Copy, Debug)]
enum ComponentExistsBehavior {
/// Return an error (default)
Error,
/// Return early for component dependencies
Return,
/// Overwrite the existing component
Overwrite,
}
/// Add a component to the managed component module
async fn add_component(
assets_path: Option<&Path>,
component_path: Option<&Path>,
component: &ResolvedComponent,
behavior: ComponentExistsBehavior,
config: &DioxusConfig,
) -> Result<()> {
// Copy the folder content to the components directory
let components_root = components_root(component_path, config)?;
let copied = copy_component_files(
&component.path,
&components_root.join(&component.name),
&component.exclude,
behavior,
)
.await?;
if !copied {
debug!(
"Component '{}' already exists, skipping copy",
component.name
);
return Ok(());
}
// Copy any global assets
let assets_root = global_assets_root(assets_path, config).await?;
copy_global_assets(&assets_root, component).await?;
// Add the module to the components mod.rs
let mod_rs_path = components_root.join("mod.rs");
let mut mod_rs = tokio::fs::OpenOptions::new()
.append(true)
.read(true)
.open(&mod_rs_path)
.await
.with_context(|| format!("Failed to open {}", mod_rs_path.display()))?;
// Check if the module already exists
let mod_rs_content = tokio::fs::read_to_string(&mod_rs_path)
.await
.with_context(|| format!("Failed to read {}", mod_rs_path.display()))?;
if !mod_rs_content.contains(&format!("mod {};", component.name)) {
let mod_line = format!("pub mod {};\n", component.name);
tokio::io::AsyncWriteExt::write_all(&mut mod_rs, mod_line.as_bytes())
.await
.with_context(|| format!("Failed to write to {}", mod_rs_path.display()))?;
}
Ok(())
}
/// Copy the component files. Returns true if the component was copied, false if it was skipped.
async fn copy_component_files(
src: &Path,
dest: &Path,
exclude: &[String],
behavior: ComponentExistsBehavior,
) -> Result<bool> {
async fn read_dir_paths(src: &Path) -> Result<Vec<PathBuf>> {
let mut entries = tokio::fs::read_dir(src).await?;
let mut paths = vec![];
while let Some(entry) = entries.next_entry().await? {
paths.push(entry.path());
}
Ok(paths)
}
// If the directory already exists, return an error, return silently or overwrite it depending on the behavior
if dest.exists() {
match behavior {
// The default behavior is to return an error
ComponentExistsBehavior::Error => {
bail!("Destination directory '{}' already exists", dest.display());
}
// For dependencies, we return early
ComponentExistsBehavior::Return => {
debug!(
"Destination directory '{}' already exists, returning early",
dest.display()
);
return Ok(false);
}
// If the force flag is set, we overwrite the existing component
ComponentExistsBehavior::Overwrite => {
debug!(
"Destination directory '{}' already exists, overwriting",
dest.display()
);
tokio::fs::remove_dir_all(dest).await?;
}
}
}
tokio::fs::create_dir_all(dest).await?;
let exclude = exclude
.iter()
.map(|exclude| dunce::canonicalize(src.join(exclude)))
.collect::<Result<Vec<_>, _>>()?;
// Set set of tasks to read directories
let mut read_folder_tasks = JoinSet::new();
// Set set of tasks to copy files
let mut copy_tasks = JoinSet::new();
// Start by reading the source directory
let src = src.to_path_buf();
read_folder_tasks.spawn({
let src = src.clone();
async move { read_dir_paths(&src).await }
});
// Continue while there are read tasks
while let Some(res) = read_folder_tasks.join_next().await {
let paths = res??;
for path in paths {
let path = dunce::canonicalize(path)?;
// Skip excluded paths
if exclude.iter().any(|e| *e == path || path.starts_with(e)) {
debug!("Excluding path {}", path.display());
continue;
}
// Find the path in the destination directory
let Ok(path_relative_to_src) = path.strip_prefix(&src) else {
continue;
};
let dest = dest.join(path_relative_to_src);
// If it's a directory, read it, otherwise copy the file
if path.is_dir() {
read_folder_tasks.spawn(async move { read_dir_paths(&path).await });
} else {
copy_tasks.spawn(async move {
if let Some(parent) = dest.parent() {
if !parent.exists() {
tokio::fs::create_dir_all(parent).await?;
}
}
tokio::fs::copy(&path, &dest).await
});
}
}
}
// Wait for all copy tasks to finish
while let Some(res) = copy_tasks.join_next().await {
res??;
}
Ok(true)
}
/// Make sure the components directory and a mod.rs file exists. Returns true if the directory was created, false if it already existed.
async fn ensure_components_module_exists(components_dir: &Path) -> Result<bool> {
if components_dir.exists() {
return Ok(false);
}
tokio::fs::create_dir_all(&components_dir).await?;
let mod_rs_path = components_dir.join("mod.rs");
if mod_rs_path.exists() {
return Ok(false);
}
tokio::fs::write(&mod_rs_path, "// AUTOGENERTED Components module\n").await?;
Ok(true)
}
/// Read a component from the given path
async fn read_component(path: &Path) -> Result<ResolvedComponent> {
let json_path = path.join("component.json");
let bytes = tokio::fs::read(&json_path).await.with_context(|| {
format!(
"Failed to open component manifest at {}",
json_path.display()
)
})?;
let component = serde_json::from_slice(&bytes)?;
Ok(ResolvedComponent {
path: path.to_path_buf(),
component,
})
}
/// Recursively discover all components starting from the root component
async fn discover_components(root: ResolvedComponent) -> Result<Vec<ResolvedComponent>> {
// Create a queue of members to read
let mut queue = root.member_paths();
// The list of discovered components
let mut components = vec![root];
// The set of pending read tasks
let mut pending = JoinSet::new();
loop {
// First, spawn tasks for all queued paths
while let Some(root_path) = queue.pop() {
pending.spawn(async move { read_component(&root_path).await });
}
// Then try to join the next task
let Some(component) = pending.join_next().await else {
break;
};
let component = component??;
// And add the result to the queue and list
queue.extend(component.member_paths());
components.push(component);
}
Ok(components)
}
/// Copy any global assets for the component
async fn copy_global_assets(assets_root: &Path, component: &ResolvedComponent) -> Result<()> {
let cache_dir = Workspace::component_cache_dir();
for path in &component.global_assets {
let src = component.path.join(path);
let absolute_source = dunce::canonicalize(&src).with_context(|| {
format!(
"Failed to find global asset '{}' for component '{}'",
src.display(),
component.name
)
})?;
// Make sure the source is inside the component registry somewhere
if !absolute_source.starts_with(&cache_dir) {
bail!(
"Cannot copy global asset '{}' for component '{}' because it is outside of the component registry",
src.display(),
component.name
);
}
// Copy the file into the assets directory, preserving the file name and extension
let dest = assets_root.join(
absolute_source
.components()
.next_back()
.context("Global assets must have at least one file component")?,
);
// Make sure the asset dir exists
if let Some(parent) = dest.parent() {
if !parent.exists() {
tokio::fs::create_dir_all(parent).await?;
}
}
tokio::fs::copy(&src, &dest).await.with_context(|| {
format!(
"Failed to copy global asset from {} to {}",
src.display(),
dest.display()
)
})?;
}
Ok(())
}
+1 -5
View File
@@ -250,11 +250,7 @@ pub(crate) async fn check_path(path: &std::path::PathBuf) -> Result<()> {
/// Perform a health check against github itself before we attempt to download any templates hosted
/// on github.
pub(crate) async fn check_connectivity() -> Result<()> {
if crate::VERBOSITY
.get()
.map(|f| f.offline)
.unwrap_or_default()
{
if crate::verbosity_or_default().offline {
return Ok(());
}
+6
View File
@@ -3,6 +3,7 @@ pub(crate) mod build;
pub(crate) mod build_assets;
pub(crate) mod bundle;
pub(crate) mod check;
pub(crate) mod component;
pub(crate) mod config;
pub(crate) mod create;
pub(crate) mod doctor;
@@ -114,6 +115,11 @@ pub(crate) enum Commands {
#[clap(name = "tools")]
#[clap(subcommand)]
Tools(BuildTools),
/// Run a dioxus build tool. IE `build-assets`, `hotpatch`, etc
#[clap(name = "components")]
#[clap(subcommand)]
Components(component::ComponentCommand),
}
#[allow(clippy::large_enum_variant)]
+4
View File
@@ -3,6 +3,10 @@ use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ApplicationConfig {
/// The path where global assets will be added when components are added with `dx components add`
#[serde(default)]
pub(crate) asset_dir: Option<PathBuf>,
#[serde(default)]
pub(crate) out_dir: Option<PathBuf>,
+15
View File
@@ -0,0 +1,15 @@
use crate::component::ComponentRegistry;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Configuration for the `dioxus component` commands
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub(crate) struct ComponentConfig {
/// The component registry to default to when adding components
#[serde(default)]
pub(crate) registry: ComponentRegistry,
/// The path where components are stored when adding or removing components
#[serde(default)]
pub(crate) components_dir: Option<PathBuf>,
}
+7
View File
@@ -1,3 +1,5 @@
use crate::config::component::ComponentConfig;
use super::*;
use serde::{Deserialize, Serialize};
@@ -10,12 +12,16 @@ pub(crate) struct DioxusConfig {
#[serde(default)]
pub(crate) bundle: BundleConfig,
#[serde(default)]
pub(crate) components: ComponentConfig,
}
impl Default for DioxusConfig {
fn default() -> Self {
Self {
application: ApplicationConfig {
asset_dir: None,
out_dir: None,
public_dir: Some("public".into()),
tailwind_input: None,
@@ -53,6 +59,7 @@ impl Default for DioxusConfig {
wasm_opt: Default::default(),
},
bundle: BundleConfig::default(),
components: ComponentConfig::default(),
}
}
}
+1
View File
@@ -1,5 +1,6 @@
mod app;
mod bundle;
mod component;
mod dioxus_config;
mod serve;
mod web;
+1 -1
View File
@@ -25,7 +25,7 @@ pub fn log_stacktrace(err: &anyhow::Error, padding: usize) -> String {
));
}
if crate::VERBOSITY.get().unwrap().trace {
if crate::verbosity_or_default().trace {
trace.push_str(&format!("\nBacktrace:\n{}", err.backtrace()));
}
+43
View File
@@ -29,6 +29,7 @@
//! - set `dx config set disable-telemetry true`
//!
use crate::component::ComponentCommand;
use crate::{dx_build_info::GIT_COMMIT_HASH_SHORT, serve::ServeUpdate, Cli, Commands, Verbosity};
use crate::{BundleFormat, CliSettings, Workspace};
use anyhow::{bail, Context, Error, Result};
@@ -68,6 +69,10 @@ const DX_SRC_FLAG: &str = "dx_src";
pub static VERBOSITY: OnceLock<Verbosity> = OnceLock::new();
pub fn verbosity_or_default() -> Verbosity {
crate::VERBOSITY.get().cloned().unwrap_or_default()
}
fn reset_cursor() {
use std::io::IsTerminal;
@@ -902,6 +907,44 @@ impl TraceController {
Print::ClientArgs(_args) => ("print client-args".to_string(), json!({})),
Print::ServerArgs(_args) => ("print server-args".to_string(), json!({})),
},
Commands::Components(cmd) => match cmd {
ComponentCommand::Add {
component,
registry,
force,
} => (
"components add".to_string(),
json!({
"component": component,
"registry": registry,
"force": force,
}),
),
ComponentCommand::Remove {
component,
registry,
} => (
"components remove".to_string(),
json!({
"component": component,
"registry": registry,
}),
),
ComponentCommand::Update { registry } => (
"components update".to_string(),
json!({
"registry": registry,
}),
),
ComponentCommand::List { registry } => (
"components list".to_string(),
json!({
"registry": registry,
}),
),
ComponentCommand::Clean => ("components clean".to_string(), json!({})),
ComponentCommand::Schema => ("components schema".to_string(), json!({})),
},
}
}
+1
View File
@@ -70,6 +70,7 @@ async fn main() -> ExitCode {
Commands::Tools(BuildTools::HotpatchTip(opts)) => opts.run().await,
Commands::Doctor(opts) => opts.doctor().await,
Commands::Print(opts) => opts.print().await,
Commands::Components(opts) => opts.run().await,
}
});
+18
View File
@@ -556,6 +556,24 @@ impl Workspace {
pub(crate) fn global_settings_file() -> PathBuf {
Self::dioxus_data_dir().join("settings.toml")
}
/// The path where components downloaded from git are cached
pub(crate) fn component_cache_dir() -> PathBuf {
Self::dioxus_data_dir().join("components")
}
/// Get the path to a specific component in the cache
pub(crate) fn component_cache_path(git: &str, rev: Option<&str>) -> PathBuf {
use std::hash::Hasher;
let mut hasher = std::hash::DefaultHasher::new();
std::hash::Hash::hash(git, &mut hasher);
if let Some(rev) = rev {
std::hash::Hash::hash(rev, &mut hasher);
}
let hash = hasher.finish();
Self::component_cache_dir().join(format!("{hash:016x}"))
}
}
impl std::fmt::Debug for Workspace {
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "dioxus-component-manifest"
edition = "2021"
version.workspace = true
description = "Wire format for the dioxus CLI telemetry type"
authors = ["Evan Almloff"]
license = "MIT OR Apache-2.0"
repository = "https://github.com/DioxusLabs/dioxus/"
homepage = "https://dioxuslabs.com"
keywords = ["dom", "ui", "gui", "react"]
[dependencies]
schemars = "1.0.4"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.143"
+121
View File
@@ -0,0 +1,121 @@
use std::process::Command;
use schemars::{schema_for, JsonSchema, Schema};
use serde::{Deserialize, Serialize};
/// A component compatible with the dioxus components system.
/// This may be a "virtual" component which is empty except for a list of members.
#[derive(Deserialize, Serialize, JsonSchema, Clone, Debug, PartialEq, Eq, Hash)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Component {
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub authors: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub component_dependencies: Vec<ComponentDependency>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cargo_dependencies: Vec<CargoDependency>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub members: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exclude: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub global_assets: Vec<String>,
}
/// A dependency on another component, either built-in or third-party.
#[derive(Deserialize, Serialize, JsonSchema, Clone, Debug, PartialEq, Eq, Hash)]
#[serde(untagged)]
pub enum ComponentDependency {
Builtin(String),
ThirdParty {
name: String,
git: String,
#[serde(default)]
rev: Option<String>,
},
}
/// A dependency on a cargo crate required for a component.
#[derive(Deserialize, Serialize, JsonSchema, Clone, Debug, PartialEq, Eq, Hash)]
#[serde(untagged)]
pub enum CargoDependency {
Simple(String),
Detailed {
name: String,
#[serde(default)]
version: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
features: Vec<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
default_features: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
git: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
rev: Option<String>,
},
}
impl CargoDependency {
/// Get the `cargo add` command for this dependency.
pub fn add_command(&self) -> Command {
let mut cmd = Command::new("cargo");
cmd.arg("add");
match self {
CargoDependency::Simple(name) => {
cmd.arg(name);
}
CargoDependency::Detailed {
name,
version,
features,
default_features,
git,
rev,
} => {
cmd.arg(format!(
"{name}{}",
version
.as_ref()
.map(|version| format!("@{version}"))
.unwrap_or_default()
));
if !features.is_empty() {
cmd.arg("--features").arg(features.join(","));
}
if !*default_features {
cmd.arg("--no-default-features");
}
if let Some(git) = git {
cmd.arg("--git").arg(git);
}
if let Some(rev) = rev {
cmd.arg("--rev").arg(rev);
}
}
}
cmd
}
/// Get the name of the dependency.
pub fn name(&self) -> &str {
match self {
CargoDependency::Simple(name) => name,
CargoDependency::Detailed { name, .. } => name,
}
}
}
/// Get the JSON schema for the `Component` struct.
pub fn component_manifest_schema() -> Schema {
schema_for!(Component)
}