From e7a8b7f147fae07f4d73ceb2e7b48366d793e1b1 Mon Sep 17 00:00:00 2001 From: Przemyslaw Olszewski Date: Sun, 30 Aug 2026 23:06:03 +0200 Subject: [PATCH] feat: Add native Actions views --- diff --git a/src/app.rs b/src/app.rs --- a/src/app.rs +++ b/src/app.rs @@ -1,69 +1,72 @@ -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! { - - <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> - } -} +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_actions::{RepositoryActionRun, RepositoryActions}; +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("actions")) view=RepositoryActions/> + <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("actions"), ParamSegment("run")) view=RepositoryActionRun/> + <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("code")) view=RepositoryCodeDefault/> + <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("code"), ParamSegment("revision")) view=RepositoryCode/> + <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("blob"), ParamSegment("revision")) view=RepositoryBlob/> + <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("blame"), ParamSegment("revision")) view=RepositoryBlame/> + <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("commits")) view=RepositoryCommitsDefault/> + <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("commits"), ParamSegment("revision")) view=RepositoryCommits/> + <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("changeset"), ParamSegment("object_id")) view=RepositoryCommit/> + <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("branches")) view=RepositoryBranches/> + <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("tags")) view=RepositoryTags/> + <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("compare")) view=RepositoryCompare/> + <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("settings")) view=RepositorySettings/> + <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("settings"), StaticSegment("protection")) view=RepositoryProtection/> + <Route path=(ParamSegment("owner"), ParamSegment("repository"), StaticSegment("fork")) view=RepositoryFork/> + // Catch-all profile route — must stay last, or it would + // shadow every static route above it (e.g. /login would + // match as owner="login"). + <Route path=ParamSegment("owner") view=Profile/> + </Routes> + </main> + </Router> + } +} diff --git a/src/main.rs b/src/main.rs --- a/src/main.rs +++ b/src/main.rs @@ -1,25 +1,26 @@ -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); -} +mod actions; +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/mod.rs b/src/pages/mod.rs --- a/src/pages/mod.rs +++ b/src/pages/mod.rs @@ -1,21 +1,22 @@ -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; +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_actions; +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/src/pages/repository_shell.rs b/src/pages/repository_shell.rs --- a/src/pages/repository_shell.rs +++ b/src/pages/repository_shell.rs @@ -1,64 +1,65 @@ -use leptos::prelude::*; -use leptos_router::components::A; -use leptos_router::hooks::use_params_map; - -use crate::components::nav::Nav; - -#[component] -pub fn RepositoryShell(location: RepositoryLocation, children: Children) -> impl IntoView { - let base = location.base(); - let link = - "rounded px-2 py-1 text-sm font-medium text-ink/70 hover:bg-ink/5 hover:text-primary"; - 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> - <A href="/repo/new" attr:class="text-sm font-medium hover:text-primary">"New repository"</A> - </Nav> - <header class="border-b border-ink/10 bg-white"> - <div class="mx-auto w-full max-w-6xl px-6 py-5"> - <p class="text-sm text-ink/55"><span class="font-medium text-ink">{location.owner.clone()}</span>" / "<span class="font-semibold text-ink">{location.repository.clone()}</span></p> - <nav class="mt-3 flex flex-wrap gap-1"> - <A href=base.clone() attr:class=link>"Overview"</A> - <A href=format!("{base}/code") attr:class=link>"Code"</A> - <A href=format!("{base}/commits") attr:class=link>"Commits"</A> - <A href=format!("{base}/branches") attr:class=link>"Branches"</A> - <A href=format!("{base}/tags") attr:class=link>"Tags"</A> - <A href=format!("{base}/compare") attr:class=link>"Compare"</A> - <A href=format!("{base}/settings") attr:class=link>"Settings"</A> - <A href=format!("{base}/fork") attr:class=link>"Fork"</A> - </nav> - </div> - </header> - <main class="mx-auto w-full max-w-6xl flex-1 px-6 py-8">{children()}</main> - </div> - } -} - -#[derive(Clone)] -pub struct RepositoryLocation { - pub owner: String, - pub repository: String, -} - -impl RepositoryLocation { - pub fn base(&self) -> String { - format!( - "/{}/{}", - urlencoding::encode(&self.owner), - urlencoding::encode(&self.repository) - ) - } -} - -pub fn repository_location() -> RepositoryLocation { - let params = use_params_map().get_untracked(); - RepositoryLocation { - owner: params.get("owner").unwrap_or_default(), - repository: params.get("repository").unwrap_or_default(), - } -} - -pub fn short_id(value: &str) -> String { - value.chars().take(12).collect() -} +use leptos::prelude::*; +use leptos_router::components::A; +use leptos_router::hooks::use_params_map; + +use crate::components::nav::Nav; + +#[component] +pub fn RepositoryShell(location: RepositoryLocation, children: Children) -> impl IntoView { + let base = location.base(); + let link = + "rounded px-2 py-1 text-sm font-medium text-ink/70 hover:bg-ink/5 hover:text-primary"; + 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> + <A href="/repo/new" attr:class="text-sm font-medium hover:text-primary">"New repository"</A> + </Nav> + <header class="border-b border-ink/10 bg-white"> + <div class="mx-auto w-full max-w-6xl px-6 py-5"> + <p class="text-sm text-ink/55"><span class="font-medium text-ink">{location.owner.clone()}</span>" / "<span class="font-semibold text-ink">{location.repository.clone()}</span></p> + <nav class="mt-3 flex flex-wrap gap-1"> + <A href=base.clone() attr:class=link>"Overview"</A> + <A href=format!("{base}/code") attr:class=link>"Code"</A> + <A href=format!("{base}/commits") attr:class=link>"Commits"</A> + <A href=format!("{base}/branches") attr:class=link>"Branches"</A> + <A href=format!("{base}/tags") attr:class=link>"Tags"</A> + <A href=format!("{base}/compare") attr:class=link>"Compare"</A> + <A href=format!("{base}/actions") attr:class=link>"Actions"</A> + <A href=format!("{base}/settings") attr:class=link>"Settings"</A> + <A href=format!("{base}/fork") attr:class=link>"Fork"</A> + </nav> + </div> + </header> + <main class="mx-auto w-full max-w-6xl flex-1 px-6 py-8">{children()}</main> + </div> + } +} + +#[derive(Clone)] +pub struct RepositoryLocation { + pub owner: String, + pub repository: String, +} + +impl RepositoryLocation { + pub fn base(&self) -> String { + format!( + "/{}/{}", + urlencoding::encode(&self.owner), + urlencoding::encode(&self.repository) + ) + } +} + +pub fn repository_location() -> RepositoryLocation { + let params = use_params_map().get_untracked(); + RepositoryLocation { + owner: params.get("owner").unwrap_or_default(), + repository: params.get("repository").unwrap_or_default(), + } +} + +pub fn short_id(value: &str) -> String { + value.chars().take(12).collect() +} diff --git a/src/actions.rs b/src/actions.rs --- /dev/null +++ b/src/actions.rs @@ -1,0 +1,86 @@ +use gloo_net::http::Request; +use serde::Deserialize; +use web_sys::RequestCredentials; + +#[derive(Clone, Deserialize)] +pub struct ActionRun { + pub id: String, + pub number: u64, + pub commit: String, + pub reference: String, + pub event: String, + pub workflow: String, + pub state: String, + pub conclusion: String, + pub jobs: Vec<ActionJob>, +} + +#[derive(Clone, Deserialize)] +pub struct ActionJob { + pub id: String, + pub key: String, + pub state: String, + pub conclusion: String, +} + +#[derive(Deserialize)] +struct ActionLogs { + lines: Vec<String>, +} + +pub async fn runs(owner: &str, repository: &str) -> Result<Vec<ActionRun>, String> { + get(&format!( + "{}/control/actions/repositories/{}/{}/runs", + control_origin(), + urlencoding::encode(owner), + urlencoding::encode(repository) + )) + .await +} + +pub async fn run(id: &str) -> Result<ActionRun, String> { + get(&format!( + "{}/control/actions/runs/{}", + control_origin(), + urlencoding::encode(id) + )) + .await +} + +pub async fn logs(run: &str, job: &str) -> Result<Vec<String>, String> { + let response: ActionLogs = get(&format!( + "{}/control/actions/runs/{}/jobs/{}/logs", + control_origin(), + urlencoding::encode(run), + urlencoding::encode(job) + )) + .await?; + Ok(response.lines) +} + +async fn get<T: serde::de::DeserializeOwned>(url: &str) -> Result<T, String> { + let response = Request::get(url) + .credentials(RequestCredentials::Include) + .send() + .await + .map_err(|error| error.to_string())?; + if !response.ok() { + return Err(format!("control returned HTTP {}", response.status())); + } + response.json().await.map_err(|error| error.to_string()) +} + +fn control_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".to_owned(); + } + match hostname.strip_prefix("new.") { + Some(rest) => format!("https://{rest}"), + None => format!("https://{hostname}"), + } +} diff --git a/src/pages/repository_actions.rs b/src/pages/repository_actions.rs --- /dev/null +++ b/src/pages/repository_actions.rs @@ -1,0 +1,126 @@ +use leptos::prelude::*; +use leptos_router::components::A; +use leptos_router::hooks::use_params_map; + +use crate::actions::{logs, run, runs, ActionJob, ActionRun}; + +use super::repository_shell::{repository_location, short_id, RepositoryShell}; + +#[component] +pub fn RepositoryActions() -> impl IntoView { + let location = repository_location(); + let request = location.clone(); + let data = LocalResource::new(move || { + let owner = request.owner.clone(); + let repository = request.repository.clone(); + async move { runs(&owner, &repository).await } + }); + let base = location.base(); + view! { + <RepositoryShell location=location> + <h1 class="text-2xl font-semibold">"Actions"</h1> + <p class="mt-2 text-sm text-ink/55">"Runs and jobs read directly from SynCode Control."</p> + <Suspense fallback=move || view! { <p class="mt-6 text-sm text-ink/50">"Loading runs…"</p> }> + {move || { + let base = base.clone(); + Suspend::new(async move { + match data.await { + Ok(runs) if runs.is_empty() => view! { <p class="mt-6 rounded border border-ink/10 bg-white p-5 text-sm text-ink/55">"No runs yet."</p> }.into_any(), + Ok(runs) => run_list(runs, &base), + Err(error) => view! { <p class="mt-6 rounded border border-red-200 bg-red-50 p-4 text-sm text-red-700">{error}</p> }.into_any(), + } + }) + }} + </Suspense> + </RepositoryShell> + } +} + +fn run_list(runs: Vec<ActionRun>, base: &str) -> AnyView { + let base = base.to_owned(); + view! { + <ul class="mt-6 divide-y divide-ink/10 overflow-hidden rounded-xl border border-ink/10 bg-white"> + {runs.into_iter().map(|run| { + let status = status(&run.state, &run.conclusion); + view! { + <li> + <A href=format!("{base}/actions/{}", run.id) attr:class="flex flex-col gap-2 px-5 py-4 no-underline hover:bg-ink/5 sm:flex-row sm:items-center sm:justify-between"> + <div> + <p class="font-medium text-ink">{run.workflow}</p> + <p class="mt-1 font-mono text-xs text-ink/45">{format!("#{} · {} · {}", run.number, run.event, short_id(&run.commit))}</p> + </div> + <span class="rounded-full bg-ink/5 px-3 py-1 text-xs font-medium text-ink/65">{status}</span> + </A> + </li> + } + }).collect_view()} + </ul> + }.into_any() +} + +#[component] +pub fn RepositoryActionRun() -> impl IntoView { + let location = repository_location(); + let run_id = use_params_map() + .get_untracked() + .get("run") + .unwrap_or_default(); + let request = run_id.clone(); + let data = LocalResource::new(move || { + let id = request.clone(); + async move { load_run(&id).await } + }); + let actions = format!("{}/actions", location.base()); + view! { + <RepositoryShell location=location> + <A href=actions attr:class="text-sm font-medium text-primary no-underline hover:underline">"← Actions"</A> + <Suspense fallback=move || view! { <p class="mt-6 text-sm text-ink/50">"Loading run…"</p> }> + {move || Suspend::new(async move { + match data.await { + Ok((run, logs)) => run_detail(run, logs), + Err(error) => view! { <p class="mt-6 rounded border border-red-200 bg-red-50 p-4 text-sm text-red-700">{error}</p> }.into_any(), + } + })} + </Suspense> + </RepositoryShell> + } +} + +async fn load_run(id: &str) -> Result<(ActionRun, Vec<(ActionJob, Vec<String>)>), String> { + let run = run(id).await?; + let mut job_logs = Vec::with_capacity(run.jobs.len()); + for job in &run.jobs { + job_logs.push((job.clone(), logs(&run.id, &job.id).await?)); + } + Ok((run, job_logs)) +} + +fn run_detail(run: ActionRun, jobs: Vec<(ActionJob, Vec<String>)>) -> AnyView { + view! { + <div class="mt-5 flex flex-wrap items-start justify-between gap-4"> + <div><p class="font-mono text-xs text-ink/45">{format!("Run #{}", run.number)}</p><h1 class="mt-1 text-2xl font-semibold">{run.workflow}</h1></div> + <span class="rounded-full bg-ink/5 px-3 py-1 text-xs font-medium">{status(&run.state, &run.conclusion)}</span> + </div> + <dl class="mt-5 grid gap-3 rounded-xl border border-ink/10 bg-white p-5 text-sm sm:grid-cols-3"> + <div><dt class="text-ink/45">"Commit"</dt><dd class="mt-1 font-mono">{short_id(&run.commit)}</dd></div> + <div><dt class="text-ink/45">"Reference"</dt><dd class="mt-1 font-mono">{run.reference}</dd></div> + <div><dt class="text-ink/45">"Event"</dt><dd class="mt-1">{run.event}</dd></div> + </dl> + <div class="mt-6 space-y-4"> + {jobs.into_iter().map(|(job, lines)| view! { + <section class="overflow-hidden rounded-xl border border-ink/10 bg-white"> + <div class="flex items-center justify-between border-b border-ink/10 px-5 py-3"><h2 class="font-medium">{job.key}</h2><span class="text-xs text-ink/55">{status(&job.state, &job.conclusion)}</span></div> + <pre class="max-h-[34rem] overflow-auto bg-ink p-4 text-xs leading-relaxed text-paper">{lines.join("\n")}</pre> + </section> + }).collect_view()} + </div> + }.into_any() +} + +fn status(state: &str, conclusion: &str) -> String { + if conclusion.is_empty() { + state.to_owned() + } else { + format!("{state}/{conclusion}") + } +} -- SynCode