From ff913e3dc0f652fd6af2c71de72be91b73bca095 Mon Sep 17 00:00:00 2001 From: Przemyslaw Olszewski Date: Sun, 30 Aug 2026 21:58:23 +0200 Subject: [PATCH] feat: Add native workspace interface --- diff --git a/README.md b/README.md --- a/README.md +++ b/README.md @@ -1,28 +1,26 @@ -# SynCode Front - -The Leptos client for [`syncode-identity`](https://syncode.sh/syncode/identity): -login, account, and linked-provider management. CSR/WASM, built with -[Trunk](https://trunkrs.dev), styled with Tailwind and per-page SCSS. It talks -to `syncode-identity`'s GraphQL API and issues the same session cookie the -forge's Go bridge already reads, so one login works in both the new front and -the old Go UI. - -## Development - -```sh -cargo fmt --check -cargo clippy --workspace --all-targets --all-features -- -D warnings -trunk build -trunk serve -``` - -## Deployment - -Static output (`dist/`), deployed to Cloudflare Pages — `new.dev.syncode.sh` -tracks `develop`, `new.syncode.sh` tracks `main`. `dev.syncode.sh` as a -literal DNS suffix (not `dev.new.syncode.sh`) matters: the session cookie's -`Domain` is `dev.syncode.sh` on dev, so it stays isolated from prod. - -## License - -MIT. See [LICENSE](LICENSE). +# SynCode Front + +The Leptos client for SynCode Identity, Repository and Collaboration. It +provides login and account management, repository browsing, and native +Workspace, Project, Docs, Version and Issue flows. CSR/WASM is built with +[Trunk](https://trunkrs.dev) and styled with Tailwind and per-page SCSS. + +## Development + +```sh +cargo fmt --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +trunk build +trunk serve +``` + +## Deployment + +Static output (`dist/`), deployed to Cloudflare Pages — `new.dev.syncode.sh` +tracks `develop`, `new.syncode.sh` tracks `main`. `dev.syncode.sh` as a +literal DNS suffix (not `dev.new.syncode.sh`) matters: the session cookie's +`Domain` is `dev.syncode.sh` on dev, so it stays isolated from prod. + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/src/app.rs b/src/app.rs --- a/src/app.rs +++ b/src/app.rs @@ -1,65 +1,69 @@ -use leptos::prelude::*; -use leptos_meta::{provide_meta_context, Title}; -use leptos_router::components::{Route, Router, Routes}; -use leptos_router::{ParamSegment, StaticSegment}; - -use crate::pages::account::Account; -use crate::pages::account_emails::AccountEmails; -use crate::pages::account_security::AccountSecurity; -use crate::pages::home::Home; -use crate::pages::login::Login; -use crate::pages::org_agents::OrgAgents; -use crate::pages::org_teams::OrgTeams; -use crate::pages::profile::Profile; -use crate::pages::repository::{NewRepository, RepositoryPage}; -use crate::pages::repository_code::{ - RepositoryBlame, RepositoryBlob, RepositoryCode, RepositoryCodeDefault, - RepositoryCommitsDefault, -}; -use crate::pages::repository_commits::{RepositoryCommit, RepositoryCommits}; -use crate::pages::repository_compare::RepositoryCompare; -use crate::pages::repository_fork::RepositoryFork; -use crate::pages::repository_protection::RepositoryProtection; -use crate::pages::repository_refs::{RepositoryBranches, RepositoryTags}; -use crate::pages::repository_settings::RepositorySettings; - -#[component] -pub fn App() -> impl IntoView { - provide_meta_context(); - - view! { - - <Router> - <main> - <Routes fallback=|| "404 not found"> - <Route path=StaticSegment("") view=Home/> - <Route path=StaticSegment("login") view=Login/> - <Route path=StaticSegment("account") view=Account/> - <Route path=(StaticSegment("account"), StaticSegment("emails")) view=AccountEmails/> - <Route path=(StaticSegment("account"), StaticSegment("security")) view=AccountSecurity/> - <Route path=(StaticSegment("org"), ParamSegment("slug"), StaticSegment("teams")) view=OrgTeams/> - <Route path=(StaticSegment("org"), ParamSegment("slug"), StaticSegment("agents")) view=OrgAgents/> - <Route path=(StaticSegment("repo"), StaticSegment("new")) view=NewRepository/> - <Route path=(ParamSegment("owner"), ParamSegment("repository")) view=RepositoryPage/> - <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("code")) view=RepositoryCodeDefault/> - <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("code"), ParamSegment("revision")) view=RepositoryCode/> - <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("blob"), ParamSegment("revision")) view=RepositoryBlob/> - <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("blame"), ParamSegment("revision")) view=RepositoryBlame/> - <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("commits")) view=RepositoryCommitsDefault/> - <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("commits"), ParamSegment("revision")) view=RepositoryCommits/> - <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("changeset"), ParamSegment("object_id")) view=RepositoryCommit/> - <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("branches")) view=RepositoryBranches/> - <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("tags")) view=RepositoryTags/> - <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("compare")) view=RepositoryCompare/> - <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("settings")) view=RepositorySettings/> - <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("settings"), StaticSegment("protection")) view=RepositoryProtection/> - <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("fork")) view=RepositoryFork/> - // Catch-all profile route — must stay last, or it would - // shadow every static route above it (e.g. /login would - // match as owner="login"). - <Route path=ParamSegment("owner") view=Profile/> - </Routes> - </main> - </Router> - } -} +use leptos::prelude::*; +use leptos_meta::{provide_meta_context, Title}; +use leptos_router::components::{Route, Router, Routes}; +use leptos_router::{ParamSegment, StaticSegment}; + +use crate::pages::account::Account; +use crate::pages::account_emails::AccountEmails; +use crate::pages::account_security::AccountSecurity; +use crate::pages::home::Home; +use crate::pages::login::Login; +use crate::pages::org_agents::OrgAgents; +use crate::pages::org_teams::OrgTeams; +use crate::pages::profile::Profile; +use crate::pages::repository::{NewRepository, RepositoryPage}; +use crate::pages::repository_code::{ + RepositoryBlame, RepositoryBlob, RepositoryCode, RepositoryCodeDefault, + RepositoryCommitsDefault, +}; +use crate::pages::repository_commits::{RepositoryCommit, RepositoryCommits}; +use crate::pages::repository_compare::RepositoryCompare; +use crate::pages::repository_fork::RepositoryFork; +use crate::pages::repository_protection::RepositoryProtection; +use crate::pages::repository_refs::{RepositoryBranches, RepositoryTags}; +use crate::pages::repository_settings::RepositorySettings; +use crate::pages::workspace::WorkspacePage; +use crate::pages::workspaces::Workspaces; + +#[component] +pub fn App() -> impl IntoView { + provide_meta_context(); + + view! { + <Title text="SynCode"/> + <Router> + <main> + <Routes fallback=|| "404 not found"> + <Route path=StaticSegment("") view=Home/> + <Route path=StaticSegment("login") view=Login/> + <Route path=StaticSegment("account") view=Account/> + <Route path=(StaticSegment("account"), StaticSegment("emails")) view=AccountEmails/> + <Route path=(StaticSegment("account"), StaticSegment("security")) view=AccountSecurity/> + <Route path=(StaticSegment("org"), ParamSegment("slug"), StaticSegment("teams")) view=OrgTeams/> + <Route path=(StaticSegment("org"), ParamSegment("slug"), StaticSegment("agents")) view=OrgAgents/> + <Route path=(StaticSegment("repo"), StaticSegment("new")) view=NewRepository/> + <Route path=StaticSegment("workspaces") view=Workspaces/> + <Route path=(StaticSegment("workspaces"), ParamSegment("id")) view=WorkspacePage/> + <Route path=(ParamSegment("owner"), ParamSegment("repository")) view=RepositoryPage/> + <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("code")) view=RepositoryCodeDefault/> + <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("code"), ParamSegment("revision")) view=RepositoryCode/> + <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("blob"), ParamSegment("revision")) view=RepositoryBlob/> + <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("blame"), ParamSegment("revision")) view=RepositoryBlame/> + <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("commits")) view=RepositoryCommitsDefault/> + <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("commits"), ParamSegment("revision")) view=RepositoryCommits/> + <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("changeset"), ParamSegment("object_id")) view=RepositoryCommit/> + <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("branches")) view=RepositoryBranches/> + <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("tags")) view=RepositoryTags/> + <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("compare")) view=RepositoryCompare/> + <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("settings")) view=RepositorySettings/> + <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("settings"), StaticSegment("protection")) view=RepositoryProtection/> + <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("fork")) view=RepositoryFork/> + // Catch-all profile route — must stay last, or it would + // shadow every static route above it (e.g. /login would + // match as owner="login"). + <Route path=ParamSegment("owner") view=Profile/> + </Routes> + </main> + </Router> + } +} diff --git a/src/graphql.rs b/src/graphql.rs --- a/src/graphql.rs +++ b/src/graphql.rs @@ -1,108 +1,150 @@ -use gloo_net::http::Request; -use web_sys::RequestCredentials; - -pub mod schema { - cynic::use_schema!("schema.graphql"); -} - -#[derive(Clone, Debug, cynic::Scalar)] -#[cynic(schema_module = "schema")] -pub struct DateTime(pub String); - -impl std::fmt::Display for DateTime { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.0.fmt(formatter) - } -} - -#[derive(Clone, Debug, cynic::Scalar)] -#[cynic(schema_module = "schema")] -pub struct NaiveDate(pub String); - -impl std::ops::Deref for NaiveDate { - type Target = str; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -pub mod repo_schema { - cynic::use_schema!("repo-schema.graphql"); -} - -pub fn identity_origin() -> String { - let window = web_sys::window().expect("SynCode front requires a browser window"); - let hostname = window - .location() - .hostname() - .expect("SynCode front requires a readable browser hostname"); - if hostname == "localhost" || hostname == "127.0.0.1" { - return "https://identity.dev.syncode.sh".to_owned(); - } - match hostname.strip_prefix("new.") { - Some(rest) => format!("https://identity.{rest}"), - None => format!("https://identity.{hostname}"), - } -} - -pub async fn typed_graphql<T, V>(operation: cynic::Operation<T, V>) -> Result<T, String> -where - T: serde::de::DeserializeOwned, - V: serde::Serialize, -{ - let response = Request::post(&format!("{}/graphql", identity_origin())) - .credentials(RequestCredentials::Include) - .header("content-type", "application/json") - .json(&operation) - .map_err(|error| error.to_string())? - .send() - .await - .map_err(|error| error.to_string())?; - let response: cynic::GraphQlResponse<T> = - response.json().await.map_err(|error| error.to_string())?; - if let Some(message) = response.errors.into_iter().flatten().next() { - return Err(message.message); - } - response - .data - .ok_or_else(|| "the response had no data".to_owned()) -} - -pub fn repo_origin() -> String { - let window = web_sys::window().expect("SynCode front requires a browser window"); - let hostname = window - .location() - .hostname() - .expect("SynCode front requires a readable browser hostname"); - if hostname == "localhost" || hostname == "127.0.0.1" { - return "http://127.0.0.1:8201".to_owned(); - } - match hostname.strip_prefix("new.") { - Some(rest) => format!("https://repo.{rest}"), - None => format!("https://repo.{hostname}"), - } -} - -pub async fn typed_repo_graphql<T, V>(operation: cynic::Operation<T, V>) -> Result<T, String> -where - T: serde::de::DeserializeOwned, - V: serde::Serialize, -{ - let response = Request::post(&format!("{}/graphql", repo_origin())) - .credentials(RequestCredentials::Include) - .header("content-type", "application/json") - .json(&operation) - .map_err(|error| error.to_string())? - .send() - .await - .map_err(|error| error.to_string())?; - let response: cynic::GraphQlResponse<T> = - response.json().await.map_err(|error| error.to_string())?; - if let Some(message) = response.errors.into_iter().flatten().next() { - return Err(message.message); - } - response - .data - .ok_or_else(|| "the response had no data".to_owned()) -} +use gloo_net::http::Request; +use web_sys::RequestCredentials; + +pub mod schema { + cynic::use_schema!("schema.graphql"); +} + +#[derive(Clone, Debug, cynic::Scalar)] +#[cynic(schema_module = "schema")] +pub struct DateTime(pub String); + +impl std::fmt::Display for DateTime { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(formatter) + } +} + +#[derive(Clone, Debug, cynic::Scalar)] +#[cynic(schema_module = "schema")] +pub struct NaiveDate(pub String); + +impl std::ops::Deref for NaiveDate { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +pub mod repo_schema { + cynic::use_schema!("repo-schema.graphql"); +} + +pub mod collab_schema { + cynic::use_schema!("collab-schema.graphql"); +} + +pub fn collab_origin() -> String { + let window = web_sys::window().expect("SynCode front requires a browser window"); + let hostname = window + .location() + .hostname() + .expect("SynCode front requires a readable browser hostname"); + if hostname == "localhost" || hostname == "127.0.0.1" { + return "https://dev.syncode.sh/collaboration/graphql".to_owned(); + } + match hostname.strip_prefix("new.") { + Some(rest) => format!("https://{rest}/collaboration/graphql"), + None => format!("https://{hostname}/collaboration/graphql"), + } +} + +pub async fn typed_collab_graphql<T, V>(operation: cynic::Operation<T, V>) -> Result<T, String> +where + T: serde::de::DeserializeOwned, + V: serde::Serialize, +{ + let response = Request::post(&collab_origin()) + .credentials(RequestCredentials::Include) + .header("content-type", "application/json") + .json(&operation) + .map_err(|error| error.to_string())? + .send() + .await + .map_err(|error| error.to_string())?; + let response: cynic::GraphQlResponse<T> = + response.json().await.map_err(|error| error.to_string())?; + if let Some(message) = response.errors.into_iter().flatten().next() { + return Err(message.message); + } + response + .data + .ok_or_else(|| "the response had no data".to_owned()) +} + +pub fn identity_origin() -> String { + let window = web_sys::window().expect("SynCode front requires a browser window"); + let hostname = window + .location() + .hostname() + .expect("SynCode front requires a readable browser hostname"); + if hostname == "localhost" || hostname == "127.0.0.1" { + return "https://identity.dev.syncode.sh".to_owned(); + } + match hostname.strip_prefix("new.") { + Some(rest) => format!("https://identity.{rest}"), + None => format!("https://identity.{hostname}"), + } +} + +pub async fn typed_graphql<T, V>(operation: cynic::Operation<T, V>) -> Result<T, String> +where + T: serde::de::DeserializeOwned, + V: serde::Serialize, +{ + let response = Request::post(&format!("{}/graphql", identity_origin())) + .credentials(RequestCredentials::Include) + .header("content-type", "application/json") + .json(&operation) + .map_err(|error| error.to_string())? + .send() + .await + .map_err(|error| error.to_string())?; + let response: cynic::GraphQlResponse<T> = + response.json().await.map_err(|error| error.to_string())?; + if let Some(message) = response.errors.into_iter().flatten().next() { + return Err(message.message); + } + response + .data + .ok_or_else(|| "the response had no data".to_owned()) +} + +pub fn repo_origin() -> String { + let window = web_sys::window().expect("SynCode front requires a browser window"); + let hostname = window + .location() + .hostname() + .expect("SynCode front requires a readable browser hostname"); + if hostname == "localhost" || hostname == "127.0.0.1" { + return "http://127.0.0.1:8201".to_owned(); + } + match hostname.strip_prefix("new.") { + Some(rest) => format!("https://repo.{rest}"), + None => format!("https://repo.{hostname}"), + } +} + +pub async fn typed_repo_graphql<T, V>(operation: cynic::Operation<T, V>) -> Result<T, String> +where + T: serde::de::DeserializeOwned, + V: serde::Serialize, +{ + let response = Request::post(&format!("{}/graphql", repo_origin())) + .credentials(RequestCredentials::Include) + .header("content-type", "application/json") + .json(&operation) + .map_err(|error| error.to_string())? + .send() + .await + .map_err(|error| error.to_string())?; + let response: cynic::GraphQlResponse<T> = + response.json().await.map_err(|error| error.to_string())?; + if let Some(message) = response.errors.into_iter().flatten().next() { + return Err(message.message); + } + response + .data + .ok_or_else(|| "the response had no data".to_owned()) +} diff --git a/src/main.rs b/src/main.rs --- a/src/main.rs +++ b/src/main.rs @@ -1,23 +1,25 @@ -mod app; -mod branch_protection; -mod components; -mod graphql; -mod heatmap; -mod identity; -mod org; -mod pages; -mod platform_agents; -mod repo; -mod repo_browser; -mod repo_insights; -mod repo_mutations; -mod repo_objects; -mod repo_read; - -use app::App; - -fn main() { - console_error_panic_hook::set_once(); - _ = console_log::init_with_level(log::Level::Warn); - leptos::mount::mount_to_body(App); -} +mod app; +mod branch_protection; +mod collab; +mod collab_write; +mod components; +mod graphql; +mod heatmap; +mod identity; +mod org; +mod pages; +mod platform_agents; +mod repo; +mod repo_browser; +mod repo_insights; +mod repo_mutations; +mod repo_objects; +mod repo_read; + +use app::App; + +fn main() { + console_error_panic_hook::set_once(); + _ = console_log::init_with_level(log::Level::Warn); + leptos::mount::mount_to_body(App); +} diff --git a/src/pages/home.rs b/src/pages/home.rs --- a/src/pages/home.rs +++ b/src/pages/home.rs @@ -1,133 +1,138 @@ -use leptos::prelude::*; -use leptos_router::components::A; - -use crate::components::logo::Ring; -use crate::components::nav::Nav; -use crate::identity::fetch_me; - -fn forge_url() -> String { - let host = web_sys::window() - .expect("SynCode home page requires a browser window") - .location() - .host() - .expect("SynCode home page requires a readable host"); - forge_url_for_host(&host) - .unwrap_or_else(|| panic!("unsupported SynCode front host: {host}")) - .to_owned() -} - -fn forge_url_for_host(host: &str) -> Option<&'static str> { - match host { - "new.syncode.sh" => Some("https://syncode.sh"), - "new.dev.syncode.sh" => Some("https://dev.syncode.sh"), - "localhost" | "127.0.0.1" => Some("https://dev.syncode.sh"), - host if host.starts_with("localhost:") || host.starts_with("127.0.0.1:") => { - Some("https://dev.syncode.sh") - } - _ => None, - } -} - -#[component] -pub fn Home() -> impl IntoView { - let me = LocalResource::new(fetch_me); - let signed_in = move || { - matches!( - me.get().map(|wrapped| (*wrapped).clone()), - Some(Ok(Some(_))) - ) - }; - - view! { - <div class="relative flex min-h-screen flex-col"> - <div class="relative z-10 bg-white"> - <Nav> - {move || { - if signed_in() { - view! { - <A href="/account" attr:class="text-sm font-medium hover:text-primary"> - "Account" - </A> - } - } else { - view! { - <A href="/login" attr:class="text-sm font-medium hover:text-primary"> - "Sign in" - </A> - } - } - }} - </Nav> - </div> - - <div class="relative flex flex-1"> - <div class="absolute inset-y-0 left-1/2 hidden w-px bg-ink/90 md:block"></div> - - <div class="relative z-10 flex w-full flex-col justify-center bg-white px-10 py-10 md:w-1/2 md:px-20"> - <div class="max-w-lg"> - <h1 class="text-6xl font-bold tracking-tight text-ink md:text-7xl">"SynCode"</h1> - <p class="mt-6 max-w-sm text-lg text-ink/60"> - "The open-source federated code forge." - </p> - <div class="mt-10 flex items-center gap-6"> - {move || { - let (href, label) = if signed_in() { - ("/account", "Go to account") - } else { - ("/login", "Sign in") - }; - view! { - <A - href=href - attr:class="rounded-lg bg-primary px-6 py-3 text-sm font-medium text-white no-underline hover:opacity-90" - > - {label} - </A> - } - }} - <a - href=forge_url() - class="text-sm font-medium text-ink/70 no-underline hover:text-ink" - > - "Explore the forge →" - </a> - </div> - </div> - - <div class="pointer-events-none absolute top-1/2 right-0 hidden -translate-y-1/2 translate-x-1/2 flex-col items-center gap-3 md:flex"> - <div class="size-7 rotate-45 bg-tertiary"></div> - <div class="h-6 w-12 bg-tertiary"></div> - <div class="size-7 rotate-45 bg-tertiary"></div> - </div> - </div> - - <div class="relative hidden md:block md:w-1/2"> - <div class="hero-art"></div> - <Ring class="pointer-events-none absolute top-1/2 left-0 z-20 h-[85%] w-auto -translate-x-1/3 -translate-y-1/2 text-white" /> - </div> - </div> - </div> - } -} - -#[cfg(test)] -mod tests { - use super::forge_url_for_host; - - #[test] - fn forge_link_matches_the_front_environment() { - assert_eq!( - forge_url_for_host("new.syncode.sh"), - Some("https://syncode.sh") - ); - assert_eq!( - forge_url_for_host("new.dev.syncode.sh"), - Some("https://dev.syncode.sh") - ); - assert_eq!( - forge_url_for_host("localhost:8180"), - Some("https://dev.syncode.sh") - ); - assert_eq!(forge_url_for_host("unexpected.example"), None); - } -} +use leptos::prelude::*; +use leptos_router::components::A; + +use crate::components::logo::Ring; +use crate::components::nav::Nav; +use crate::identity::fetch_me; + +fn forge_url() -> String { + let host = web_sys::window() + .expect("SynCode home page requires a browser window") + .location() + .host() + .expect("SynCode home page requires a readable host"); + forge_url_for_host(&host) + .unwrap_or_else(|| panic!("unsupported SynCode front host: {host}")) + .to_owned() +} + +fn forge_url_for_host(host: &str) -> Option<&'static str> { + match host { + "new.syncode.sh" => Some("https://syncode.sh"), + "new.dev.syncode.sh" => Some("https://dev.syncode.sh"), + "localhost" | "127.0.0.1" => Some("https://dev.syncode.sh"), + host if host.starts_with("localhost:") || host.starts_with("127.0.0.1:") => { + Some("https://dev.syncode.sh") + } + _ => None, + } +} + +#[component] +pub fn Home() -> impl IntoView { + let me = LocalResource::new(fetch_me); + let signed_in = move || { + matches!( + me.get().map(|wrapped| (*wrapped).clone()), + Some(Ok(Some(_))) + ) + }; + + view! { + <div class="relative flex min-h-screen flex-col"> + <div class="relative z-10 bg-white"> + <Nav> + {move || { + if signed_in() { + view! { + <div class="flex items-center gap-5"> + <A href="/workspaces" attr:class="text-sm font-medium hover:text-primary"> + "Workspaces" + </A> + <A href="/account" attr:class="text-sm font-medium hover:text-primary"> + "Account" + </A> + </div> + }.into_any() + } else { + view! { + <A href="/login" attr:class="text-sm font-medium hover:text-primary"> + "Sign in" + </A> + }.into_any() + } + }} + </Nav> + </div> + + <div class="relative flex flex-1"> + <div class="absolute inset-y-0 left-1/2 hidden w-px bg-ink/90 md:block"></div> + + <div class="relative z-10 flex w-full flex-col justify-center bg-white px-10 py-10 md:w-1/2 md:px-20"> + <div class="max-w-lg"> + <h1 class="text-6xl font-bold tracking-tight text-ink md:text-7xl">"SynCode"</h1> + <p class="mt-6 max-w-sm text-lg text-ink/60"> + "The open-source federated code forge." + </p> + <div class="mt-10 flex items-center gap-6"> + {move || { + let (href, label) = if signed_in() { + ("/workspaces", "Open workspaces") + } else { + ("/login", "Sign in") + }; + view! { + <A + href=href + attr:class="rounded-lg bg-primary px-6 py-3 text-sm font-medium text-white no-underline hover:opacity-90" + > + {label} + </A> + } + }} + <a + href=forge_url() + class="text-sm font-medium text-ink/70 no-underline hover:text-ink" + > + "Explore the forge →" + </a> + </div> + </div> + + <div class="pointer-events-none absolute top-1/2 right-0 hidden -translate-y-1/2 translate-x-1/2 flex-col items-center gap-3 md:flex"> + <div class="size-7 rotate-45 bg-tertiary"></div> + <div class="h-6 w-12 bg-tertiary"></div> + <div class="size-7 rotate-45 bg-tertiary"></div> + </div> + </div> + + <div class="relative hidden md:block md:w-1/2"> + <div class="hero-art"></div> + <Ring class="pointer-events-none absolute top-1/2 left-0 z-20 h-[85%] w-auto -translate-x-1/3 -translate-y-1/2 text-white" /> + </div> + </div> + </div> + } +} + +#[cfg(test)] +mod tests { + use super::forge_url_for_host; + + #[test] + fn forge_link_matches_the_front_environment() { + assert_eq!( + forge_url_for_host("new.syncode.sh"), + Some("https://syncode.sh") + ); + assert_eq!( + forge_url_for_host("new.dev.syncode.sh"), + Some("https://dev.syncode.sh") + ); + assert_eq!( + forge_url_for_host("localhost:8180"), + Some("https://dev.syncode.sh") + ); + assert_eq!(forge_url_for_host("unexpected.example"), None); + } +} diff --git a/src/pages/mod.rs b/src/pages/mod.rs --- a/src/pages/mod.rs +++ b/src/pages/mod.rs @@ -1,18 +1,21 @@ -pub mod account; -pub mod account_emails; -pub mod account_security; -pub mod home; -pub mod login; -mod org_agent_forms; -pub mod org_agents; -pub mod org_teams; -pub mod profile; -pub mod repository; -pub mod repository_code; -pub mod repository_commits; -pub mod repository_compare; -pub mod repository_fork; -pub mod repository_protection; -pub mod repository_refs; -pub mod repository_settings; -mod repository_shell; +pub mod account; +pub mod account_emails; +pub mod account_security; +pub mod home; +pub mod login; +mod org_agent_forms; +pub mod org_agents; +pub mod org_teams; +pub mod profile; +pub mod repository; +pub mod repository_code; +pub mod repository_commits; +pub mod repository_compare; +pub mod repository_fork; +pub mod repository_protection; +pub mod repository_refs; +pub mod repository_settings; +mod repository_shell; +pub mod workspace; +mod workspace_forms; +pub mod workspaces; diff --git a/collab-schema.graphql b/collab-schema.graphql --- /dev/null +++ b/collab-schema.graphql @@ -1,0 +1,158 @@ +input AttachRepositoryInput { + workspaceId: ID! + owner: String! + name: String! + role: RepositoryRole! + branch: String! +} + +type Checkout { + workspace: Workspace! + environment: String! + repositories: [CheckoutRepository!]! + omittedRepositories: Int! +} + +type CheckoutRepository { + repositoryId: ID! + owner: String! + name: String! + role: RepositoryRole! + branch: String! +} + +input ConfigureEnvironmentInput { + workspaceId: ID! + name: String! + defaultSourceBranch: String! + infraBranch: String +} + +input CreateIssueInput { + projectId: ID! + title: String! + description: String! = "" + priority: IssuePriority! +} + +input CreateProjectInput { + workspaceId: ID! + key: String! + name: String! + slug: String! + description: String! = "" + kind: ProjectKind! +} + +input CreateVersionInput { + projectId: ID! + name: String! + slug: String! +} + +input CreateWorkspaceInput { + ownerKind: OwnerKind! + ownerId: ID! + name: String! + slug: String! + defaultEnvironment: String! + defaultSourceBranch: String! +} + +type Document { + id: ID! + workspaceId: ID! + projectId: ID + path: String! + revisionId: ID! + content: String! +} + +type Environment { + name: String! + defaultSourceBranch: String! + infraBranch: String +} + +type Issue { + id: ID! + projectId: ID! + number: Int! + title: String! + description: String! + statusId: ID! + typeId: ID! + priority: IssuePriority! + version: Int! +} + +enum IssuePriority { NONE LOW NORMAL HIGH URGENT } + +type Mutation { + createWorkspace(input: CreateWorkspaceInput!): Workspace! + attachRepository(input: AttachRepositoryInput!): Boolean! + configureEnvironment(input: ConfigureEnvironmentInput!): Environment! + createProject(input: CreateProjectInput!): Project! + putDocument(input: PutDocumentInput!): Document! + createIssue(input: CreateIssueInput!): Issue! + createVersion(input: CreateVersionInput!): Version! +} + +enum OwnerKind { USER ORGANIZATION } + +type Project { + id: ID! + workspaceId: ID! + key: String! + name: String! + slug: String! + description: String! + kind: ProjectKind! + lifecycle: String! + version: Int! +} + +enum ProjectKind { CONTINUOUS FINITE } + +input PutDocumentInput { + workspaceId: ID! + projectId: ID + path: String! + content: String! + baseRevisionId: ID +} + +type Query { + version: String! + envelopeProtocolVersion: Int! + workspaces: [Workspace!]! + workspace(id: ID!): Workspace! + checkout(workspaceId: ID!, environment: String, partial: Boolean): Checkout! + projects(workspaceId: ID!): [Project!]! + environments(workspaceId: ID!): [Environment!]! + documents(workspaceId: ID!, projectId: ID): [Document!]! + issues(projectId: ID!): [Issue!]! + versions(projectId: ID!): [Version!]! +} + +enum RepositoryRole { SOURCE INFRA } + +type Version { + id: ID! + projectId: ID! + name: String! + slug: String! + state: String! +} + +type Workspace { + id: ID! + ownerKind: OwnerKind! + ownerId: ID! + name: String! + slug: String! + defaultEnvironment: String! + version: Int! +} + +schema { query: Query mutation: Mutation } diff --git a/src/collab.rs b/src/collab.rs --- /dev/null +++ b/src/collab.rs @@ -1,0 +1,299 @@ +use cynic::{MutationBuilder, QueryBuilder}; + +use crate::graphql::collab_schema as schema; +use crate::graphql::typed_collab_graphql; + +#[derive(Clone, cynic::Enum, PartialEq, Eq)] +#[cynic(schema_path = "collab-schema.graphql")] +pub enum OwnerKind { + User, + Organization, +} + +#[derive(Clone, cynic::Enum, PartialEq, Eq)] +#[cynic(schema_path = "collab-schema.graphql")] +pub enum RepositoryRole { + Source, + Infra, +} + +#[derive(Clone, cynic::Enum, PartialEq, Eq)] +#[cynic(schema_path = "collab-schema.graphql")] +pub enum ProjectKind { + Continuous, + Finite, +} + +#[derive(Clone, cynic::Enum, PartialEq, Eq)] +#[cynic(schema_path = "collab-schema.graphql")] +pub enum IssuePriority { + None, + Low, + Normal, + High, + Urgent, +} + +#[derive(Clone, cynic::QueryFragment)] +#[cynic(schema_path = "collab-schema.graphql")] +pub struct Workspace { + pub id: cynic::Id, + pub name: String, + pub slug: String, + pub default_environment: String, +} + +#[derive(Clone, cynic::QueryFragment)] +#[cynic(schema_path = "collab-schema.graphql")] +pub struct CheckoutRepository { + pub owner: String, + pub name: String, + pub role: RepositoryRole, + pub branch: String, +} + +#[derive(Clone, cynic::QueryFragment)] +#[cynic(schema_path = "collab-schema.graphql")] +pub struct Checkout { + pub repositories: Vec<CheckoutRepository>, +} + +#[derive(Clone, cynic::QueryFragment)] +#[cynic(schema_path = "collab-schema.graphql")] +pub struct Environment { + pub name: String, + pub default_source_branch: String, + pub infra_branch: Option<String>, +} + +#[derive(Clone, cynic::QueryFragment)] +#[cynic(schema_path = "collab-schema.graphql")] +pub struct Project { + pub id: cynic::Id, + pub key: String, + pub name: String, + pub slug: String, + pub description: String, + pub lifecycle: String, +} + +#[derive(Clone, cynic::QueryFragment)] +#[cynic(schema_path = "collab-schema.graphql")] +pub struct Document { + pub path: String, + pub revision_id: cynic::Id, + pub content: String, +} + +#[derive(Clone, cynic::QueryFragment)] +#[cynic(schema_path = "collab-schema.graphql")] +pub struct Issue { + pub number: i32, + pub title: String, +} + +#[derive(Clone, cynic::QueryFragment)] +#[cynic(schema_path = "collab-schema.graphql")] +pub struct Version { + pub name: String, + pub slug: String, + pub state: String, +} + +#[derive(cynic::QueryFragment)] +#[cynic(schema_path = "collab-schema.graphql", graphql_type = "Query")] +struct WorkspacesResponse { + workspaces: Vec<Workspace>, +} + +pub async fn workspaces() -> Result<Vec<Workspace>, String> { + let response: WorkspacesResponse = typed_collab_graphql(QueryBuilder::build(())).await?; + Ok(response.workspaces) +} + +#[derive(cynic::QueryVariables)] +struct WorkspaceVariables { + id: cynic::Id, + workspace_id: cynic::Id, +} + +#[derive(cynic::QueryFragment)] +#[cynic( + schema_path = "collab-schema.graphql", + graphql_type = "Query", + variables = "WorkspaceVariables" +)] +struct WorkspaceResponse { + #[arguments(id: $id)] + workspace: Workspace, + #[arguments(workspaceId: $workspace_id)] + checkout: Checkout, + #[arguments(workspaceId: $workspace_id)] + environments: Vec<Environment>, + #[arguments(workspaceId: $workspace_id)] + projects: Vec<Project>, + #[arguments(workspaceId: $workspace_id)] + documents: Vec<Document>, +} + +pub async fn workspace( + id: cynic::Id, +) -> Result< + ( + Workspace, + Vec<CheckoutRepository>, + Vec<Environment>, + Vec<Project>, + Vec<Document>, + ), + String, +> { + let response: WorkspaceResponse = + typed_collab_graphql(QueryBuilder::build(WorkspaceVariables { + id: id.clone(), + workspace_id: id, + })) + .await?; + Ok(( + response.workspace, + response.checkout.repositories, + response.environments, + response.projects, + response.documents, + )) +} + +#[derive(cynic::QueryVariables)] +struct IssuesVariables { + project_id: cynic::Id, +} + +#[derive(cynic::QueryFragment)] +#[cynic( + schema_path = "collab-schema.graphql", + graphql_type = "Query", + variables = "IssuesVariables" +)] +struct IssuesResponse { + #[arguments(projectId: $project_id)] + issues: Vec<Issue>, +} + +pub async fn issues(project_id: cynic::Id) -> Result<Vec<Issue>, String> { + let response: IssuesResponse = + typed_collab_graphql(QueryBuilder::build(IssuesVariables { project_id })).await?; + Ok(response.issues) +} + +#[derive(cynic::QueryVariables)] +struct VersionsVariables { + project_id: cynic::Id, +} + +#[derive(cynic::QueryFragment)] +#[cynic( + schema_path = "collab-schema.graphql", + graphql_type = "Query", + variables = "VersionsVariables" +)] +struct VersionsResponse { + #[arguments(projectId: $project_id)] + versions: Vec<Version>, +} + +pub async fn versions(project_id: cynic::Id) -> Result<Vec<Version>, String> { + let response: VersionsResponse = + typed_collab_graphql(QueryBuilder::build(VersionsVariables { project_id })).await?; + Ok(response.versions) +} + +#[derive(cynic::InputObject)] +#[cynic(schema_path = "collab-schema.graphql")] +struct CreateWorkspaceInput { + owner_kind: OwnerKind, + owner_id: cynic::Id, + name: String, + slug: String, + default_environment: String, + default_source_branch: String, +} + +#[derive(cynic::QueryVariables)] +struct CreateWorkspaceVariables { + input: CreateWorkspaceInput, +} + +#[derive(cynic::QueryFragment)] +#[cynic( + schema_path = "collab-schema.graphql", + graphql_type = "Mutation", + variables = "CreateWorkspaceVariables" +)] +struct CreateWorkspaceResponse { + #[arguments(input: $input)] + create_workspace: Workspace, +} + +pub async fn create_workspace(owner_id: &str, name: &str, slug: &str) -> Result<Workspace, String> { + let response: CreateWorkspaceResponse = + typed_collab_graphql(MutationBuilder::build(CreateWorkspaceVariables { + input: CreateWorkspaceInput { + owner_kind: OwnerKind::User, + owner_id: cynic::Id::new(owner_id), + name: name.to_owned(), + slug: slug.to_owned(), + default_environment: "development".to_owned(), + default_source_branch: "main".to_owned(), + }, + })) + .await?; + Ok(response.create_workspace) +} + +#[derive(cynic::InputObject)] +#[cynic(schema_path = "collab-schema.graphql")] +struct CreateProjectInput { + workspace_id: cynic::Id, + key: String, + name: String, + slug: String, + description: String, + kind: ProjectKind, +} + +#[derive(cynic::QueryVariables)] +struct CreateProjectVariables { + input: CreateProjectInput, +} + +#[derive(cynic::QueryFragment)] +#[cynic( + schema_path = "collab-schema.graphql", + graphql_type = "Mutation", + variables = "CreateProjectVariables" +)] +struct CreateProjectResponse { + #[arguments(input: $input)] + create_project: Project, +} + +pub async fn create_project( + workspace_id: &str, + key: &str, + name: &str, + slug: &str, +) -> Result<Project, String> { + let response: CreateProjectResponse = + typed_collab_graphql(MutationBuilder::build(CreateProjectVariables { + input: CreateProjectInput { + workspace_id: cynic::Id::new(workspace_id), + key: key.to_owned(), + name: name.to_owned(), + slug: slug.to_owned(), + description: String::new(), + kind: ProjectKind::Continuous, + }, + })) + .await?; + Ok(response.create_project) +} diff --git a/src/collab_write.rs b/src/collab_write.rs --- /dev/null +++ b/src/collab_write.rs @@ -1,0 +1,222 @@ +use cynic::MutationBuilder; + +use crate::collab::{Document, Environment, Issue, IssuePriority, RepositoryRole, Version}; +use crate::graphql::collab_schema as schema; +use crate::graphql::typed_collab_graphql; + +#[derive(cynic::InputObject)] +#[cynic(schema_path = "collab-schema.graphql")] +struct AttachRepositoryInput { + workspace_id: cynic::Id, + owner: String, + name: String, + role: RepositoryRole, + branch: String, +} + +#[derive(cynic::QueryVariables)] +struct AttachRepositoryVariables { + input: AttachRepositoryInput, +} + +#[derive(cynic::QueryFragment)] +#[cynic( + schema_path = "collab-schema.graphql", + graphql_type = "Mutation", + variables = "AttachRepositoryVariables" +)] +struct AttachRepositoryResponse { + #[arguments(input: $input)] + attach_repository: bool, +} + +pub async fn attach_repository( + workspace_id: &str, + owner: &str, + name: &str, + role: RepositoryRole, + branch: &str, +) -> Result<(), String> { + let response: AttachRepositoryResponse = + typed_collab_graphql(MutationBuilder::build(AttachRepositoryVariables { + input: AttachRepositoryInput { + workspace_id: cynic::Id::new(workspace_id), + owner: owner.to_owned(), + name: name.to_owned(), + role, + branch: branch.to_owned(), + }, + })) + .await?; + response + .attach_repository + .then_some(()) + .ok_or_else(|| "repository was not attached".to_owned()) +} + +#[derive(cynic::InputObject)] +#[cynic(schema_path = "collab-schema.graphql")] +struct ConfigureEnvironmentInput { + workspace_id: cynic::Id, + name: String, + default_source_branch: String, + infra_branch: Option<String>, +} + +#[derive(cynic::QueryVariables)] +struct ConfigureEnvironmentVariables { + input: ConfigureEnvironmentInput, +} + +#[derive(cynic::QueryFragment)] +#[cynic( + schema_path = "collab-schema.graphql", + graphql_type = "Mutation", + variables = "ConfigureEnvironmentVariables" +)] +struct ConfigureEnvironmentResponse { + #[arguments(input: $input)] + configure_environment: Environment, +} + +pub async fn configure_environment( + workspace_id: &str, + name: &str, + source_branch: &str, + infra_branch: Option<&str>, +) -> Result<Environment, String> { + let response: ConfigureEnvironmentResponse = + typed_collab_graphql(MutationBuilder::build(ConfigureEnvironmentVariables { + input: ConfigureEnvironmentInput { + workspace_id: cynic::Id::new(workspace_id), + name: name.to_owned(), + default_source_branch: source_branch.to_owned(), + infra_branch: infra_branch.map(str::to_owned), + }, + })) + .await?; + Ok(response.configure_environment) +} + +#[derive(cynic::InputObject)] +#[cynic(schema_path = "collab-schema.graphql")] +struct PutDocumentInput { + workspace_id: cynic::Id, + project_id: Option<cynic::Id>, + path: String, + content: String, + base_revision_id: Option<cynic::Id>, +} + +#[derive(cynic::QueryVariables)] +struct PutDocumentVariables { + input: PutDocumentInput, +} + +#[derive(cynic::QueryFragment)] +#[cynic( + schema_path = "collab-schema.graphql", + graphql_type = "Mutation", + variables = "PutDocumentVariables" +)] +struct PutDocumentResponse { + #[arguments(input: $input)] + put_document: Document, +} + +pub async fn put_workspace_document( + workspace_id: &str, + project_id: Option<&str>, + path: &str, + content: &str, + base_revision_id: Option<&str>, +) -> Result<Document, String> { + let response: PutDocumentResponse = + typed_collab_graphql(MutationBuilder::build(PutDocumentVariables { + input: PutDocumentInput { + workspace_id: cynic::Id::new(workspace_id), + project_id: project_id.map(cynic::Id::new), + path: path.to_owned(), + content: content.to_owned(), + base_revision_id: base_revision_id.map(cynic::Id::new), + }, + })) + .await?; + Ok(response.put_document) +} + +#[derive(cynic::InputObject)] +#[cynic(schema_path = "collab-schema.graphql")] +struct CreateIssueInput { + project_id: cynic::Id, + title: String, + description: String, + priority: IssuePriority, +} + +#[derive(cynic::QueryVariables)] +struct CreateIssueVariables { + input: CreateIssueInput, +} + +#[derive(cynic::QueryFragment)] +#[cynic( + schema_path = "collab-schema.graphql", + graphql_type = "Mutation", + variables = "CreateIssueVariables" +)] +struct CreateIssueResponse { + #[arguments(input: $input)] + create_issue: Issue, +} + +pub async fn create_issue(project_id: &str, title: &str) -> Result<Issue, String> { + let response: CreateIssueResponse = + typed_collab_graphql(MutationBuilder::build(CreateIssueVariables { + input: CreateIssueInput { + project_id: cynic::Id::new(project_id), + title: title.to_owned(), + description: String::new(), + priority: IssuePriority::Normal, + }, + })) + .await?; + Ok(response.create_issue) +} + +#[derive(cynic::InputObject)] +#[cynic(schema_path = "collab-schema.graphql")] +struct CreateVersionInput { + project_id: cynic::Id, + name: String, + slug: String, +} + +#[derive(cynic::QueryVariables)] +struct CreateVersionVariables { + input: CreateVersionInput, +} + +#[derive(cynic::QueryFragment)] +#[cynic( + schema_path = "collab-schema.graphql", + graphql_type = "Mutation", + variables = "CreateVersionVariables" +)] +struct CreateVersionResponse { + #[arguments(input: $input)] + create_version: Version, +} + +pub async fn create_version(project_id: &str, name: &str, slug: &str) -> Result<Version, String> { + let response: CreateVersionResponse = + typed_collab_graphql(MutationBuilder::build(CreateVersionVariables { + input: CreateVersionInput { + project_id: cynic::Id::new(project_id), + name: name.to_owned(), + slug: slug.to_owned(), + }, + })) + .await?; + Ok(response.create_version) +} diff --git a/src/pages/workspace.rs b/src/pages/workspace.rs --- /dev/null +++ b/src/pages/workspace.rs @@ -1,0 +1,105 @@ +use leptos::prelude::*; +use leptos_router::components::A; +use leptos_router::hooks::use_params_map; + +use crate::collab::{issues, versions, workspace, Issue, Project, Version}; +use crate::components::nav::Nav; + +use super::workspace_forms::{ + AttachRepositoryForm, DocumentForm, EnvironmentForm, IssueForm, ProjectForm, VersionForm, +}; + +#[derive(Clone)] +struct WorkspaceData { + workspace: crate::collab::Workspace, + repositories: Vec<crate::collab::CheckoutRepository>, + environments: Vec<crate::collab::Environment>, + projects: Vec<(Project, Vec<Issue>, Vec<Version>)>, + documents: Vec<crate::collab::Document>, +} + +async fn load_workspace(id: &str) -> Result<WorkspaceData, String> { + let (workspace, repositories, environments, projects, documents) = + workspace(cynic::Id::new(id)).await?; + let mut projects_with_issues = Vec::with_capacity(projects.len()); + for project in projects { + let project_issues = issues(project.id.clone()).await?; + let project_versions = versions(project.id.clone()).await?; + projects_with_issues.push((project, project_issues, project_versions)); + } + Ok(WorkspaceData { + workspace, + repositories, + environments, + projects: projects_with_issues, + documents, + }) +} + +#[component] +pub fn WorkspacePage() -> impl IntoView { + let params = use_params_map(); + let refresh = RwSignal::new(0_u64); + let data = LocalResource::new(move || { + refresh.get(); + let id = params.read().get("id").unwrap_or_default(); + async move { load_workspace(&id).await } + }); + + view! { + <div class="flex min-h-screen flex-col bg-paper text-ink"> + <Nav> + <A href="/workspaces" attr:class="text-sm font-medium hover:text-primary">"Workspaces"</A> + <A href="/account" attr:class="text-sm font-medium hover:text-primary">"Account"</A> + </Nav> + <main class="mx-auto w-full max-w-6xl flex-1 px-6 py-12"> + <Suspense fallback=|| view! { <p class="text-ink/60">"Loading workspace…"</p> }> + {move || data.get().map(|result| match &*result { + Ok(data) => { + let workspace = data.workspace.clone(); + let workspace_id = workspace.id.inner().to_owned(); + let projects = data.projects.clone(); + let documents = data.documents.clone(); + let repositories = data.repositories.clone(); + let environments = data.environments.clone(); + view! { + <header class="flex flex-wrap items-end justify-between gap-4 border-b border-ink/10 pb-8"> + <div><p class="font-mono text-sm text-ink/50">{workspace.slug}</p><h1 class="mt-1 text-3xl font-semibold">{workspace.name}</h1></div> + <span class="rounded-full bg-tertiary/20 px-3 py-1 text-xs font-medium">{workspace.default_environment}</span> + </header> + <div class="mt-8 grid gap-8 lg:grid-cols-[1fr_22rem]"> + <div class="space-y-10"> + <section> + <div class="flex items-center justify-between"><h2 class="text-xl font-semibold">"Projects"</h2><span class="text-sm text-ink/50">{projects.len()}</span></div> + <ul class="mt-4 space-y-4">{projects.clone().into_iter().map(|(project, issues, versions)| view! { + <li class="rounded-xl border border-ink/10 bg-white p-5"> + <div class="flex items-center justify-between gap-4"><div><h3 class="font-semibold">{format!("{} · {}", project.key, project.name)}</h3><p class="mt-1 text-sm text-ink/55">{project.description}</p><p class="mt-1 font-mono text-xs text-ink/40">{project.slug}</p></div><span class="text-xs uppercase text-ink/45">{project.lifecycle}</span></div> + <ul class="mt-4 divide-y divide-ink/10 border-t border-ink/10">{issues.into_iter().map(|issue| view! { <li class="flex items-center gap-3 py-3 text-sm"><span class="font-mono text-ink/45">{format!("#{}", issue.number)}</span><span>{issue.title}</span></li> }).collect_view()}</ul> + <IssueForm project_id=project.id.inner().to_owned() refresh=refresh/> + <div class="mt-5 border-t border-ink/10 pt-4"><h4 class="text-xs font-semibold uppercase tracking-wide text-ink/45">"Versions"</h4><ul class="mt-2 flex flex-wrap gap-2">{versions.into_iter().map(|version| view! { <li class="rounded-full bg-ink/5 px-3 py-1 text-xs">{format!("{} ({}) · {}", version.name, version.slug, version.state)}</li> }).collect_view()}</ul><VersionForm project_id=project.id.inner().to_owned() refresh=refresh/></div> + </li> + }).collect_view()}</ul> + </section> + <section> + <div class="flex items-center justify-between"><h2 class="text-xl font-semibold">"Docs"</h2><span class="text-sm text-ink/50">{documents.len()}</span></div> + <ul class="mt-4 space-y-3">{documents.into_iter().map(|document| view! { <li class="rounded-xl border border-ink/10 bg-white p-5"><div class="flex items-center justify-between gap-4"><p class="font-mono text-sm font-semibold">{document.path}</p><span class="font-mono text-xs text-ink/35">{document.revision_id.inner().to_owned()}</span></div><pre class="mt-3 whitespace-pre-wrap text-sm text-ink/70">{document.content}</pre></li> }).collect_view()}</ul> + </section> + </div> + <aside class="space-y-5"> + <section class="rounded-xl border border-ink/10 bg-white p-5"><div class="flex items-center justify-between"><h2 class="font-semibold">"Environments"</h2><span class="text-sm text-ink/50">{environments.len()}</span></div><ul class="mt-3 space-y-2">{environments.into_iter().map(|environment| view! { <li class="rounded border border-ink/10 px-3 py-2 text-sm"><p class="font-medium">{environment.name}</p><p class="mt-1 font-mono text-xs text-ink/45">{format!("source: {} · infra: {}", environment.default_source_branch, environment.infra_branch.unwrap_or_else(|| "not configured".to_owned()))}</p></li> }).collect_view()}</ul></section> + <EnvironmentForm workspace_id=workspace_id.clone() refresh=refresh/> + <section class="rounded-xl border border-ink/10 bg-white p-5"><div class="flex items-center justify-between"><h2 class="font-semibold">"Repositories"</h2><span class="text-sm text-ink/50">{repositories.len()}</span></div><ul class="mt-3 space-y-2">{repositories.into_iter().map(|repository| { let role = match repository.role { crate::collab::RepositoryRole::Source => "source", crate::collab::RepositoryRole::Infra => "infra" }; view! { <li class="rounded border border-ink/10 px-3 py-2 text-sm"><p class="font-medium">{format!("{}/{}", repository.owner, repository.name)}</p><p class="mt-1 font-mono text-xs text-ink/45">{format!("{} · {role}", repository.branch)}</p></li> } }).collect_view()}</ul></section> + <ProjectForm workspace_id=workspace_id.clone() refresh=refresh/> + <DocumentForm workspace_id=workspace_id.clone() refresh=refresh/> + <AttachRepositoryForm workspace_id=workspace_id refresh=refresh/> + </aside> + </div> + }.into_any() + } + Err(message) => view! { <p class="text-red-600">{message.clone()}</p> }.into_any(), + })} + </Suspense> + </main> + </div> + } +} diff --git a/src/pages/workspace_forms.rs b/src/pages/workspace_forms.rs --- /dev/null +++ b/src/pages/workspace_forms.rs @@ -1,0 +1,145 @@ +use leptos::prelude::*; +use leptos::task::spawn_local; + +use crate::collab::create_project; +use crate::collab::RepositoryRole; +use crate::collab_write::{ + attach_repository, configure_environment, create_issue, create_version, put_workspace_document, +}; + +fn submit_result( + result: Result<(), String>, + error: RwSignal<Option<String>>, + pending: RwSignal<bool>, + refresh: RwSignal<u64>, +) { + match result { + Ok(()) => refresh.update(|value| *value += 1), + Err(message) => error.set(Some(message)), + } + pending.set(false); +} + +#[component] +pub fn ProjectForm(workspace_id: String, refresh: RwSignal<u64>) -> impl IntoView { + let key = RwSignal::new(String::new()); + let name = RwSignal::new(String::new()); + let slug = RwSignal::new(String::new()); + let error = RwSignal::new(None::<String>); + let pending = RwSignal::new(false); + view! { + <FormCard title="New project" on_submit=move || { + let workspace_id = workspace_id.clone(); + let key = key.get(); let name = name.get(); let slug = slug.get(); + pending.set(true); error.set(None); + spawn_local(async move { submit_result(create_project(&workspace_id, &key, &name, &slug).await.map(|_| ()), error, pending, refresh); }); + } pending=pending error=error> + <Input label="Key" value=key/><Input label="Name" value=name/><Input label="Slug" value=slug/> + </FormCard> + } +} + +#[component] +pub fn DocumentForm(workspace_id: String, refresh: RwSignal<u64>) -> impl IntoView { + let path = RwSignal::new(String::new()); + let content = RwSignal::new(String::new()); + let project_id = RwSignal::new(String::new()); + let base_revision = RwSignal::new(String::new()); + let error = RwSignal::new(None::<String>); + let pending = RwSignal::new(false); + view! { + <FormCard title="Add document" on_submit=move || { + let workspace_id = workspace_id.clone(); let path = path.get(); let content = content.get(); let project_id = project_id.get(); let base_revision = base_revision.get(); + pending.set(true); error.set(None); + spawn_local(async move { submit_result(put_workspace_document(&workspace_id, (!project_id.is_empty()).then_some(project_id.as_str()), &path, &content, (!base_revision.is_empty()).then_some(base_revision.as_str())).await.map(|_| ()), error, pending, refresh); }); + } pending=pending error=error> + <Input label="Path" value=path/> + <Input label="Project ID (optional)" value=project_id/> + <Input label="Base revision (when editing)" value=base_revision/> + <label class="text-sm">"Content"<textarea class="mt-1 min-h-28 w-full rounded border border-ink/20 px-3 py-2" prop:value=move || content.get() on:input=move |event| content.set(event_target_value(&event))></textarea></label> + </FormCard> + } +} + +#[component] +pub fn AttachRepositoryForm(workspace_id: String, refresh: RwSignal<u64>) -> impl IntoView { + let owner = RwSignal::new(String::new()); + let name = RwSignal::new(String::new()); + let branch = RwSignal::new("main".to_owned()); + let role = RwSignal::new("source".to_owned()); + let error = RwSignal::new(None::<String>); + let pending = RwSignal::new(false); + view! { + <FormCard title="Attach source repository" on_submit=move || { + let workspace_id = workspace_id.clone(); let owner = owner.get(); let name = name.get(); let branch = branch.get(); let role = if role.get() == "infra" { RepositoryRole::Infra } else { RepositoryRole::Source }; + pending.set(true); error.set(None); + spawn_local(async move { submit_result(attach_repository(&workspace_id, &owner, &name, role, &branch).await, error, pending, refresh); }); + } pending=pending error=error> + <Input label="Owner" value=owner/><Input label="Repository" value=name/><label class="text-sm">"Role"<select class="mt-1 w-full rounded border border-ink/20 px-3 py-2" on:change=move |event| role.set(event_target_value(&event))><option value="source">"source"</option><option value="infra">"infra"</option></select></label><Input label="Branch" value=branch/> + </FormCard> + } +} + +#[component] +pub fn EnvironmentForm(workspace_id: String, refresh: RwSignal<u64>) -> impl IntoView { + let name = RwSignal::new(String::new()); + let source_branch = RwSignal::new("main".to_owned()); + let infra_branch = RwSignal::new(String::new()); + let error = RwSignal::new(None::<String>); + let pending = RwSignal::new(false); + view! { + <FormCard title="Configure environment" on_submit=move || { + let workspace_id = workspace_id.clone(); let name = name.get(); let source_branch = source_branch.get(); let infra_branch = infra_branch.get(); + pending.set(true); error.set(None); + spawn_local(async move { submit_result(configure_environment(&workspace_id, &name, &source_branch, (!infra_branch.is_empty()).then_some(infra_branch.as_str())).await.map(|_| ()), error, pending, refresh); }); + } pending=pending error=error> + <Input label="Name" value=name/><Input label="Default source branch" value=source_branch/><Input label="Infra branch (optional)" value=infra_branch/> + </FormCard> + } +} + +#[component] +pub fn IssueForm(project_id: String, refresh: RwSignal<u64>) -> impl IntoView { + let title = RwSignal::new(String::new()); + let error = RwSignal::new(None::<String>); + let pending = RwSignal::new(false); + view! { + <form class="mt-3 flex gap-2" on:submit=move |event| { event.prevent_default(); let project_id = project_id.clone(); let title = title.get(); pending.set(true); error.set(None); spawn_local(async move { submit_result(create_issue(&project_id, &title).await.map(|_| ()), error, pending, refresh); }); }> + <input class="min-w-0 flex-1 rounded border border-ink/20 px-3 py-2 text-sm" placeholder="New issue" prop:value=move || title.get() on:input=move |event| title.set(event_target_value(&event))/> + <button class="rounded bg-primary px-3 py-2 text-sm text-white disabled:opacity-50" disabled=move || pending.get()>"Add"</button> + </form> + {move || error.get().map(|message| view! { <p class="mt-2 text-xs text-red-600">{message}</p> })} + } +} + +#[component] +pub fn VersionForm(project_id: String, refresh: RwSignal<u64>) -> impl IntoView { + let name = RwSignal::new(String::new()); + let slug = RwSignal::new(String::new()); + let error = RwSignal::new(None::<String>); + let pending = RwSignal::new(false); + view! { + <form class="mt-3 grid grid-cols-[1fr_1fr_auto] gap-2" on:submit=move |event| { event.prevent_default(); let project_id = project_id.clone(); let name = name.get(); let slug = slug.get(); pending.set(true); error.set(None); spawn_local(async move { submit_result(create_version(&project_id, &name, &slug).await.map(|_| ()), error, pending, refresh); }); }> + <input class="min-w-0 rounded border border-ink/20 px-3 py-2 text-sm" placeholder="Version" prop:value=move || name.get() on:input=move |event| name.set(event_target_value(&event))/> + <input class="min-w-0 rounded border border-ink/20 px-3 py-2 text-sm font-mono" placeholder="v0-6-0" prop:value=move || slug.get() on:input=move |event| slug.set(event_target_value(&event))/> + <button class="rounded bg-primary px-3 py-2 text-sm text-white disabled:opacity-50" disabled=move || pending.get()>"Add"</button> + </form> + {move || error.get().map(|message| view! { <p class="mt-2 text-xs text-red-600">{message}</p> })} + } +} + +#[component] +fn Input(label: &'static str, value: RwSignal<String>) -> impl IntoView { + view! { <label class="text-sm">{label}<input class="mt-1 w-full rounded border border-ink/20 px-3 py-2" prop:value=move || value.get() on:input=move |event| value.set(event_target_value(&event))/></label> } +} + +#[component] +fn FormCard( + title: &'static str, + on_submit: impl Fn() + 'static, + pending: RwSignal<bool>, + error: RwSignal<Option<String>>, + children: Children, +) -> impl IntoView { + view! { <form class="grid gap-3 rounded-xl border border-ink/10 bg-white p-5" on:submit=move |event| { event.prevent_default(); on_submit(); }><h2 class="font-semibold">{title}</h2>{children()}<button class="rounded bg-primary px-3 py-2 text-sm font-medium text-white disabled:opacity-50" disabled=move || pending.get()>"Save"</button>{move || error.get().map(|message| view! { <p class="text-xs text-red-600">{message}</p> })}</form> } +} diff --git a/src/pages/workspaces.rs b/src/pages/workspaces.rs --- /dev/null +++ b/src/pages/workspaces.rs @@ -1,0 +1,88 @@ +use leptos::prelude::*; +use leptos::task::spawn_local; +use leptos_router::components::A; +use leptos_router::hooks::use_navigate; + +use crate::collab::create_workspace; +use crate::components::nav::Nav; +use crate::identity::fetch_me; + +#[component] +pub fn Workspaces() -> impl IntoView { + let refresh = RwSignal::new(0_u64); + let workspaces = LocalResource::new(move || { + refresh.get(); + crate::collab::workspaces() + }); + let me = LocalResource::new(fetch_me); + let navigate = use_navigate(); + let name = RwSignal::new(String::new()); + let slug = RwSignal::new(String::new()); + let error = RwSignal::new(None::<String>); + let pending = RwSignal::new(false); + + view! { + <div class="flex min-h-screen flex-col bg-paper text-ink"> + <Nav> + <A href="/account" attr:class="text-sm font-medium hover:text-primary">"Account"</A> + </Nav> + <main class="mx-auto w-full max-w-5xl flex-1 px-6 py-12"> + <div class="flex flex-wrap items-start justify-between gap-8"> + <div> + <h1 class="text-3xl font-semibold">"Workspaces"</h1> + <p class="mt-2 text-sm text-ink/60">"Projects, documentation and delivery context in one place."</p> + </div> + <form + class="grid w-full max-w-md gap-3 rounded-xl border border-ink/10 bg-white p-5" + on:submit=move |event| { + event.prevent_default(); + let Some(Ok(Some(user))) = me.get().map(|value| (*value).clone()) else { + error.set(Some("sign in before creating a workspace".to_owned())); + return; + }; + let name_value = name.get(); + let slug_value = slug.get(); + let navigate = navigate.clone(); + pending.set(true); + error.set(None); + spawn_local(async move { + match create_workspace(user.id.inner(), &name_value, &slug_value).await { + Ok(workspace) => navigate( + &format!("/workspaces/{}", workspace.id.inner()), + Default::default(), + ), + Err(message) => error.set(Some(message)), + } + pending.set(false); + }); + } + > + <h2 class="font-semibold">"New workspace"</h2> + <label class="text-sm">"Name"<input class="mt-1 w-full rounded border border-ink/20 px-3 py-2" prop:value=move || name.get() on:input=move |event| name.set(event_target_value(&event))/></label> + <label class="text-sm">"Slug"<input class="mt-1 w-full rounded border border-ink/20 px-3 py-2 font-mono" prop:value=move || slug.get() on:input=move |event| slug.set(event_target_value(&event))/></label> + <button class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50" type="submit" disabled=move || pending.get()>"Create workspace"</button> + {move || error.get().map(|message| view! { <p class="text-sm text-red-600">{message}</p> })} + </form> + </div> + + <Suspense fallback=|| view! { <p class="mt-10 text-ink/60">"Loading workspaces…"</p> }> + {move || workspaces.get().map(|result| match &*result { + Ok(items) if items.is_empty() => view! { <p class="mt-10 rounded-xl border border-dashed border-ink/20 p-8 text-center text-ink/60">"No workspaces yet."</p> }.into_any(), + Ok(items) => view! { + <ul class="mt-10 grid gap-4 md:grid-cols-2"> + {items.clone().into_iter().map(|workspace| view! { + <li><A href=format!("/workspaces/{}", workspace.id.inner()) attr:class="block rounded-xl border border-ink/10 bg-white p-5 no-underline hover:border-primary/50"> + <h2 class="text-lg font-semibold">{workspace.name}</h2> + <p class="mt-1 font-mono text-sm text-ink/50">{workspace.slug}</p> + <p class="mt-4 text-xs uppercase tracking-wide text-ink/45">{workspace.default_environment}</p> + </A></li> + }).collect_view()} + </ul> + }.into_any(), + Err(message) => view! { <p class="mt-10 text-red-600">{message.clone()}</p> }.into_any(), + })} + </Suspense> + </main> + </div> + } +} -- SynCode