feat: Add native Actions views #27

Manually merged
day01 merged 1 commits from feat/0.6-actions-view into develop 2026-08-30 21:06:27 +00:00
6 changed files with 218 additions and 0 deletions
Showing only changes of commit e7a8b7f147 - Show all commits
+3
View File
@@ -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! {
<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>
}
}
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>
}
}
+1
View File
@@ -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);
}
+1
View File
@@ -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;
+1
View File
@@ -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()
}
+86
View File
@@ -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}"),
}
}
+126
View File
@@ -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}")
}
}