Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Show job cobs on latest revision and default branch commits
Rūdolfs Ošiņš committed 9 months ago
commit bfa38d9fe9015aef7999174583b3ea7bdcf0d6b4
parent 8632ea9
21 files changed +539 -15
modified Cargo.lock
@@ -192,6 +192,56 @@ dependencies = [
]

[[package]]
+
name = "anstream"
+
version = "0.6.19"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933"
+
dependencies = [
+
 "anstyle",
+
 "anstyle-parse",
+
 "anstyle-query",
+
 "anstyle-wincon",
+
 "colorchoice",
+
 "is_terminal_polyfill",
+
 "utf8parse",
+
]
+

+
[[package]]
+
name = "anstyle"
+
version = "1.0.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd"
+

+
[[package]]
+
name = "anstyle-parse"
+
version = "0.2.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
+
dependencies = [
+
 "utf8parse",
+
]
+

+
[[package]]
+
name = "anstyle-query"
+
version = "1.1.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9"
+
dependencies = [
+
 "windows-sys 0.59.0",
+
]
+

+
[[package]]
+
name = "anstyle-wincon"
+
version = "3.0.9"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882"
+
dependencies = [
+
 "anstyle",
+
 "once_cell_polyfill",
+
 "windows-sys 0.59.0",
+
]
+

+
[[package]]
name = "anyhow"
version = "1.0.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -802,6 +852,47 @@ dependencies = [
]

[[package]]
+
name = "clap"
+
version = "4.5.41"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9"
+
dependencies = [
+
 "clap_builder",
+
 "clap_derive",
+
]
+

+
[[package]]
+
name = "clap_builder"
+
version = "4.5.41"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d"
+
dependencies = [
+
 "anstream",
+
 "anstyle",
+
 "clap_lex",
+
 "strsim",
+
 "terminal_size",
+
]
+

+
[[package]]
+
name = "clap_derive"
+
version = "4.5.41"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491"
+
dependencies = [
+
 "heck 0.5.0",
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.101",
+
]
+

+
[[package]]
+
name = "clap_lex"
+
version = "0.7.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675"
+

+
[[package]]
name = "clipboard-win"
version = "5.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -811,6 +902,12 @@ dependencies = [
]

[[package]]
+
name = "colorchoice"
+
version = "1.0.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+

+
[[package]]
name = "combine"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2562,6 +2659,12 @@ dependencies = [
]

[[package]]
+
name = "is_terminal_polyfill"
+
version = "1.70.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
+

+
[[package]]
name = "itoa"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3038,6 +3141,12 @@ dependencies = [
]

[[package]]
+
name = "nonempty"
+
version = "0.11.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "549e471b99ccaf2f89101bec68f4d244457d5a95a9c3d0672e9564124397741d"
+

+
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3361,6 +3470,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"

[[package]]
+
name = "once_cell_polyfill"
+
version = "1.70.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
+

