repo view and top level file/dir view
This commit is contained in:
parent
f7e39fa7b6
commit
795fee216e
32 changed files with 1209 additions and 363 deletions
|
@ -37,3 +37,6 @@ tracing-opentelemetry = "0.28.0"
|
|||
serde = "1.0.215"
|
||||
itertools = "0.13.0"
|
||||
once_cell = "1.20.2"
|
||||
chrono = { version = "0.4.39", features = ["serde"] }
|
||||
toml = "0.8.19"
|
||||
parking_lot = "0.12.3"
|
||||
|
|
|
@ -4,10 +4,9 @@
|
|||
[![Please don't upload to GitHub](https://nogithub.codeberg.page/badge.svg)](https://nogithub.codeberg.page)
|
||||
|
||||
crusto is a git forge written in Rust, that uses:
|
||||
- vespid, a custom built in-tree (for now) SSR framework
|
||||
- vespid, a custom built in-tree (for now) SSR-only web framework
|
||||
- htmx, as the HATEOAS client layer
|
||||
- axum, as the web server
|
||||
- sleep deprivation, as the motivation
|
||||
|
||||
## how to run
|
||||
|
||||
|
@ -19,6 +18,7 @@ You can set `RUST_LOG=crusto=trace` to see traces with TRACE and up. By default
|
|||
|
||||
## roadmap
|
||||
|
||||
- [ ] repo shepherding
|
||||
- [ ] auth and users
|
||||
- [ ] repo list
|
||||
- [ ] git send-pack & receive-pack
|
||||
- [ ] repo creation
|
||||
|
|
8
infra/config.toml
Normal file
8
infra/config.toml
Normal file
|
@ -0,0 +1,8 @@
|
|||
#otel_endpoint = "http://localhost:4318" # FIXME: ERROR opentelemetry_sdk: name="BatchSpanProcessor.Flush.ExportError" reason="ExportFailed(Status { code: Unknown, message: \", detailed error message: h2 protocol error: http2 error tonic::transport::Error(Transport, hyper::Error(Http2, Error { kind: GoAway(b\\\"\\\", FRAME_SIZE_ERROR, Library) }))\" })" Failed during the export process
|
||||
|
||||
repositories_home = "run/repos"
|
||||
|
||||
[surrealdb]
|
||||
endpoint = "localhost:9867"
|
||||
ns = "crusto"
|
||||
db = "crusto"
|
|
@ -1,5 +1,5 @@
|
|||
use tailwind_fuse::tw_merge;
|
||||
use vespid::{MaybeText, component, view};
|
||||
use vespid::prelude::{MaybeText, component, view};
|
||||
|
||||
pub const CARD: &str = "rounded-lg border bg-card text-card-foreground shadow-sm";
|
||||
pub const CARD_HEADER: &str = "flex flex-col gap-2 p-6";
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
use tailwind_fuse::tw_merge;
|
||||
use vespid::{MaybeText, component, view};
|
||||
use vespid::prelude::{MaybeText, component, view};
|
||||
|
||||
#[component]
|
||||
pub async fn Spinner(#[builder(default, setter(into))] class: MaybeText) -> String {
|
||||
|
|
34
src/config.rs
Normal file
34
src/config.rs
Normal file
|
@ -0,0 +1,34 @@
|
|||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct Config {
|
||||
#[serde(default)]
|
||||
pub otel_endpoint: Option<String>,
|
||||
pub repositories_home: String,
|
||||
pub surrealdb: SurrealdbConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct SurrealdbConfig {
|
||||
pub endpoint: String,
|
||||
pub ns: String,
|
||||
pub db: String,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
otel_endpoint: None,
|
||||
repositories_home: "run/repos".to_string(),
|
||||
surrealdb: SurrealdbConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SurrealdbConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
endpoint: "localhost:9867".to_string(),
|
||||
ns: "crusto".to_string(),
|
||||
db: "crusto".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
268
src/db.rs
268
src/db.rs
|
@ -1,6 +1,268 @@
|
|||
use once_cell::sync::Lazy;
|
||||
use surrealdb::{engine::remote::ws, Surreal};
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
fmt::{Debug, Display},
|
||||
future::IntoFuture,
|
||||
hash::Hash,
|
||||
marker::PhantomData,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
pub mod flake;
|
||||
use eyre::eyre;
|
||||
use itertools::Itertools;
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use surrealdb::{
|
||||
engine::remote::ws,
|
||||
sql::{Id, Thing},
|
||||
Surreal,
|
||||
};
|
||||
use tracing::{trace_span, Instrument};
|
||||
use vespid::oco::Oco;
|
||||
|
||||
pub static DB: Lazy<Surreal<ws::Client>> = Lazy::new(Surreal::init);
|
||||
|
||||
pub trait Refable: DeserializeOwned {
|
||||
const TABLE: &'static str;
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! refable {
|
||||
(@base $name:ident, $table:literal) => {
|
||||
impl $crate::db::Refable for $name {
|
||||
const TABLE: &'static str = $table;
|
||||
}
|
||||
impl $crate::db::AsFlake<$name> for $name {
|
||||
fn as_flake(&self) -> std::borrow::Cow<'_, str> {
|
||||
std::borrow::Cow::Borrowed(self.id.flake())
|
||||
}
|
||||
}
|
||||
|
||||
impl core::ops::Deref for $name {
|
||||
type Target = $crate::db::Flake<$name>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.id
|
||||
}
|
||||
}
|
||||
};
|
||||
($name:ident, $table:literal) => {
|
||||
$crate::refable!(@base $name, $table);
|
||||
$crate::db_helpers!($name);
|
||||
};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! db_helpers {
|
||||
($name:ident) => {
|
||||
#[allow(unused)]
|
||||
impl $name {
|
||||
#[tracing::instrument(level = "trace", skip(id), fields(id = AsRef::<str>::as_ref(&id), otel.name = concat!(stringify!($name), "::get"), otel.kind = "client", peer.service = "surrealdb"))]
|
||||
pub async fn get(id: impl AsRef<str>) -> crate::Result<$name> {
|
||||
use $crate::db::{Refable, DB};
|
||||
let data: Option<$name> = DB.select(($name::TABLE, id.as_ref())).await?;
|
||||
data.ok_or_else(|| eyre::eyre!("{}:{} not found", Self::TABLE, id.as_ref()).into())
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "trace", fields(otel.name = concat!(stringify!($name), "::all"), otel.kind = "client", peer.service = "surrealdb"))]
|
||||
pub async fn all() -> crate::Result<Vec<$name>> {
|
||||
use $crate::db::{Refable, DB};
|
||||
let data: Vec<$name> = DB.select($name::TABLE).await?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "trace", skip_all, fields(otel.name = concat!(stringify!($name), "::delete"), otel.kind = "client", id = self.id.flake(), peer.service = "surrealdb"))]
|
||||
pub async fn delete(&self) -> crate::Result<()> {
|
||||
use $crate::db::{Refable, DB};
|
||||
let _: Option<serde::de::IgnoredAny> =
|
||||
DB.delete(($name::TABLE, self.id.flake())).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub use refable;
|
||||
|
||||
impl Refable for () {
|
||||
const TABLE: &'static str = "";
|
||||
}
|
||||
|
||||
pub struct BorrowedFlake<'a, T: Refable>(pub &'a str, PhantomData<T>);
|
||||
impl<'a, T: Refable> BorrowedFlake<'a, T> {
|
||||
pub fn new(s: &'a str) -> Self {
|
||||
Self(s, PhantomData)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, T: Refable> AsFlake<T> for BorrowedFlake<'a, T> {
|
||||
fn as_flake(&self) -> Cow<'_, str> {
|
||||
Cow::Borrowed(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait AsFlake<T: Refable> {
|
||||
fn as_flake(&self) -> Cow<'_, str>;
|
||||
}
|
||||
|
||||
impl<T: Refable> AsFlake<T> for Flake<T> {
|
||||
fn as_flake(&self) -> Cow<'_, str> {
|
||||
Cow::Borrowed(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Refable, F: AsFlake<T>> AsFlake<T> for &F {
|
||||
fn as_flake(&self) -> Cow<'_, str> {
|
||||
(**self).as_flake()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Flake<T: ?Sized>(pub Oco<'static, str>, PhantomData<T>);
|
||||
|
||||
impl<T: Refable + ?Sized> Debug for Flake<T> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Flake({}:{})", T::TABLE, self.flake())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ?Sized> Hash for Flake<T> {
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.flake().hash(state)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ?Sized> PartialEq for Flake<T> {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.flake() == other.flake()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ?Sized> Eq for Flake<T> {}
|
||||
|
||||
impl<T: ?Sized> Clone for Flake<T> {
|
||||
fn clone(&self) -> Self {
|
||||
Self(self.0.clone(), PhantomData)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ?Sized> Flake<T> {
|
||||
pub fn flake(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Refable + ?Sized> Flake<T> {
|
||||
pub fn borrowed(s: &str) -> BorrowedFlake<T> {
|
||||
BorrowedFlake::new(s)
|
||||
}
|
||||
|
||||
pub fn new(s: impl Into<Oco<'static, str>>) -> Self {
|
||||
Self(s.into(), PhantomData)
|
||||
}
|
||||
|
||||
pub fn into_thing(self) -> Thing {
|
||||
Thing::from((T::TABLE, self.flake()))
|
||||
}
|
||||
|
||||
pub fn from_thing_format(thing: &str) -> Self {
|
||||
let mut parts = thing.split(':');
|
||||
let flake_or_table = parts.next().unwrap();
|
||||
|
||||
Self::new(Oco::Counted(Arc::from(
|
||||
parts.next().unwrap_or(flake_or_table),
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn from_data(data: &T) -> Self
|
||||
where
|
||||
T: AsFlake<T>,
|
||||
{
|
||||
Self::new(Oco::Counted(Arc::from(data.as_flake().as_ref())))
|
||||
}
|
||||
|
||||
pub async fn fetch(&self) -> eyre::Result<T> {
|
||||
let data: Option<T> = DB
|
||||
.select::<Option<T>>((T::TABLE, self.flake()))
|
||||
.into_future()
|
||||
.instrument(trace_span!(
|
||||
"Flake::fetch",
|
||||
otel.kind = "client",
|
||||
otel.name = format!(
|
||||
"{}::get",
|
||||
std::any::type_name::<T>().trim_start_matches("chat::")
|
||||
),
|
||||
peer.service = "surrealdb",
|
||||
id = self.flake()
|
||||
))
|
||||
.await?;
|
||||
data.ok_or_else(|| eyre!("{}:{} not found", T::TABLE, self.flake()))
|
||||
}
|
||||
|
||||
pub fn cast<U: Refable>(self) -> Flake<U> {
|
||||
Flake(self.0, PhantomData)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Refable + ?Sized> Display for Flake<T> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}:{}", T::TABLE, self.flake())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Refable> AsRef<str> for Flake<T> {
|
||||
fn as_ref(&self) -> &str {
|
||||
self.flake()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de, T: Refable> Deserialize<'de> for Flake<T> {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
struct V<T: Refable>(PhantomData<T>);
|
||||
|
||||
impl<'de, T: Refable> serde::de::Visitor<'de> for V<T> {
|
||||
type Value = Flake<T>;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
writeln!(formatter, "a string or Thing")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Ok(Flake::from_thing_format(v))
|
||||
}
|
||||
|
||||
fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
|
||||
where
|
||||
A: serde::de::MapAccess<'de>,
|
||||
{
|
||||
use serde::Deserialize;
|
||||
|
||||
Ok(Flake(
|
||||
Oco::Owned(
|
||||
Thing::deserialize(serde::de::value::MapAccessDeserializer::new(map))?
|
||||
.id
|
||||
.to_raw(),
|
||||
),
|
||||
PhantomData,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_any(V::<T>(PhantomData))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Refable> Serialize for Flake<T> {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
self.flake().serialize(serializer)
|
||||
//Thing::from((String::from(T::TABLE), Id::String(self.flake().to_string())))
|
||||
// .serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
|
262
src/db/flake.rs
262
src/db/flake.rs
|
@ -1,262 +0,0 @@
|
|||
use std::{
|
||||
borrow::Cow,
|
||||
fmt::{Debug, Display},
|
||||
future::IntoFuture,
|
||||
hash::Hash,
|
||||
marker::PhantomData,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use eyre::eyre;
|
||||
use itertools::Itertools;
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use surrealdb::sql::{Id, Thing};
|
||||
use tracing::{trace_span, Instrument};
|
||||
use vespid::Oco;
|
||||
use super::DB;
|
||||
|
||||
pub trait Refable: DeserializeOwned {
|
||||
const TABLE: &'static str;
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! refable {
|
||||
(@base $name:ident, $table:literal) => {
|
||||
impl $crate::db::Refable for $name {
|
||||
const TABLE: &'static str = $table;
|
||||
}
|
||||
impl $crate::db::AsFlake<$name> for $name {
|
||||
fn as_flake(&self) -> std::borrow::Cow<'_, str> {
|
||||
std::borrow::Cow::Borrowed(self.id.flake())
|
||||
}
|
||||
}
|
||||
|
||||
impl core::ops::Deref for $name {
|
||||
type Target = $crate::db::Flake<$name>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.id
|
||||
}
|
||||
}
|
||||
};
|
||||
($name:ident, $table:literal) => {
|
||||
$crate::refable!(@base $name, $table);
|
||||
$crate::db_helpers!($name);
|
||||
};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! db_helpers {
|
||||
($name:ident) => {
|
||||
#[allow(unused)]
|
||||
impl $name {
|
||||
#[tracing::instrument(level = "trace", skip(id), fields(id = AsRef::<str>::as_ref(&id), otel.name = concat!(stringify!($name), "::get"), otel.kind = "client", peer.service = "surrealdb"))]
|
||||
pub async fn get(id: impl AsRef<str>) -> crate::Result<$name> {
|
||||
use $crate::db::{Refable, DB};
|
||||
let data: Option<$name> = DB.select(($name::TABLE, id.as_ref())).await?;
|
||||
data.ok_or_else(|| eyre!("{}:{} not found", Self::TABLE, id.as_ref()).into())
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "trace", fields(otel.name = concat!(stringify!($name), "::all"), otel.kind = "client", peer.service = "surrealdb"))]
|
||||
pub async fn all() -> crate::Result<Vec<$name>> {
|
||||
use $crate::db::{Refable, DB};
|
||||
let data: Vec<$name> = DB.select($name::TABLE).await?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "trace", skip_all, fields(otel.name = concat!(stringify!($name), "::delete"), otel.kind = "client", id = self.id.flake(), peer.service = "surrealdb"))]
|
||||
pub async fn delete(&self) -> crate::Result<()> {
|
||||
use $crate::db::{Refable, DB};
|
||||
let _: Option<serde::de::IgnoredAny> =
|
||||
DB.delete(($name::TABLE, self.id.flake())).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub use refable;
|
||||
|
||||
impl Refable for () {
|
||||
const TABLE: &'static str = "";
|
||||
}
|
||||
|
||||
pub struct BorrowedFlake<'a, T: Refable>(pub &'a str, PhantomData<T>);
|
||||
impl<'a, T: Refable> BorrowedFlake<'a, T> {
|
||||
pub fn new(s: &'a str) -> Self {
|
||||
Self(s, PhantomData)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, T: Refable> AsFlake<T> for BorrowedFlake<'a, T> {
|
||||
fn as_flake(&self) -> Cow<'_, str> {
|
||||
Cow::Borrowed(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait AsFlake<T: Refable> {
|
||||
fn as_flake(&self) -> Cow<'_, str>;
|
||||
}
|
||||
|
||||
impl<T: Refable> AsFlake<T> for Flake<T> {
|
||||
fn as_flake(&self) -> Cow<'_, str> {
|
||||
Cow::Borrowed(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Refable, F: AsFlake<T>> AsFlake<T> for &F {
|
||||
fn as_flake(&self) -> Cow<'_, str> {
|
||||
(**self).as_flake()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Flake<T: ?Sized>(pub Oco<'static, str>, PhantomData<T>);
|
||||
|
||||
impl<T: Refable + ?Sized> Debug for Flake<T> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Flake({}:{})", T::TABLE, self.flake())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ?Sized> Hash for Flake<T> {
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.flake().hash(state)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ?Sized> PartialEq for Flake<T> {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.flake() == other.flake()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ?Sized> Eq for Flake<T> {}
|
||||
|
||||
impl<T: ?Sized> Clone for Flake<T> {
|
||||
fn clone(&self) -> Self {
|
||||
Self(self.0.clone(), PhantomData)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ?Sized> Flake<T> {
|
||||
pub fn flake(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Refable + ?Sized> Flake<T> {
|
||||
pub fn borrowed(s: &str) -> BorrowedFlake<T> {
|
||||
BorrowedFlake::new(s)
|
||||
}
|
||||
|
||||
pub fn new(s: impl Into<Oco<'static, str>>) -> Self {
|
||||
Self(s.into(), PhantomData)
|
||||
}
|
||||
|
||||
pub fn into_thing(self) -> Thing {
|
||||
Thing::from((T::TABLE, self.flake()))
|
||||
}
|
||||
|
||||
pub fn from_thing_format(thing: &str) -> Self {
|
||||
let mut parts = thing.split(':');
|
||||
let flake_or_table = parts.next().unwrap();
|
||||
|
||||
Self::new(Oco::Counted(Arc::from(
|
||||
parts.next().unwrap_or(flake_or_table),
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn from_data(data: &T) -> Self
|
||||
where
|
||||
T: AsFlake<T>,
|
||||
{
|
||||
Self::new(Oco::Counted(Arc::from(data.as_flake().as_ref())))
|
||||
}
|
||||
|
||||
pub async fn fetch(&self) -> eyre::Result<T> {
|
||||
let data: Option<T> = DB
|
||||
.select::<Option<T>>((T::TABLE, self.flake()))
|
||||
.into_future()
|
||||
.instrument(trace_span!(
|
||||
"Flake::fetch",
|
||||
otel.kind = "client",
|
||||
otel.name = format!(
|
||||
"{}::get",
|
||||
std::any::type_name::<T>().trim_start_matches("chat::")
|
||||
),
|
||||
peer.service = "surrealdb",
|
||||
id = self.flake()
|
||||
))
|
||||
.await?;
|
||||
data.ok_or_else(|| eyre!("{}:{} not found", T::TABLE, self.flake()))
|
||||
}
|
||||
|
||||
pub fn cast<U: Refable>(self) -> Flake<U> {
|
||||
Flake(self.0, PhantomData)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Refable + ?Sized> Display for Flake<T> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}:{}", T::TABLE, self.flake())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Refable> AsRef<str> for Flake<T> {
|
||||
fn as_ref(&self) -> &str {
|
||||
self.flake()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de, T: Refable> Deserialize<'de> for Flake<T> {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
struct V<T: Refable>(PhantomData<T>);
|
||||
|
||||
impl<'de, T: Refable> serde::de::Visitor<'de> for V<T> {
|
||||
type Value = Flake<T>;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
writeln!(formatter, "a string or Thing")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Ok(Flake::from_thing_format(v))
|
||||
}
|
||||
|
||||
fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
|
||||
where
|
||||
A: serde::de::MapAccess<'de>,
|
||||
{
|
||||
use serde::Deserialize;
|
||||
|
||||
Ok(Flake(
|
||||
Oco::Owned(
|
||||
Thing::deserialize(serde::de::value::MapAccessDeserializer::new(map))?
|
||||
.id
|
||||
.to_raw(),
|
||||
),
|
||||
PhantomData,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_any(V::<T>(PhantomData))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Refable> Serialize for Flake<T> {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
self.flake().serialize(serializer)
|
||||
//Thing::from((String::from(T::TABLE), Id::String(self.flake().to_string())))
|
||||
// .serialize(serializer)
|
||||
}
|
||||
}
|
14
src/http.rs
Normal file
14
src/http.rs
Normal file
|
@ -0,0 +1,14 @@
|
|||
use vespid::axum::handler;
|
||||
|
||||
use super::*;
|
||||
|
||||
mod repo;
|
||||
|
||||
pub fn app() -> axum::Router {
|
||||
axum::Router::new()
|
||||
.route("/", get(handler(ui::index::Index)))
|
||||
.route("/widget", get(handler(ui::Widget)))
|
||||
.route("/:owner", get(handler(ui::Owner)))
|
||||
.route("/:owner/", get(handler(ui::Owner)))
|
||||
.nest("/:owner/:repo", repo::app())
|
||||
}
|
10
src/http/repo.rs
Normal file
10
src/http/repo.rs
Normal file
|
@ -0,0 +1,10 @@
|
|||
use super::*;
|
||||
|
||||
pub fn app() -> axum::Router {
|
||||
axum::Router::new()
|
||||
.route("/", get(handler(ui::repo::Index)))
|
||||
//.route("/git-upload-pack", post(upload_pack))
|
||||
//.route("/git-receive-pack", post(receive_pack))
|
||||
}
|
||||
|
||||
async fn git_rpc(service: &str) {}
|
79
src/main.rs
79
src/main.rs
|
@ -5,83 +5,48 @@ extern crate tracing;
|
|||
|
||||
use std::sync::{atomic::AtomicU64, Arc};
|
||||
|
||||
use axum::{response::Html, routing::get};
|
||||
use axum::{
|
||||
response::Html,
|
||||
routing::{get, post},
|
||||
};
|
||||
use magic::prelude::*;
|
||||
use surrealdb::{engine::remote::ws::Ws, opt::auth::Root};
|
||||
use vespid::axum::render;
|
||||
|
||||
mod config;
|
||||
mod db;
|
||||
mod model;
|
||||
use db::DB;
|
||||
mod http;
|
||||
mod tracing_stuff;
|
||||
mod ui;
|
||||
|
||||
#[component]
|
||||
fn Shell(children: String) -> String {
|
||||
info!("Index");
|
||||
view! {
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Crusto</title>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.3" integrity="sha384-0895/pl2MU10Hqc6jd4RvrthNlDiE9U1tWmX7WRESftEDRosgxNsQG/Ze9YMRzHq" crossorigin="anonymous"></script>
|
||||
<style>{include_str!("../target/app.css")}</style>
|
||||
</head>
|
||||
<body class="flex flex-col">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
}
|
||||
type Result<T, E = vespid::error::Error> = std::result::Result<T, E>;
|
||||
|
||||
async fn index() -> Html<String> {
|
||||
render(async move {
|
||||
view! {
|
||||
<Shell>
|
||||
<h1>"Hello to Crusto!"</h1>
|
||||
<p>"Index"</p>
|
||||
|
||||
<div id="widget" class="flex flex-col items-center justify-center" hx-get="/widget" hx-swap="outerHTML" hx-trigger="load" hx-indicator="#widget > .spinner">
|
||||
<Spinner />
|
||||
</div>
|
||||
</Shell>
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
pub static CONFIG: once_cell::sync::Lazy<parking_lot::RwLock<config::Config>> =
|
||||
once_cell::sync::Lazy::new(|| parking_lot::RwLock::new(config::Config::default()));
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> eyre::Result<()> {
|
||||
color_eyre::install()?;
|
||||
let _otel_guard = tracing_stuff::init_tracing_subscriber();
|
||||
let url = std::env::var("CRUSTO_DB_URL").unwrap_or_else(|_| "localhost:9867".to_string());
|
||||
debug!(%url, "Database connecting");
|
||||
DB.connect::<Ws>(url).await?;
|
||||
let config_path = std::env::args()
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| "config.toml".to_string());
|
||||
let config = toml::from_str::<config::Config>(&std::fs::read_to_string(&config_path)?)?;
|
||||
let _otel_guard = tracing_stuff::init_tracing_subscriber(config.otel_endpoint.clone());
|
||||
debug!(?config.surrealdb, "Database connecting");
|
||||
DB.connect::<Ws>(&config.surrealdb.endpoint).await?;
|
||||
debug!("Database connected");
|
||||
DB.signin(Root {
|
||||
username: "root",
|
||||
password: "root",
|
||||
})
|
||||
.await?;
|
||||
DB.use_ns("crusto").await?;
|
||||
DB.use_db("crusto").await?;
|
||||
DB.use_ns(&config.surrealdb.ns).await?;
|
||||
DB.use_db(&config.surrealdb.db).await?;
|
||||
*CONFIG.write() = config;
|
||||
info!("Database ready");
|
||||
|
||||
let amount_of_refreshes = Arc::new(AtomicU64::new(0));
|
||||
|
||||
let app = axum::Router::new().route("/", get(index)).route("/widget", get(|| async move {
|
||||
render(async move {
|
||||
view! {
|
||||
<Card id="widget" class="w-64 flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle>"Widget"</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>{amount_of_refreshes.fetch_add(1, std::sync::atomic::Ordering::Relaxed)} " refreshes"</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
}
|
||||
}).await
|
||||
}));
|
||||
let app = http::app();
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
|
||||
info!("listening on {}", listener.local_addr()?);
|
||||
|
|
71
src/model.rs
Normal file
71
src/model.rs
Normal file
|
@ -0,0 +1,71 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use super::*;
|
||||
use crate::db::*;
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct User {
|
||||
pub id: Flake<User>,
|
||||
pub username: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub about_me: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_logout: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
refable!(User, "user");
|
||||
|
||||
impl User {
|
||||
#[tracing::instrument(level = "trace", fields(otel.name = "User::get_by_name", otel.kind = "client"))]
|
||||
pub async fn get_by_name(name: &str) -> Result<Self> {
|
||||
let query = format!("SELECT * FROM user WHERE username = \"{name}\"");
|
||||
trace!(%query);
|
||||
let opt_me: Option<Self> = DB.query(&query).await?.take(0)?;
|
||||
opt_me.ok_or_else(|| eyre::eyre!("User not found").into())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Repository {
|
||||
pub id: Flake<Repository>,
|
||||
pub owner: Flake<User>,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
refable!(Repository, "repo");
|
||||
|
||||
impl Repository {
|
||||
#[tracing::instrument(level = "trace", fields(otel.name = "Repository::get_by_owner_repo", otel.kind = "client"))]
|
||||
pub async fn get_by_owner_repo(owner: &Flake<User>, repo: &str) -> Result<Self> {
|
||||
let query = format!("SELECT * FROM repo WHERE owner = {owner} AND name = \"{repo}\"");
|
||||
trace!(%query);
|
||||
let opt_me: Option<Self> = DB.query(&query).await?.check()?.take(0)?;
|
||||
opt_me.ok_or_else(|| eyre::eyre!("Repository not found").into())
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "trace", fields(otel.name = "Repository::get_by_owner_name_and_repo", otel.kind = "client"))]
|
||||
pub async fn get_by_owner_name_and_repo(owner: &str, repo: &str) -> Result<Self> {
|
||||
let query =
|
||||
format!("SELECT * FROM repo WHERE owner.username = \"{owner}\" AND name = \"{repo}\"");
|
||||
trace!(%query);
|
||||
let opt_me: Option<Self> = DB.query(&query).await?.check()?.take(0)?;
|
||||
opt_me.ok_or_else(|| eyre::eyre!("Repository not found").into())
|
||||
}
|
||||
|
||||
pub async fn open(&self) -> Result<git2::Repository> {
|
||||
let id = self.id.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let config = crate::CONFIG.read();
|
||||
git2::Repository::open_bare(PathBuf::from(&config.repositories_home).join(id.flake()))
|
||||
.map_err(|e| e.into())
|
||||
})
|
||||
.await
|
||||
.expect("JoinError")
|
||||
}
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
use opentelemetry::{global, trace::TracerProvider as _, KeyValue};
|
||||
use opentelemetry_otlp::WithExportConfig;
|
||||
use opentelemetry_sdk::{
|
||||
metrics::{MeterProviderBuilder, PeriodicReader, SdkMeterProvider},
|
||||
runtime,
|
||||
|
@ -23,9 +24,10 @@ fn resource() -> Resource {
|
|||
)
|
||||
}
|
||||
|
||||
fn init_meter_provider() -> SdkMeterProvider {
|
||||
fn init_meter_provider(otel_endpoint: &str) -> SdkMeterProvider {
|
||||
let exporter = opentelemetry_otlp::MetricExporter::builder()
|
||||
.with_tonic()
|
||||
.with_endpoint(otel_endpoint)
|
||||
.with_temporality(opentelemetry_sdk::metrics::Temporality::default())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
@ -44,9 +46,10 @@ fn init_meter_provider() -> SdkMeterProvider {
|
|||
meter_provider
|
||||
}
|
||||
|
||||
fn init_tracer() -> Tracer {
|
||||
fn init_tracer(otel_endpoint: &str) -> Tracer {
|
||||
let exporter = opentelemetry_otlp::SpanExporter::builder()
|
||||
.with_tonic()
|
||||
.with_endpoint(otel_endpoint)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
|
@ -62,36 +65,45 @@ fn init_tracer() -> Tracer {
|
|||
provider.tracer("crusto")
|
||||
}
|
||||
|
||||
pub fn init_tracing_subscriber() -> OtelGuard {
|
||||
let meter_provider = init_meter_provider();
|
||||
let tracer = init_tracer();
|
||||
|
||||
tracing_subscriber::registry()
|
||||
pub fn init_tracing_subscriber(otel_endpoint: Option<String>) -> OtelGuard {
|
||||
let registry = tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::builder()
|
||||
.with_default_directive(LevelFilter::INFO.into())
|
||||
.from_env_lossy(),
|
||||
)
|
||||
//.with(funnylog::tracing::TracingLayer::new(
|
||||
// funnylog::terminal::TerminalConfig::default().to_stdout().ignore_error(),
|
||||
//))
|
||||
.with(tracing_error::ErrorLayer::default())
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.with(MetricsLayer::new(meter_provider.clone()))
|
||||
.with(OpenTelemetryLayer::new(tracer))
|
||||
.init();
|
||||
.with(tracing_subscriber::fmt::layer());
|
||||
|
||||
OtelGuard { meter_provider }
|
||||
if let Some(otel_endpoint) = otel_endpoint {
|
||||
let meter_provider = init_meter_provider(&otel_endpoint);
|
||||
let tracer = init_tracer(&otel_endpoint);
|
||||
registry
|
||||
.with(MetricsLayer::new(meter_provider.clone()))
|
||||
.with(OpenTelemetryLayer::new(tracer))
|
||||
.init();
|
||||
|
||||
OtelGuard {
|
||||
meter_provider: Some(meter_provider),
|
||||
}
|
||||
} else {
|
||||
registry.init();
|
||||
OtelGuard {
|
||||
meter_provider: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OtelGuard {
|
||||
meter_provider: SdkMeterProvider,
|
||||
meter_provider: Option<SdkMeterProvider>,
|
||||
}
|
||||
|
||||
impl Drop for OtelGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Err(err) = self.meter_provider.shutdown() {
|
||||
eprintln!("meter_provider.shutdown: {err:?}");
|
||||
if let Some(meter_provider) = self.meter_provider.take() {
|
||||
if let Err(err) = meter_provider.shutdown() {
|
||||
eprintln!("meter_provider.shutdown: {err:?}");
|
||||
}
|
||||
}
|
||||
opentelemetry::global::shutdown_tracer_provider();
|
||||
}
|
||||
|
|
50
src/ui.rs
Normal file
50
src/ui.rs
Normal file
|
@ -0,0 +1,50 @@
|
|||
use axum::{extract::Path, http::request::Parts};
|
||||
use model::User;
|
||||
use vespid::axum::{extract, handler};
|
||||
|
||||
use super::*;
|
||||
|
||||
pub mod index;
|
||||
pub mod user;
|
||||
pub mod repo;
|
||||
|
||||
#[component]
|
||||
fn Shell(children: String) -> String {
|
||||
view! {
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>Crusto</title>
|
||||
<script
|
||||
src="https://unpkg.com/htmx.org@2.0.3"
|
||||
integrity="sha384-0895/pl2MU10Hqc6jd4RvrthNlDiE9U1tWmX7WRESftEDRosgxNsQG/Ze9YMRzHq"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
<style>{include_str!("../target/app.css")}</style>
|
||||
</head>
|
||||
<body class="flex flex-col">{children}</body>
|
||||
</html>
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn Widget() -> crate::Result<String> {
|
||||
let parts: Parts = extract().await?;
|
||||
Ok(view! {
|
||||
<Card id="widget" class="w-64 flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle>"Widget"</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{"professional goober"} <p>"URL: " {parts.uri.to_string()}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn Owner() -> crate::Result<String> {
|
||||
let Path(owner): Path<String> = extract().await?;
|
||||
let user = User::get_by_name(&owner).await?;
|
||||
|
||||
user::Index(user).await
|
||||
}
|
21
src/ui/index.rs
Normal file
21
src/ui/index.rs
Normal file
|
@ -0,0 +1,21 @@
|
|||
use super::*;
|
||||
|
||||
pub async fn Index() -> crate::Result<String> {
|
||||
Ok(view! {
|
||||
<Shell>
|
||||
<h1>"Hello to Crusto!"</h1>
|
||||
<p>"Index"</p>
|
||||
|
||||
<div
|
||||
id="widget"
|
||||
class="flex flex-col items-center justify-center"
|
||||
hx-get="/widget"
|
||||
hx-swap="outerHTML"
|
||||
hx-trigger="load"
|
||||
hx-indicator=".spinner"
|
||||
>
|
||||
<Spinner/>
|
||||
</div>
|
||||
</Shell>
|
||||
})
|
||||
}
|
105
src/ui/repo.rs
Normal file
105
src/ui/repo.rs
Normal file
|
@ -0,0 +1,105 @@
|
|||
use axum::extract::Path;
|
||||
use model::*;
|
||||
|
||||
use super::*;
|
||||
|
||||
pub async fn Index() -> crate::Result<String> {
|
||||
let Path((p_owner, p_repo)): Path<(String, String)> = extract().await?;
|
||||
let repo = Repository::get_by_owner_name_and_repo(&p_owner, &p_repo).await?;
|
||||
trace!(?repo);
|
||||
let owner = User::get(&repo.owner).await?;
|
||||
let git = repo.open().await?;
|
||||
let head = git.head()?;
|
||||
let head_oid = head
|
||||
.target()
|
||||
.ok_or_else(|| eyre::eyre!("head is symbolic ref we dont like that"))?;
|
||||
let head_commit = head.peel_to_commit()?;
|
||||
let head_tree = head_commit.tree()?;
|
||||
let mut dirs = vec![];
|
||||
let mut files = vec![];
|
||||
for entry in head_tree.into_iter() {
|
||||
let kind = entry.kind();
|
||||
let name = entry
|
||||
.name()
|
||||
.expect("not utf8 for whatever reason")
|
||||
.to_string();
|
||||
if kind.unwrap_or(git2::ObjectType::Tree) == git2::ObjectType::Tree {
|
||||
dirs.push(name);
|
||||
} else {
|
||||
files.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(view! {
|
||||
<Shell>
|
||||
<Card class="h-full w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>{owner.username} "/" {repo.name}</CardTitle>
|
||||
|
||||
{if let Some(description) = &repo.description {
|
||||
Some(view! { <CardDescription>{description}</CardDescription> })
|
||||
} else {
|
||||
None
|
||||
}}
|
||||
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex flex-row gap-4">
|
||||
// TODO: head commit info
|
||||
// <a href=format!("/{head_commit_committer}")>
|
||||
// {head_commit_committer}
|
||||
// </a>
|
||||
// <a href=format!("/{p_owner}/{p_repo}/commit/{head_oid}")>
|
||||
//
|
||||
// </a>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
{collect_fragments(
|
||||
dirs
|
||||
.into_iter()
|
||||
.map(|dir| (dir, true))
|
||||
.chain(files.into_iter().map(|file| (file, false)))
|
||||
.map(|(name, is_dir)| {
|
||||
let p_owner = p_owner.clone();
|
||||
let p_repo = p_repo.clone();
|
||||
async move {
|
||||
view! {
|
||||
<div class="flex flex-row gap-4">
|
||||
<a
|
||||
class="flex flex-row gap-2 group"
|
||||
href=format!(
|
||||
"/{p_owner}/{p_repo}/{kind}/{name}",
|
||||
kind = if is_dir { "tree" } else { "blob" },
|
||||
)
|
||||
>
|
||||
|
||||
<Icon
|
||||
icon=if is_dir {
|
||||
icondata::LuFolder
|
||||
} else {
|
||||
icondata::LuFile
|
||||
}
|
||||
|
||||
class=tw_merge!(
|
||||
"w-6 h-6", if is_dir { "text-primary" } else { "" }
|
||||
)
|
||||
/>
|
||||
|
||||
<span
|
||||
class="group-hover:underline">{name}</span>
|
||||
</a>
|
||||
|
||||
// TODO: commit
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Shell>
|
||||
})
|
||||
}
|
14
src/ui/user.rs
Normal file
14
src/ui/user.rs
Normal file
|
@ -0,0 +1,14 @@
|
|||
use super::*;
|
||||
|
||||
pub async fn Index(owner: User) -> crate::Result<String> {
|
||||
Ok(view! {
|
||||
<Shell>
|
||||
<Card class="h-full w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>{owner.username}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>{owner.id.flake()}</CardContent>
|
||||
</Card>
|
||||
</Shell>
|
||||
})
|
||||
}
|
|
@ -12,3 +12,9 @@ vespid_macros.path = "macros"
|
|||
typed-builder = "0.20.0"
|
||||
serde = "1.0.215"
|
||||
thiserror = "2.0.6"
|
||||
pin-project-lite = "0.2.15"
|
||||
eyre = "0.6.12"
|
||||
rustc-hash = "2.1.0"
|
||||
parking_lot = "0.12.3"
|
||||
http-body-util = "0.1.2"
|
||||
futures-util = { version = "0.3.31", default-features = false, features = ["std"] }
|
||||
|
|
11
vespid/LICENSE
Normal file
11
vespid/LICENSE
Normal file
|
@ -0,0 +1,11 @@
|
|||
Anti-GitHub License (AGHL) v1 (based on the MIT license)
|
||||
|
||||
Copyright (c) 2024 Ilya Borodinov <borodinov.ilya@gmail.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
2. The Software shall not be published, distributed, or otherwise made available on GitHub.com or any of its subdomains or affiliated websites. Failure to comply with this condition will immediately revoke the rights granted hereunder.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
10
vespid/README.md
Normal file
10
vespid/README.md
Normal file
|
@ -0,0 +1,10 @@
|
|||
# vespid - an SSR-only web framework for Rust
|
||||
|
||||
[![License: AGHL v1](https://badgers.space/badge/License/AGHL%20v1/blue)](./LICENSE)
|
||||
[![Please don't upload to GitHub](https://nogithub.codeberg.page/badge.svg)](https://nogithub.codeberg.page)
|
||||
|
||||
vespid is a Rust web framework that is SSR-only, meaning that it just renders HTML.
|
||||
|
||||
vespid is very much inspired by [leptos](https://github.com/leptos-rs/leptos), and if you look at it from this perspective, it's just leptos without the WASM, and tailored for [axum](https://github.com/tokio-rs/axum).
|
||||
|
||||
Most of the framework's code are just utilities, like error handling using an `<ErrorBoundary>` component (with a bit taken from leptos's [`throw_error`](https://docs.rs/throw_error)), an `Error` type that is just `eyre::Error` + `axum::http::StatusCode`, etc.
|
|
@ -67,7 +67,7 @@ fn process_nodes<'n>(
|
|||
#(#errors;)*
|
||||
// Make sure that "enum x{};" and "let _x = crate::element;" can be used in this context
|
||||
#(#docs;)*
|
||||
format!(#html_string, #(vespid::FormatRender::new(#values)),*)
|
||||
format!(#html_string, #(vespid::render_adapter::FormatRender::new(#values)),*)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,12 @@
|
|||
use std::{future::Future, sync::OnceLock, thread::available_parallelism};
|
||||
use std::{any::TypeId, future::Future, sync::OnceLock, thread::available_parallelism};
|
||||
|
||||
use axum::response::Html;
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{FromRequest, FromRequestParts, Request},
|
||||
http::{request::Parts, StatusCode},
|
||||
response::{Html, IntoResponse, Response},
|
||||
RequestExt,
|
||||
};
|
||||
use tokio_util::task::LocalPoolHandle;
|
||||
|
||||
use crate::context;
|
||||
|
@ -25,3 +31,70 @@ where
|
|||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn handler<F, O, RenderFn>(
|
||||
f: RenderFn,
|
||||
) -> impl Fn(Request) -> std::pin::Pin<Box<dyn Future<Output = Response> + Send + 'static>>
|
||||
+ Clone
|
||||
+ Send
|
||||
+ 'static
|
||||
where
|
||||
RenderFn: Fn() -> F + Clone + Send + 'static,
|
||||
F: Future<Output = Result<O, crate::error::Error>> + 'static,
|
||||
O: Into<Body> + Send + 'static,
|
||||
{
|
||||
move |req: Request| {
|
||||
let f = f.clone();
|
||||
Box::pin(async move {
|
||||
get_rendering_pool()
|
||||
.spawn_pinned(move || async move {
|
||||
let f = f.clone();
|
||||
context::spawn_local(async move {
|
||||
let f = f.clone();
|
||||
context::provide_context(req.into_parts().0);
|
||||
f().await.map(|x| Html(x)).into_response()
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn extract<E: FromRequestParts<()> + Send + 'static>() -> Result<E, crate::error::Error>
|
||||
where
|
||||
E::Rejection: std::fmt::Debug,
|
||||
{
|
||||
extract_with_state(&()).await
|
||||
}
|
||||
|
||||
pub async fn extract_with_state<E: FromRequestParts<S> + Send + 'static, S>(
|
||||
state: &S,
|
||||
) -> Result<E, crate::error::Error>
|
||||
where
|
||||
E::Rejection: std::fmt::Debug,
|
||||
{
|
||||
let mut parts = context::use_context::<Parts>()
|
||||
.ok_or_else(|| eyre::eyre!("should have had Parts provided by vespid::axum::handler"))?;
|
||||
|
||||
match E::from_request_parts(&mut parts, state).await {
|
||||
Ok(ok) => Ok(ok),
|
||||
Err(e) => {
|
||||
use http_body_util::BodyExt;
|
||||
|
||||
let res = e.into_response();
|
||||
let (parts, body) = res.into_parts();
|
||||
let bytes = body.collect().await.expect("Body::collect").to_bytes();
|
||||
let s = String::from_utf8(bytes.into()).expect("String::from_utf8");
|
||||
Err(crate::error::Error::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
eyre::eyre!(
|
||||
"failed to extract {E}: {parts:?} {s}",
|
||||
E = std::any::type_name::<E>(),
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -50,3 +50,37 @@ where
|
|||
use_context::<T>()
|
||||
.unwrap_or_else(|| panic!("Context not found for {T}", T = std::any::type_name::<T>()))
|
||||
}
|
||||
|
||||
pub fn apply_context<T: 'static, U>(f: impl FnOnce(&T) -> U) -> Option<U> {
|
||||
CONTEXT.with(|context| {
|
||||
context
|
||||
.borrow()
|
||||
.get(&TypeId::of::<T>())
|
||||
.map(|any| {
|
||||
any.downcast_ref::<T>().unwrap_or_else(|| {
|
||||
panic!(
|
||||
"Context type mismatch for {T}",
|
||||
T = std::any::type_name::<T>()
|
||||
)
|
||||
})
|
||||
})
|
||||
.map(f)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn apply_context_mut<T: 'static, U>(f: impl FnOnce(&mut T) -> U) -> Option<U> {
|
||||
CONTEXT.with(|context| {
|
||||
context
|
||||
.borrow_mut()
|
||||
.get_mut(&TypeId::of::<T>())
|
||||
.map(|any| {
|
||||
any.downcast_mut::<T>().unwrap_or_else(|| {
|
||||
panic!(
|
||||
"Context type mismatch for {T}",
|
||||
T = std::any::type_name::<T>()
|
||||
)
|
||||
})
|
||||
})
|
||||
.map(f)
|
||||
})
|
||||
}
|
||||
|
|
11
vespid/src/error.rs
Normal file
11
vespid/src/error.rs
Normal file
|
@ -0,0 +1,11 @@
|
|||
mod hook;
|
||||
pub use hook::*;
|
||||
|
||||
mod errors;
|
||||
pub use errors::*;
|
||||
|
||||
mod error_boundary;
|
||||
pub use error_boundary::*;
|
||||
|
||||
mod wrapper;
|
||||
pub use wrapper::*;
|
49
vespid/src/error/error_boundary.rs
Normal file
49
vespid/src/error/error_boundary.rs
Normal file
|
@ -0,0 +1,49 @@
|
|||
use std::{future::Future, sync::Arc};
|
||||
|
||||
type SyncErrors = Arc<parking_lot::Mutex<super::Errors>>;
|
||||
|
||||
use vespid_macros::component;
|
||||
|
||||
// #[derive(vespid::typed_builder::TypedBuilder)]
|
||||
// #[builder(doc, crate = ::vespid::typed_builder)]
|
||||
// pub struct ErrorBoundaryProps<Fallback, FallbackFn> {
|
||||
// pub children: String,
|
||||
// pub fallback: FallbackFn,
|
||||
// }
|
||||
//
|
||||
// #[allow(non_snake_case)]
|
||||
// pub async fn ErrorBoundary<
|
||||
// Fallback: Future<Output = String>,
|
||||
// FallbackFn: Fn(super::Errors) -> Fallback,
|
||||
// >(
|
||||
// props: ErrorBoundaryProps<Fallback, FallbackFn>
|
||||
// ) -> String {
|
||||
// let id = crate::next_id();
|
||||
// let errors = SyncErrors::new(parking_lot::Mutex::new(super::Errors::default()));
|
||||
// let hook = Arc::new(ErrorHook {
|
||||
// errors: errors.clone(),
|
||||
// });
|
||||
// let hook = hook as Arc<dyn super::ErrorHook>;
|
||||
// let errors = errors.lock();
|
||||
// if errors.is_empty() {
|
||||
// props.children
|
||||
// } else {
|
||||
// props.fallback(errors).await
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// struct ErrorHook {
|
||||
// errors: SyncErrors,
|
||||
// }
|
||||
//
|
||||
// impl super::ErrorHook for ErrorHook {
|
||||
// fn throw(&self, error: super::Error) -> super::ErrorId {
|
||||
// let id = super::ErrorId(crate::next_id());
|
||||
// self.errors.lock().insert(id, error);
|
||||
// id
|
||||
// }
|
||||
//
|
||||
// fn clear(&self, id: &super::ErrorId) {
|
||||
// self.errors.lock().remove(id);
|
||||
// }
|
||||
// }
|
82
vespid/src/error/errors.rs
Normal file
82
vespid/src/error/errors.rs
Normal file
|
@ -0,0 +1,82 @@
|
|||
//! A container for errors thrown by views, taken from [`leptos`](https://github.com/leptos-rs/leptos/blob/d9b590b8e0e49a0537b973b7083040cbb46b523c/leptos/src/error_boundary.rs#L467)
|
||||
|
||||
use rustc_hash::FxHashMap;
|
||||
|
||||
use super::{Error, ErrorId};
|
||||
|
||||
/// A struct to hold all the possible errors that could be provided by child
|
||||
/// [`view!s`](vespid_macros::view)
|
||||
#[derive(Debug, Default)]
|
||||
#[repr(transparent)]
|
||||
pub struct Errors(FxHashMap<ErrorId, Error>);
|
||||
|
||||
impl Errors {
|
||||
/// Returns `true` if there are no errors.
|
||||
#[inline(always)]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
|
||||
/// Add an error to Errors that will be processed by `<ErrorBoundary/>`
|
||||
pub fn insert<E>(&mut self, key: ErrorId, error: E)
|
||||
where
|
||||
E: Into<Error>,
|
||||
{
|
||||
self.0.insert(key, error.into());
|
||||
}
|
||||
|
||||
/// Add an error with the default key for errors outside the reactive system
|
||||
pub fn insert_with_default_key<E>(&mut self, error: E)
|
||||
where
|
||||
E: Into<Error>,
|
||||
{
|
||||
self.0.insert(Default::default(), error.into());
|
||||
}
|
||||
|
||||
/// Remove an error to Errors that will be processed by `<ErrorBoundary/>`
|
||||
pub fn remove(&mut self, key: &ErrorId) -> Option<Error> {
|
||||
self.0.remove(key)
|
||||
}
|
||||
|
||||
/// An iterator over all the errors, in arbitrary order.
|
||||
#[inline(always)]
|
||||
pub fn iter(&self) -> Iter<'_> {
|
||||
Iter(self.0.iter())
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoIterator for Errors {
|
||||
type IntoIter = IntoIter;
|
||||
type Item = (ErrorId, Error);
|
||||
|
||||
#[inline(always)]
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
IntoIter(self.0.into_iter())
|
||||
}
|
||||
}
|
||||
|
||||
/// An owning iterator over all the errors contained in the [`Errors`] struct.
|
||||
#[repr(transparent)]
|
||||
pub struct IntoIter(std::collections::hash_map::IntoIter<ErrorId, Error>);
|
||||
|
||||
impl Iterator for IntoIter {
|
||||
type Item = (ErrorId, Error);
|
||||
|
||||
#[inline(always)]
|
||||
fn next(&mut self) -> std::option::Option<<Self as std::iter::Iterator>::Item> {
|
||||
self.0.next()
|
||||
}
|
||||
}
|
||||
|
||||
/// An iterator over all the errors contained in the [`Errors`] struct.
|
||||
#[repr(transparent)]
|
||||
pub struct Iter<'a>(std::collections::hash_map::Iter<'a, ErrorId, Error>);
|
||||
|
||||
impl<'a> Iterator for Iter<'a> {
|
||||
type Item = (&'a ErrorId, &'a Error);
|
||||
|
||||
#[inline(always)]
|
||||
fn next(&mut self) -> std::option::Option<<Self as std::iter::Iterator>::Item> {
|
||||
self.0.next()
|
||||
}
|
||||
}
|
121
vespid/src/error/hook.rs
Normal file
121
vespid/src/error/hook.rs
Normal file
|
@ -0,0 +1,121 @@
|
|||
//! Error handling utilities taken from [`throw_error`](https://github.com/leptos-rs/leptos/blob/d9b590b8e0e49a0537b973b7083040cbb46b523c/any_error/src/lib.rs), and adapted for vespid's [`Error`](crate::error::Error) type
|
||||
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
fmt::{self, Display},
|
||||
future::Future,
|
||||
mem,
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
|
||||
use super::Error;
|
||||
|
||||
/// Implements behavior that allows for global or scoped error handling.
|
||||
///
|
||||
/// This allows for both "throwing" errors to register them, and "clearing" errors when they are no
|
||||
/// longer valid. This is useful for something like a user interface, in which an error can be
|
||||
/// "thrown" on some invalid user input, and later "cleared" if the user corrects the input.
|
||||
/// Keeping a unique identifier for each error allows the UI to be updated accordingly.
|
||||
pub trait ErrorHook: Send + Sync {
|
||||
/// Handles the given error, returning a unique identifier.
|
||||
fn throw(&self, error: crate::error::Error) -> ErrorId;
|
||||
|
||||
/// Clears the error associated with the given identifier.
|
||||
fn clear(&self, id: &ErrorId);
|
||||
}
|
||||
/// A unique identifier for an error. This is returned when you call [`throw`], which calls a
|
||||
/// global error handler.
|
||||
#[derive(Debug, PartialEq, Eq, Hash, Clone, Default)]
|
||||
pub struct ErrorId(usize);
|
||||
|
||||
impl Display for ErrorId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
Display::fmt(&self.0, f)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<usize> for ErrorId {
|
||||
fn from(value: usize) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static ERROR_HOOK: RefCell<Option<Arc<dyn ErrorHook>>> = RefCell::new(None);
|
||||
}
|
||||
|
||||
/// Resets the error hook to its previous state when dropped.
|
||||
pub struct ResetErrorHookOnDrop(Option<Arc<dyn ErrorHook>>);
|
||||
|
||||
impl Drop for ResetErrorHookOnDrop {
|
||||
fn drop(&mut self) {
|
||||
ERROR_HOOK.with_borrow_mut(|this| *this = self.0.take())
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the current error hook.
|
||||
pub fn get_error_hook() -> Option<Arc<dyn ErrorHook>> {
|
||||
ERROR_HOOK.with_borrow(Clone::clone)
|
||||
}
|
||||
|
||||
/// Sets the current thread-local error hook, which will be invoked when [`throw`] is called.
|
||||
pub fn set_error_hook(hook: Arc<dyn ErrorHook>) -> ResetErrorHookOnDrop {
|
||||
ResetErrorHookOnDrop(ERROR_HOOK.with_borrow_mut(|this| mem::replace(this, Some(hook))))
|
||||
}
|
||||
|
||||
/// Invokes the error hook set by [`set_error_hook`] with the given error.
|
||||
pub fn throw(error: impl Into<Error>) -> ErrorId {
|
||||
ERROR_HOOK
|
||||
.with_borrow(|hook| hook.as_ref().map(|hook| hook.throw(error.into())))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Clears the given error from the current error hook.
|
||||
pub fn clear(id: &ErrorId) {
|
||||
ERROR_HOOK
|
||||
.with_borrow(|hook| hook.as_ref().map(|hook| hook.clear(id)))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pin_project_lite::pin_project! {
|
||||
/// A [`Future`] that reads the error hook that is set when it is created, and sets this as the
|
||||
/// current error hook whenever it is polled.
|
||||
pub struct ErrorHookFuture<Fut> {
|
||||
hook: Option<Arc<dyn ErrorHook>>,
|
||||
#[pin]
|
||||
inner: Fut
|
||||
}
|
||||
}
|
||||
|
||||
impl<Fut> ErrorHookFuture<Fut> {
|
||||
/// Reads the current hook and wraps the given [`Future`], returning a new `Future` that will
|
||||
/// set the error hook whenever it is polled.
|
||||
pub fn new(inner: Fut) -> Self {
|
||||
Self {
|
||||
hook: ERROR_HOOK.with_borrow(Clone::clone),
|
||||
inner,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_with_hook(inner: Fut, hook: Arc<dyn ErrorHook>) -> Self {
|
||||
Self { hook: Some(hook), inner }
|
||||
}
|
||||
}
|
||||
|
||||
impl<Fut> Future for ErrorHookFuture<Fut>
|
||||
where
|
||||
Fut: Future,
|
||||
{
|
||||
type Output = Fut::Output;
|
||||
|
||||
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||
let this = self.project();
|
||||
let _hook = this
|
||||
.hook
|
||||
.as_ref()
|
||||
.map(|hook| set_error_hook(Arc::clone(hook)));
|
||||
this.inner.poll(cx)
|
||||
}
|
||||
}
|
113
vespid/src/error/wrapper.rs
Normal file
113
vespid/src/error/wrapper.rs
Normal file
|
@ -0,0 +1,113 @@
|
|||
use std::fmt::{Debug, Display};
|
||||
|
||||
use axum::{http::StatusCode, response::IntoResponse};
|
||||
|
||||
pub type Result<T, E = Error> = std::result::Result<T, E>;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Error {
|
||||
pub status: StatusCode,
|
||||
pub error: eyre::Error,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
#[track_caller]
|
||||
pub fn new(status: StatusCode, error: impl Into<eyre::Error>) -> Self {
|
||||
Self {
|
||||
status,
|
||||
error: error.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
pub fn msg(status: StatusCode, msg: impl Display + Debug + Send + Sync + 'static) -> Self {
|
||||
Self {
|
||||
status,
|
||||
error: eyre::Error::msg(msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ErrorExt {
|
||||
#[track_caller]
|
||||
fn with_status(self, status: StatusCode) -> Error;
|
||||
#[track_caller]
|
||||
fn context<E: std::fmt::Display + Send + Sync + 'static>(self, context: E) -> Error;
|
||||
#[track_caller]
|
||||
fn realize(self) -> Error;
|
||||
}
|
||||
|
||||
impl ErrorExt for Error {
|
||||
fn with_status(mut self, status: StatusCode) -> Error {
|
||||
self.status = status;
|
||||
self
|
||||
}
|
||||
|
||||
fn context<E: std::fmt::Display + Send + Sync + 'static>(mut self, context: E) -> Error {
|
||||
self.error = self.error.wrap_err(context);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
fn realize(self) -> Error {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: Into<eyre::Error>> ErrorExt for E {
|
||||
fn with_status(self, status: StatusCode) -> Error {
|
||||
Error::new(status, self)
|
||||
}
|
||||
|
||||
fn context<D: std::fmt::Display + Send + Sync + 'static>(self, context: D) -> Error {
|
||||
Error::new(StatusCode::INTERNAL_SERVER_ERROR, self).context(context)
|
||||
}
|
||||
|
||||
fn realize(self) -> Error {
|
||||
Error::new(StatusCode::INTERNAL_SERVER_ERROR, self)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ResultExt<T> {
|
||||
#[track_caller]
|
||||
fn with_status(self, status: StatusCode) -> Result<T, Error>;
|
||||
#[track_caller]
|
||||
fn context<E: std::fmt::Display + Send + Sync + 'static>(self, context: E) -> Result<T, Error>;
|
||||
#[track_caller]
|
||||
fn realize(self) -> Result<T, Error>;
|
||||
}
|
||||
|
||||
impl<T, E: ErrorExt> ResultExt<T> for Result<T, E> {
|
||||
fn with_status(self, status: StatusCode) -> Result<T, Error> {
|
||||
self.map_err(|e| e.with_status(status))
|
||||
}
|
||||
|
||||
fn context<D: std::fmt::Display + Send + Sync + 'static>(self, context: D) -> Result<T, Error> {
|
||||
self.map_err(|e| e.context(context))
|
||||
}
|
||||
|
||||
fn realize(self) -> Result<T, Error> {
|
||||
self.map_err(|e| e.realize())
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: Into<eyre::Error>> From<E> for Error {
|
||||
#[track_caller]
|
||||
fn from(value: E) -> Self {
|
||||
Self::new(StatusCode::INTERNAL_SERVER_ERROR, value)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error {
|
||||
#[track_caller]
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}: {:?}", self.status, self.error)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for Error {
|
||||
#[track_caller]
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
(self.status, format!("{}", self.error)).into_response()
|
||||
}
|
||||
}
|
|
@ -1,22 +1,49 @@
|
|||
// Used so that vespid_macros::component can be used here, as it uses ::vespid
|
||||
extern crate self as vespid;
|
||||
|
||||
mod escape_attribute;
|
||||
use std::future::Future;
|
||||
|
||||
pub use escape_attribute::*;
|
||||
|
||||
#[doc(hidden)]
|
||||
pub mod support;
|
||||
pub use support::*;
|
||||
|
||||
mod render;
|
||||
pub use render::Render;
|
||||
|
||||
mod render_adapter;
|
||||
pub use render_adapter::*;
|
||||
|
||||
mod context;
|
||||
pub use context::*;
|
||||
|
||||
mod text;
|
||||
pub use text::*;
|
||||
pub mod axum;
|
||||
pub mod context;
|
||||
pub mod error;
|
||||
pub mod oco;
|
||||
pub mod render;
|
||||
pub mod render_adapter;
|
||||
|
||||
static ID_COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
|
||||
pub fn next_id() -> usize {
|
||||
ID_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub async fn collect_fragments<Fut: Future<Output = String>>(
|
||||
fragments: impl Iterator<Item = Fut>,
|
||||
) -> String {
|
||||
futures_util::future::join_all(fragments).await.join("")
|
||||
}
|
||||
|
||||
pub async fn try_collect_fragments<Fut: Future<Output = Result<String, E>>, E>(
|
||||
fragments: impl Iterator<Item = Fut>,
|
||||
) -> Result<String, E> {
|
||||
futures_util::future::try_join_all(fragments)
|
||||
.await
|
||||
.map(|v| v.join(""))
|
||||
}
|
||||
|
||||
pub async fn async_transpose<Fut: Future<Output = O>, O>(future: Option<Fut>) -> Option<O> {
|
||||
if let Some(future) = future {
|
||||
Some(future.await)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub use vespid_macros::*;
|
||||
|
||||
|
@ -26,7 +53,9 @@ pub extern crate html_escape;
|
|||
pub extern crate typed_builder;
|
||||
|
||||
pub mod prelude {
|
||||
pub use {html_escape, typed_builder};
|
||||
pub use html_escape;
|
||||
pub use typed_builder;
|
||||
pub use vespid_macros::*;
|
||||
pub use crate::{context::*, render::Render, render_adapter::*, text::*};
|
||||
|
||||
pub use crate::{context::*, oco::*, render::Render, render_adapter::*, collect_fragments, try_collect_fragments, async_transpose};
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
use core::fmt;
|
||||
|
||||
use crate::Render;
|
||||
use crate::render::Render;
|
||||
|
||||
pub struct RenderDisplay<T>(pub T);
|
||||
|
||||
|
|
2
watch.sh
2
watch.sh
|
@ -1,3 +1,3 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
cargo watch -w src -w magic -w vespid -w Cargo.toml -s "bun tailwind:build; cargo run"
|
||||
cargo watch -w src -w magic -w vespid -w Cargo.toml -s "bun tailwind:build; env RUST_LOG=crusto=trace,info cargo run infra/config.toml"
|
||||
|
|
Loading…
Reference in a new issue