feat: Add working workspace #25

Manually merged
day01 merged 1 commits from feat/0.6-working-workspace into develop 2026-08-30 19:59:21 +00:00
12 changed files with 1087 additions and 16 deletions
+4 -6
View File
@@ -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).
+4
View File
@@ -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! {
<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=(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>
}
}
+42
View File
@@ -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())
}
+2
View File
@@ -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);
}
+15 -10
View File
@@ -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);
}
}
+3
View File
@@ -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;
+158
View File
@@ -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 }
+299
View File
@@ -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)
}
+222
View File
@@ -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)
}
+105
View File
@@ -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>
}
}
+145
View File
@@ -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> }
}
+88
View File
@@ -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>
}
}