+
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3952,7 +4067,7 @@ dependencies = [
 "localtime",
 "log",
 "multibase",
-
 "nonempty",
+
 "nonempty 0.9.0",
 "qcheck",
 "radicle-cob",
 "radicle-crypto",
@@ -3977,7 +4092,7 @@ dependencies = [
 "fastrand",
 "git2",
 "log",
-
 "nonempty",
+
 "nonempty 0.9.0",
 "once_cell",
 "radicle-crypto",
 "radicle-dag",
@@ -4034,6 +4149,25 @@ dependencies = [
]

[[package]]
+
name = "radicle-job"
+
version = "0.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "70b2de1bb748a1b587f759137058c4ae917753652b5fb4ae3baee991a7b31a68"
+
dependencies = [
+
 "clap",
+
 "indexmap 2.9.0",
+
 "nonempty 0.11.0",
+
 "once_cell",
+
 "qcheck",
+
 "radicle",
+
 "serde",
+
 "serde_json",
+
 "thiserror 2.0.12",
+
 "url",
+
 "uuid",
+
]
+

+
[[package]]
name = "radicle-ssh"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4062,7 +4196,7 @@ dependencies = [
 "flate2",
 "git2",
 "log",
-
 "nonempty",
+
 "nonempty 0.9.0",
 "radicle-git-ext",
 "radicle-std-ext",
 "serde",
@@ -4081,6 +4215,7 @@ dependencies = [
 "infer",
 "log",
 "radicle",
+
 "radicle-job",
 "radicle-surf",
 "radicle-types",
 "serde",
@@ -4111,6 +4246,7 @@ dependencies = [
 "log",
 "mime-infer",
 "radicle",
+
 "radicle-job",
 "radicle-surf",
 "serde",
 "serde_json",
@@ -5775,6 +5911,16 @@ dependencies = [
]

[[package]]
+
name = "terminal_size"
+
version = "0.4.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed"
+
dependencies = [
+
 "rustix 1.0.7",
+
 "windows-sys 0.59.0",
+
]
+

+
[[package]]
name = "test-http-api"
version = "0.1.0"
dependencies = [
@@ -6533,13 +6679,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"

[[package]]
+
name = "utf8parse"
+
version = "0.2.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+

+
[[package]]
name = "uuid"
-
version = "1.16.0"
+
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9"
+
checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d"
dependencies = [
 "getrandom 0.3.3",
+
 "js-sys",
 "serde",
+
 "wasm-bindgen",
]

[[package]]
modified crates/radicle-tauri/Cargo.toml
@@ -21,6 +21,7 @@ either = { version = "1.15" }
infer = { version = "0.19.0" }
log = { version = "0.4.22" }
radicle = { version = "0.16.1" }
+
radicle-job = { version = "0.2.0" }
radicle-types = { version = "0.1.0", path = "../radicle-types" }
radicle-surf = { version = "0.22.1", features = ["serde"] }
serde = { version = "1.0.0", features = ["derive"] }
modified crates/radicle-tauri/src/commands/cob.rs
@@ -11,6 +11,7 @@ use tauri_plugin_dialog::DialogExt;
use crate::AppState;

pub mod issue;
+
pub mod job;
pub mod patch;

#[tauri::command]
added crates/radicle-tauri/src/commands/cob/job.rs
@@ -0,0 +1,16 @@
+
use radicle::git;
+
use radicle::identity::RepoId;
+
use radicle_types::cobs::job;
+
use radicle_types::error::Error;
+
use radicle_types::traits::job::Jobs;
+

+
use crate::AppState;
+

+
#[tauri::command]
+
pub fn list_jobs(
+
    ctx: tauri::State<AppState>,
+
    rid: RepoId,
+
    sha: git::Oid,
+
) -> Result<Vec<job::Job>, Error> {
+
    ctx.list_jobs(rid, sha)
+
}
modified crates/radicle-tauri/src/lib.rs
@@ -32,6 +32,7 @@ pub fn run() {
            cob::issue::issue_by_id,
            cob::issue::list_issues,
            cob::issue::rebuild_issue_cache,
+
            cob::job::list_jobs,
            cob::patch::activity_by_patch,
            cob::patch::edit_patch,
            cob::patch::list_patches,
modified crates/radicle-types/Cargo.toml
@@ -12,6 +12,7 @@ log = { version = "0.4.22" }
infer = { version = "0.19.0" }
mime-infer = { version = "3.0.0" }
radicle = { version = "0.16.1" }
+
radicle-job = { version = "0.2.0" }
radicle-surf = { version = "0.22.1", features = ["serde"] }
serde = { version = "1.0.0", features = ["derive"] }
serde_json = { version = "1.0.0" }
added crates/radicle-types/bindings/repo/Job.ts
@@ -0,0 +1,4 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
import type { Run } from "./Run";
+

+
export type Job = { jobId: string; commit: string; runs: Array<Run> };
added crates/radicle-types/bindings/repo/Run.ts
@@ -0,0 +1,5 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
import type { Author } from "../cob/Author";
+
import type { Status } from "./Status";
+

+
export type Run = { runId: string; node: Author; status: Status; log: string };
added crates/radicle-types/bindings/repo/Status.ts
@@ -0,0 +1,3 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+

+
export type Status = "started" | "failed" | "succeeded";
modified crates/radicle-types/src/cobs.rs
@@ -10,6 +10,7 @@ use radicle::node::{Alias, AliasStore};

pub mod diff;
pub mod issue;
+
pub mod job;
pub mod repo;
pub mod stream;
pub mod thread;
added crates/radicle-types/src/cobs/job.rs
@@ -0,0 +1,80 @@
+
use radicle::git;
+
use radicle::node::AliasStore;
+
use serde::Serialize;
+
use ts_rs::TS;
+
use url::Url;
+

+
use crate::cobs;
+

+
#[derive(Clone, Serialize, TS, Debug)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "repo/")]
+
pub struct Job {
+
    #[ts(as = "String")]
+
    pub job_id: radicle_job::JobId,
+
    #[ts(as = "String")]
+
    pub commit: git::Oid,
+
    pub runs: Vec<Run>,
+
}
+

+
impl PartialEq for Job {
+
    fn eq(&self, other: &Self) -> bool {
+
        self.job_id == other.job_id && self.commit == other.commit && self.runs == other.runs
+
    }
+
}
+

+
impl Job {
+
    pub fn new(id: radicle_job::JobId, job: &radicle_job::Job, aliases: &impl AliasStore) -> Self {
+
        Self {
+
            job_id: id,
+
            commit: *job.oid(),
+
            runs: job
+
                .runs()
+
                .iter()
+
                .flat_map(|(node_id, runs)| {
+
                    runs.iter().map(move |(run_id, run)| Run {
+
                        run_id: run_id.to_string(),
+
                        node: cobs::Author::new(&(*node_id).into(), aliases),
+
                        status: (*run.status()).into(),
+
                        log: run.log().clone(),
+
                    })
+
                })
+
                .collect(),
+
        }
+
    }
+
}
+

+
#[derive(Clone, Serialize, TS, Debug, PartialEq)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "repo/")]
+
pub struct Run {
+
    pub run_id: String,
+
    pub node: cobs::Author,
+
    pub status: Status,
+
    #[ts(as = "String")]
+
    pub log: Url,
+
}
+

+
#[derive(Clone, Serialize, TS, Debug, PartialEq)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "repo/")]
+
pub enum Status {
+
    Started,
+
    Failed,
+
    Succeeded,
+
}
+

+
impl From<radicle_job::Status> for Status {
+
    fn from(value: radicle_job::Status) -> Self {
+
        match value {
+
            radicle_job::Status::Started => Self::Started,
+
            radicle_job::Status::Finished(reason) => match reason {
+
                radicle_job::Reason::Failed => Self::Failed,
+
                radicle_job::Reason::Succeeded => Self::Succeeded,
+
            },
+
        }
+
    }
+
}
modified crates/radicle-types/src/lib.rs
@@ -1,5 +1,6 @@
use traits::cobs::Cobs;
use traits::issue::{Issues, IssuesMut};
+
use traits::job::Jobs;
use traits::patch::{Patches, PatchesMut};
use traits::repo::Repo;
use traits::thread::Thread;
@@ -27,6 +28,7 @@ impl Thread for AppState {}
impl Cobs for AppState {}
impl Issues for AppState {}
impl IssuesMut for AppState {}
+
impl Jobs for AppState {}
impl Patches for AppState {}
impl PatchesMut for AppState {}
impl Profile for AppState {
modified crates/radicle-types/src/traits.rs
@@ -4,6 +4,7 @@ use crate::config::Config;

pub mod cobs;
pub mod issue;
+
pub mod job;
pub mod patch;
pub mod repo;
pub mod thread;
added crates/radicle-types/src/traits/job.rs
@@ -0,0 +1,23 @@
+
use radicle::storage::ReadStorage;
+
use radicle::{git, identity};
+
use radicle_job::{Job, JobId, Jobs as JobsStore};
+

+
use crate::cobs::job;
+
use crate::error::Error;
+
use crate::traits::Profile;
+

+
pub trait Jobs: Profile {
+
    fn list_jobs(&self, rid: identity::RepoId, sha: git::Oid) -> Result<Vec<job::Job>, Error> {
+
        let profile = self.profile();
+
        let repo = profile.storage.repository(rid)?;
+
        let aliases = &profile.aliases();
+

+
        let jobs = JobsStore::open(&repo).unwrap();
+
        let found_jobs: Result<Vec<(JobId, Job)>, _> = jobs.find_by_commit(sha)?.collect();
+

+
        Ok(found_jobs?
+
            .into_iter()
+
            .map(|(id, job)| job::Job::new(id, &job, aliases))
+
            .collect())
+
    }
+
}
modified crates/test-http-api/src/api.rs
@@ -25,6 +25,7 @@ use radicle_types::error::Error;
use radicle_types::outbound::sqlite::Sqlite;
use radicle_types::traits::cobs::Cobs;
use radicle_types::traits::issue::{Issues, IssuesMut};
+
use radicle_types::traits::job::Jobs;
use radicle_types::traits::patch::{Patches, PatchesMut};
use radicle_types::traits::repo::{Repo, Show};
use radicle_types::traits::thread::Thread;
@@ -41,6 +42,7 @@ impl Cobs for Context {}
impl Thread for Context {}
impl Issues for Context {}
impl IssuesMut for Context {}
+
impl Jobs for Context {}
impl Patches for Context {}
impl PatchesMut for Context {}
impl Profile for Context {
@@ -90,6 +92,7 @@ pub fn router(ctx: Context) -> Router {
        .route("/save_embed_by_clipboard", post(save_embed_handler))
        .route("/save_embed_by_bytes", post(save_embed_handler))
        .route("/save_embed_to_disk", post(save_embed_handler))
+
        .route("/list_jobs", post(jobs_handler))
        .layer(
            CorsLayer::new()
                .allow_origin(cors::Any)
@@ -470,3 +473,18 @@ async fn revision_handler(

    Ok::<_, Error>(Json(revisions))
}
+

+
#[derive(Serialize, Deserialize)]
+
struct JobsBody {
+
    pub rid: identity::RepoId,
+
    pub sha: git::Oid,
+
}
+

+
async fn jobs_handler(
+
    State(ctx): State<Context>,
+
    Json(JobsBody { rid, sha }): Json<JobsBody>,
+
) -> impl IntoResponse {
+
    let jobs = ctx.list_jobs(rid, sha)?;
+

+
    Ok::<_, Error>(Json(jobs))
+
}
modified src/components/Changes.svelte
@@ -10,6 +10,7 @@
  import CommitsContainer from "@app/components/CommitsContainer.svelte";
  import Icon from "@app/components/Icon.svelte";
  import Id from "@app/components/Id.svelte";
+
  import JobCob from "@app/components/JobCob.svelte";
  import NakedButton from "@app/components/NakedButton.svelte";

  interface Props {
@@ -175,7 +176,7 @@
            <div class="global-counter">Base</div>
          </div>
          <div class="commits">
-
            {#each [...commits].reverse() as commit}
+
            {#each [...commits].reverse() as commit, idx}
              <div
                class="commit"
                class:single-commit={commits.length === 1}
@@ -196,7 +197,11 @@
                      commitId: commit.id,
                    });
                  }}
-
                  {commit} />
+
                  {commit}>
+
                  {#if idx === commits.length - 1}
+
                    <JobCob {rid} commit={commit.id} />
+
                  {/if}
+
                </CobCommitTeaser>
              </div>
            {/each}
          </div>
modified src/components/CobCommitTeaser.svelte
@@ -1,5 +1,6 @@
<script lang="ts">
  import type { Commit } from "@bindings/repo/Commit";
+
  import type { Snippet } from "svelte";

  import { twemoji } from "@app/lib/utils";

@@ -10,13 +11,20 @@
  import NakedButton from "@app/components/NakedButton.svelte";

  interface Props {
-
    disabled: boolean;
+
    children?: Snippet;
    commit: Commit;
-
    onclick: () => void;
+
    disabled: boolean;
    hoverable?: boolean;
+
    onclick: () => void;
  }

-
  const { disabled, hoverable = false, commit, onclick }: Props = $props();
+
  const {
+
    children,
+
    commit,
+
    disabled,
+
    hoverable = false,
+
    onclick,
+
  }: Props = $props();

  let commitMessageVisible = $state(false);
</script>
@@ -99,6 +107,7 @@
    {/if}
  </div>
  <div class="right">
+
    {@render children?.()}
    <CompactCommitAuthorship {commit}>
      <Id
        id={commit.id}
modified src/components/HoverPopover.svelte
@@ -5,8 +5,10 @@

  interface Props {
    popover: Snippet;
-
    stylePopoverPositionBottom: string | undefined;
-
    stylePopoverPositionLeft: string | undefined;
+
    stylePopoverPositionBottom?: string | undefined;
+
    stylePopoverPositionLeft?: string | undefined;
+
    stylePopoverPositionRight?: string | undefined;
+
    stylePadding?: string | undefined;
    toggle: Snippet;
  }

@@ -14,6 +16,8 @@
    popover,
    stylePopoverPositionBottom,
    stylePopoverPositionLeft,
+
    stylePopoverPositionRight,
+
    stylePadding = "0.5rem 1rem",
    toggle,
  }: Props = $props();

@@ -31,8 +35,6 @@
  }
  .popover {
    background: var(--color-fill-ghost);
-
    border-radius: var(--border-radius-regular);
-
    padding: 0.5rem 1rem;
    box-shadow: var(--elevation-low);
    position: absolute;
    clip-path: var(--2px-corner-fill);
@@ -41,9 +43,13 @@
</style>

<div class="container">
+
  <!-- svelte-ignore a11y_click_events_have_key_events -->
  <div
    role="button"
    tabindex="0"
+
    onclick={e => {
+
      e.stopPropagation();
+
    }}
    onmouseenter={() => setVisible(true)}
    onmouseleave={() => setVisible(false)}>
    {@render toggle()}
@@ -52,7 +58,9 @@
      <div style:position="absolute">
        <div
          class="popover"
+
          style:padding={stylePadding}
          style:left={stylePopoverPositionLeft}
+
          style:right={stylePopoverPositionRight}
          style:bottom={stylePopoverPositionBottom}>
          {@render popover()}
        </div>
modified src/components/Icon.svelte
@@ -21,6 +21,7 @@
      | "checkbox-checked"
      | "checkbox-unchecked"
      | "checkmark"
+
      | "checkmark-double"
      | "checkout"
      | "chevron-down"
      | "chevron-right"
@@ -34,6 +35,7 @@
      | "comment-cross"
      | "copy"
      | "cross"
+
      | "cross-double"
      | "dashboard"
      | "delegate"
      | "diff"
@@ -47,6 +49,7 @@
      | "filter"
      | "folder-closed"
      | "folder-open"
+
      | "help"
      | "home"
      | "hourglass"
      | "inbox"
@@ -296,6 +299,23 @@
    <path d="M4 8V9H3L3 8H4Z" />
    <path d="M5 9L5 10L4 10L4 9H5Z" />
    <path d="M6 10L6 11H5L5 10L6 10Z" />
+
  {:else if name === "checkmark-double"}
+
    <path d="M6 10L6 11H5L5 10H6Z" />
+
    <path d="M8 10V11H7L7 10H8Z" />
+
    <path d="M7 9L7 10L6 10V9H7Z" />
+
    <path d="M9 9V10H8V9L9 9Z" />
+
    <path d="M8 8L8 9L7 9L7 8H8Z" />
+
    <path d="M10 8V9L9 9V8H10Z" />
+
    <path d="M9 7L9 8L8 8L8 7H9Z" />
+
    <path d="M11 7V8L10 8L10 7H11Z" />
+
    <path d="M10 6V7L9 7V6L10 6Z" />
+
    <path d="M12 6V7H11V6H12Z" />
+
    <path d="M11 5V6L10 6V5L11 5Z" />
+
    <path d="M13 5V6L12 6V5L13 5Z" />
+
    <path d="M4 8V9H3L3 8H4Z" />
+
    <path d="M6 8L6 9L5 9V8H6Z" />
+
    <path d="M5 9L5 10H4L4 9H5Z" />
+
    <path d="M7 9L7 10L6 10V9H7Z" />
  {:else if name === "checkout"}
    <path d="M5 5H11V6H5V5Z" />
    <path d="M4 6L5 6L5 11H4L4 6Z" />
@@ -522,6 +542,33 @@
    <path d="M6.00003 6L6.00003 7H7.00003L7.00003 6H6.00003Z" />
    <path d="M5.00003 5V6L6.00003 6V5L5.00003 5Z" />
    <path d="M4.00003 4L4.00003 5L5.00003 5L5.00003 4L4.00003 4Z" />
+
  {:else if name === "cross-double"}
+
    <path d="M4 11V12H3L3 11H4Z" />
+
    <path d="M5 10L5 11H4L4 10H5Z" />
+
    <path d="M6 9V10H5L5 9L6 9Z" />
+
    <path d="M7 7V8H6V7H7Z" />
+
    <path d="M10 5V6L9 6V5H10Z" />
+
    <path d="M11 4V5L10 5V4L11 4Z" />
+
    <path d="M9 10V11H10V10H9Z" />
+
    <path d="M10 11V12H11V11H10Z" />
+
    <path d="M8 9V10H9V9H8Z" />
+
    <path d="M7 8L7 9H8V8L7 8Z" />
+
    <path d="M5 6L5 7H6L6 6H5Z" />
+
    <path d="M4 5V6L5 6V5L4 5Z" />
+
    <path d="M3 4L3 5L4 5L4 4L3 4Z" />
+
    <path d="M6 11V12H5L5 11H6Z" />
+
    <path d="M7 10L7 11H6L6 10H7Z" />
+
    <path d="M9 7V8H8V7H9Z" />
+
    <path d="M11 6V7H10V6H11Z" />
+
    <path d="M12 5V6L11 6V5H12Z" />
+
    <path d="M13 4V5L12 5V4L13 4Z" />
+
    <path d="M11 10V11H12V10H11Z" />
+
    <path d="M12 11V12H13V11H12Z" />
+
    <path d="M10 9V10H11V9H10Z" />
+
    <path d="M9 8L9 9H10V8L9 8Z" />
+
    <path d="M7 6L7 7H8L8 6H7Z" />
+
    <path d="M6 5V6L7 6V5L6 5Z" />
+
    <path d="M5 4L5 5L6 5L6 4L5 4Z" />
  {:else if name === "dashboard"}
    <path d="M2 11H14V12H2V11Z" />
    <path d="M2 9H3V11H2L2 9Z" />
@@ -721,6 +768,32 @@
    <path d="M4.5 8V10H3.5L3.5 8H4.5Z" />
    <path d="M14.5 8L14.5 10H13.5L13.5 8H14.5Z" />
    <path d="M11.5 12H12.5V13H11.5V12Z" />
+
  {:else if name === "help"}
+
    <path d="M9 12H8L8 14H9L9 12Z" />
+
    <path d="M8 12H7V14H8L8 12Z" />
+
    <path d="M11 5L11 7H12V5H11Z" />
+
    <path d="M11 5L11 7H12V5H11Z" />
+
    <path d="M10 5L10 7L11 7L11 5L10 5Z" />
+
    <path d="M5 4L5 6H4L4 4H5Z" />
+
    <path d="M6 4V6H5L5 4H6Z" />
+
    <path d="M11 4V5H12V4H11Z" />
+
    <path d="M10 4V5L11 5V4L10 4Z" />
+
    <path d="M9 8V9H10V8H9Z" />
+
    <path d="M8 9V10H9V9L8 9Z" />
+
    <path d="M8 10V11H9V10H8Z" />
+
    <path d="M6 4H5L5 3L6 3L6 4Z" />
+
    <path d="M9 4L11 4V3L9 3V4Z" />
+
    <path d="M11 6V7H12V6H11Z" />
+
    <path d="M10 6V7L11 7V6L10 6Z" />
+
    <path d="M9 7L9 8H10L10 7H9Z" />
+
    <path d="M8 8V9L9 9V8H8Z" />
+
    <path d="M7 9V10L8 10V9L7 9Z" />
+
    <path d="M7 10L7 11L8 11V10L7 10Z" />
+
    <path d="M10 7L10 8H11V7L10 7Z" />
+
    <path d="M7 3L6 3L6 2L7 2V3Z" />
+
    <path d="M7 4H6L6 3L7 3V4Z" />
+
    <path d="M7 3L9 3V2L7 2V3Z" />
+
    <path d="M7 4L9 4V3L7 3V4Z" />
  {:else if name === "home"}
    <path d="M7 1.50003H9V2.50003H7V1.50003Z" />
    <path d="M6 2.50003L7 2.50003V3.50003H6V2.50003Z" />
added src/components/JobCob.svelte
@@ -0,0 +1,115 @@
+
<script lang="ts">
+
  import type { Job } from "@bindings/repo/Job";
+

+
  import { invoke } from "@app/lib/invoke";
+
  import { authorForNodeId } from "@app/lib/utils";
+

+
  import HoverPopover from "@app/components/HoverPopover.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+

+
  import DropdownListItem from "./DropdownListItem.svelte";
+
  import NodeId from "./NodeId.svelte";
+

+
  interface Props {
+
    commit: string;
+
    rid: string;
+
  }
+

+
  const { commit, rid }: Props = $props();
+
</script>
+

+
<style>
+
  .status {
+
    gap: 0.5rem;
+
    width: fit-content;
+
  }
+
</style>
+

+
{#await invoke<Job[]>("list_jobs", { rid, sha: commit }) then jobs}
+
  {#if jobs.length > 0}
+
    <HoverPopover
+
      stylePadding="0.25rem"
+
      stylePopoverPositionBottom="2rem"
+
      stylePopoverPositionRight="-1.5rem">
+
      {#snippet toggle()}
+
        {#if jobs.every(j => {
+
          return j.runs.every(r => {
+
            return r.status === "succeeded";
+
          });
+
        })}
+
          <div
+
            class="global-counter"
+
            style:padding="0"
+
            style:color="var(--color-fill-success)"
+
            style:background-color="var(--color-fill-diff-green)">
+
            <Icon name="checkmark-double" />
+
          </div>
+
        {:else if jobs.every(j => {
+
          return j.runs.every(r => {
+
            return r.status === "failed";
+
          });
+
        })}
+
          <div
+
            class="global-counter"
+
            style:color="var(--color-foreground-red)"
+
            style:padding="0"
+
            style:background-color="var(--color-fill-diff-red)">
+
            <Icon name="cross-double" />
+
          </div>
+
        {:else}
+
          <div
+
            class="global-counter"
+
            style:padding="0"
+
            style:color="var(--color-fill-gray)"
+
            style:background-color="var(--color-fill-ghost)">
+
            <Icon name="help" />
+
          </div>
+
        {/if}
+
      {/snippet}
+

+
      {#snippet popover()}
+
        {#each jobs as job}
+
          {#each job.runs as run}
+
            <a
+
              style:text-decoration="none"
+
              style:cursor="pointer"
+
              style:width="100%"
+
              href={run.log}
+
              target="_blank">
+
              <DropdownListItem styleGap="0.5rem" selected={true}>
+
                {#if run.status === "started"}
+
                  <div
+
                    class="global-counter status"
+
                    style:background-color="var(--color-fill-float)"
+
                    style:color="var(--color-fill-gray)">
+
                    <Icon name="hourglass" /> Started
+
                  </div>
+
                {:else if run.status === "failed"}
+
                  <div
+
                    class="global-counter status"
+
                    style:color="var(--color-foreground-red)"
+
                    style:background-color="var(--color-fill-diff-red)">
+
                    <Icon name="cross" /> Failed
+
                  </div>
+
                {:else if run.status === "succeeded"}
+
                  <div
+
                    class="global-counter status"
+
                    style:color="var(--color-fill-success)"
+
                    style:background-color="var(--color-fill-diff-green)">
+
                    <Icon name="checkmark" /> Passed
+
                  </div>
+
                {/if}
+
                <NodeId {...authorForNodeId(run.node)} />
+
                <div
+
                  style:margin-left="auto"
+
                  style:color="var(--color-fill-gray)">
+
                  <Icon name="open-external" />
+
                </div>
+
              </DropdownListItem>
+
            </a>
+
          {/each}
+
        {/each}
+
      {/snippet}
+
    </HoverPopover>
+
  {/if}
+
{/await}
modified src/components/RepoMetadata.svelte
@@ -11,6 +11,8 @@
  import NodeId from "@app/components/NodeId.svelte";
  import VisibilityBadge from "@app/components/VisibilityBadge.svelte";

+
  import JobCob from "./JobCob.svelte";
+

  interface Props {
    horizontal?: boolean;
    repo: RepoInfo;
@@ -111,13 +113,14 @@
    style:width="100%">
    <div class="metadata-section">
      <div class="metadata-section-title">Default branch</div>
-
      <span>
+
      <span class="global-flex">
        <span class="txt-selectable">{project.data.defaultBranch}</span>
        ->
        <Id
          id={project.meta.head}
          clipboard={project.meta.head}
          variant="commit" />
+
        <JobCob rid={repo.rid} commit={project.meta.head} />
      </span>
    </div>