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! {
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- // Catch-all profile route — must stay last, or it would
- // shadow every static route above it (e.g. /login would
- // match as owner="login").
-
-
-
-
- }
-}
+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! {
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ // Catch-all profile route — must stay last, or it would
+ // shadow every static route above it (e.g. /login would
+ // match as owner="login").
+
+
+
+
+ }
+}
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(operation: cynic::Operation) -> Result
-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 =
- 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(operation: cynic::Operation) -> Result
-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 =
- 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(operation: cynic::Operation) -> Result
+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 =
+ 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(operation: cynic::Operation) -> Result
+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 =
+ 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(operation: cynic::Operation) -> Result
+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 =
+ 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! {
-
-
-
-
-
-
-
-
-
"SynCode"
-
- "The open-source federated code forge."
-
-
- {move || {
- let (href, label) = if signed_in() {
- ("/account", "Go to account")
- } else {
- ("/login", "Sign in")
- };
- view! {
-
- {label}
-
- }
- }}
-
- "Explore the forge →"
-
-
-
-
-
-
-
-
-
-
- }
-}
-
-#[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! {
+
+
+
+
+
+
+
+
+
+
+
"SynCode"
+
+ "The open-source federated code forge."
+
+
+ {move || {
+ let (href, label) = if signed_in() {
+ ("/workspaces", "Open workspaces")
+ } else {
+ ("/login", "Sign in")
+ };
+ view! {
+
+ {label}
+
+ }
+ }}
+
+ "Explore the forge →"
+
+
+
+
+
+
+
+
+
+
+ }
+}
+
+#[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,
+}
+
+#[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,
+}
+
+#[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,
+}
+
+pub async fn workspaces() -> Result, 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,
+ #[arguments(workspaceId: $workspace_id)]
+ projects: Vec,
+ #[arguments(workspaceId: $workspace_id)]
+ documents: Vec,
+}
+
+pub async fn workspace(
+ id: cynic::Id,
+) -> Result<
+ (
+ Workspace,
+ Vec,
+ Vec,
+ Vec,
+ Vec,
+ ),
+ 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,
+}
+
+pub async fn issues(project_id: cynic::Id) -> Result, 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,
+}
+
+pub async fn versions(project_id: cynic::Id) -> Result, 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 {
+ 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 {
+ 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,
+}
+
+#[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 {
+ 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,
+ path: String,
+ content: String,
+ base_revision_id: Option,
+}
+
+#[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 {
+ 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 {
+ 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 {
+ 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,
+ environments: Vec,
+ projects: Vec<(Project, Vec, Vec)>,
+ documents: Vec,
+}
+
+async fn load_workspace(id: &str) -> Result {
+ 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! {
+
+
+
+ "Loading workspace…" }>
+ {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! {
+
+ {workspace.slug}
{workspace.name}
+ {workspace.default_environment}
+
+
+
+
+ "Projects"
{projects.len()}
+ {projects.clone().into_iter().map(|(project, issues, versions)| view! {
+ -
+
{format!("{} · {}", project.key, project.name)}
{project.description}
{project.slug}
{project.lifecycle}
+ {issues.into_iter().map(|issue| view! { - {format!("#{}", issue.number)}{issue.title}
}).collect_view()}
+
+ "Versions"
{versions.into_iter().map(|version| view! { - {format!("{} ({}) · {}", version.name, version.slug, version.state)}
}).collect_view()}
+
+ }).collect_view()}
+
+
+ "Docs"
{documents.len()}
+ {documents.into_iter().map(|document| view! { {document.path}
{document.revision_id.inner().to_owned()}{document.content} }).collect_view()}
+
+
+
+
+ }.into_any()
+ }
+ Err(message) => view! { {message.clone()}
}.into_any(),
+ })}
+
+
+
+ }
+}
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