This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

web: add repository pipelines page with mocked data

Adds the pipelines tab at /[handle]/[repo]/pipelines. The cards follow the
issue cards: what set the run off on top, then a status tag, a dropdown
listing the workflows, when it ran and how long it took. While a run is
still going the dropdown counts the finished ones, so 1/3.

The data is fake for now. It sits in components/repo/pipelines/mock.ts and
the page load filters it, so pointing this at bobbin later should only mean
rewriting +page.ts.

The search box and the push/pull-request tabs keep each other's state, so
searching inside a tab does not throw the tab away.

Along for the ride: Tag gained a danger colour for failed runs, DropdownItem
takes a class and now renders Button, and Button exposes a bindable ref.

The links to workflow logs point at a route that does not exist yet.

Signed-off-by: eti <eti@eti.tf>

+973 -7
+26
web/src/lib/components/repo/pipelines/PipelineCard.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import PipelineCard from "./PipelineCard.svelte"; 4 + import { pipelines } from "./mock"; 5 + 6 + const [running, mixed, passed, waiting, timedOut, cancelled] = pipelines; 7 + 8 + const { Story } = defineMeta({ 9 + title: "Repo/Pipelines/PipelineCard", 10 + component: PipelineCard, 11 + tags: ["autodocs"], 12 + args: { 13 + ownerHandle: "tangled.org", 14 + repoName: "core", 15 + pipeline: passed 16 + } 17 + }); 18 + </script> 19 + 20 + <Story name="Passed" /> 21 + <Story name="Running" args={{ pipeline: running }} /> 22 + <Story name="Mixed" args={{ pipeline: mixed }} /> 23 + <Story name="Timed out" args={{ pipeline: timedOut }} /> 24 + <Story name="Cancelled" args={{ pipeline: cancelled }} /> 25 + <Story name="Waiting for a spindle" args={{ pipeline: waiting }} /> 26 + <Story name="Plain" args={{ border: false, shadow: false, background: false }} />
+27
web/src/lib/components/repo/pipelines/PipelineCard.svelte
··· 1 + <script lang="ts"> 2 + import Card, { type CardVariants } from "$lib/components/ui/Card.svelte"; 3 + import PipelineCardContent from "./PipelineCardContent.svelte"; 4 + import type { PipelineSummary } from "$lib/components/repo/types"; 5 + 6 + interface Props { 7 + ownerHandle: string; 8 + repoName: string; 9 + pipeline: PipelineSummary; 10 + border?: CardVariants["border"]; 11 + shadow?: CardVariants["shadow"]; 12 + background?: CardVariants["background"]; 13 + } 14 + 15 + let { 16 + ownerHandle, 17 + repoName, 18 + pipeline, 19 + border = true, 20 + shadow = false, 21 + background = true 22 + }: Props = $props(); 23 + </script> 24 + 25 + <Card {border} {shadow} {background} padding="sm"> 26 + <PipelineCardContent {ownerHandle} {repoName} {pipeline} /> 27 + </Card>
+154
web/src/lib/components/repo/pipelines/PipelineCardContent.svelte
··· 1 + <script module lang="ts"> 2 + import { tv, type VariantProps } from "tailwind-variants"; 3 + import type { TagVariants } from "$lib/components/ui/Tag.svelte"; 4 + import type { PipelineStatus } from "$lib/components/repo/types"; 5 + 6 + export const pipelineCardContent = tv({ 7 + slots: { 8 + // does not wrap: a long branch name wraps inside the trigger group instead, so the 9 + // sha stays pinned top-right 10 + header: "flex items-start justify-between gap-3 pb-2", 11 + trigger: "flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1", 12 + triggerRow: "flex flex-wrap items-center gap-x-1.5 gap-y-1 text-foreground-default", 13 + triggerIcon: "size-4 shrink-0 text-foreground-muted", 14 + triggerVerb: "text-foreground-muted", 15 + triggerRef: "font-semibold", 16 + triggerArrow: "size-3 shrink-0 text-foreground-muted", 17 + pullLink: "typography-paragraph-small text-foreground-muted no-underline hover:underline", 18 + sha: "shrink-0 rounded-sm bg-background-inset px-2 py-0.5 typography-monospace-small text-foreground-default no-underline hover:underline", 19 + meta: "flex flex-wrap items-center justify-between text-foreground-muted", 20 + metaSub: "flex flex-wrap items-center gap-2", 21 + workflows: "ml-1", 22 + duration: "inline-flex items-center gap-1", 23 + durationIcon: "size-3 shrink-0" 24 + }, 25 + variants: { 26 + // the trigger line is only a link once a workflow exists to open 27 + linked: { 28 + true: { triggerRow: "no-underline hover:underline" }, 29 + false: {} 30 + } 31 + }, 32 + defaultVariants: { 33 + linked: false 34 + } 35 + }); 36 + 37 + export type PipelineCardContentVariants = VariantProps<typeof pipelineCardContent>; 38 + 39 + /** which Tag colour each status uses */ 40 + export const STATUS_TAG_COLORS: Record<PipelineStatus, TagVariants["color"]> = { 41 + pending: "gray", 42 + running: "warning", 43 + success: "success", 44 + failed: "danger", 45 + timeout: "warning", 46 + cancelled: "gray" 47 + }; 48 + </script> 49 + 50 + <script lang="ts"> 51 + import { resolve } from "$app/paths"; 52 + import ArrowLeft from "$icon/arrow-left"; 53 + import CirclePlay from "$icon/circle-play"; 54 + import Clock from "$icon/clock"; 55 + import GitCommitHorizontal from "$icon/git-commit-horizontal"; 56 + import GitPullRequest from "$icon/git-pull-request"; 57 + import TimeAgo from "$lib/components/ui/TimeAgo.svelte"; 58 + import { formatDuration } from "$lib/format"; 59 + import Tag from "$lib/components/ui/Tag.svelte"; 60 + import type { PipelineSummary } from "$lib/components/repo/types"; 61 + import { aggregateStatus, longSummary, STATUS_LABELS, totalDuration } from "./pipeline"; 62 + import { STATUS_ICONS } from "./PipelineStatusIcon.svelte"; 63 + import PipelineWorkflows from "./PipelineWorkflows.svelte"; 64 + import Separator from "$lib/components/ui/Separator.svelte"; 65 + 66 + interface Props { 67 + ownerHandle: string; 68 + repoName: string; 69 + pipeline: PipelineSummary; 70 + } 71 + 72 + let { ownerHandle, repoName, pipeline }: Props = $props(); 73 + 74 + const status = $derived(aggregateStatus(pipeline)); 75 + const duration = $derived(totalDuration(pipeline)); 76 + 77 + // links to the first workflow's logs, plain text until a spindle reports one 78 + // TODO: the workflow log route does not exist in the svelte app yet 79 + const href = $derived( 80 + pipeline.workflows.length > 0 81 + ? resolve( 82 + `/${ownerHandle}/${repoName}/pipelines/${pipeline.id}/workflow/${pipeline.workflows[0].name}` as "/" 83 + ) 84 + : undefined 85 + ); 86 + const commitHref = $derived(resolve(`/${ownerHandle}/${repoName}/commit/${pipeline.sha}` as "/")); 87 + 88 + const classes = $derived(pipelineCardContent({ linked: href !== undefined })); 89 + </script> 90 + 91 + {#snippet triggerLabel()} 92 + {#if pipeline.trigger.kind === "push"} 93 + <GitCommitHorizontal class={classes.triggerIcon()} aria-hidden="true" /> 94 + <span class={classes.triggerVerb()}>Push to</span> 95 + <span class={classes.triggerRef()}>{pipeline.trigger.targetRef}</span> 96 + {:else if pipeline.trigger.kind === "pull_request"} 97 + <GitPullRequest class={classes.triggerIcon()} aria-hidden="true" /> 98 + <span class={classes.triggerVerb()}>Pull request</span> 99 + <span class={classes.triggerRef()}>{pipeline.trigger.targetRef}</span> 100 + <ArrowLeft class={classes.triggerArrow()} aria-hidden="true" /> 101 + <span class={classes.triggerRef()}>{pipeline.trigger.sourceLabel}</span> 102 + {:else} 103 + <CirclePlay class={classes.triggerIcon()} aria-hidden="true" /> 104 + <span class={classes.triggerVerb()}>Manual dispatch</span> 105 + {/if} 106 + {/snippet} 107 + 108 + <div class={classes.header()}> 109 + <div class={classes.trigger()}> 110 + {#if href} 111 + <a {href} class={classes.triggerRow()}> 112 + {@render triggerLabel()} 113 + </a> 114 + {:else} 115 + <span class={classes.triggerRow()}> 116 + {@render triggerLabel()} 117 + </span> 118 + {/if} 119 + 120 + {#if pipeline.trigger.kind === "pull_request" && pipeline.trigger.pullPath} 121 + <a href={resolve(pipeline.trigger.pullPath as "/")} class={classes.pullLink()}> (view PR) </a> 122 + {/if} 123 + </div> 124 + 125 + {#if pipeline.sha} 126 + <a href={commitHref} class={classes.sha()}> 127 + {pipeline.sha.slice(0, 8)} 128 + </a> 129 + {/if} 130 + </div> 131 + 132 + <div class={classes.meta()}> 133 + <span class={classes.metaSub()}> 134 + <Tag class="mt-px" size="lg" color={STATUS_TAG_COLORS[status]} icon={STATUS_ICONS[status]} 135 + >{STATUS_LABELS[status]}</Tag 136 + > 137 + 138 + <span class={classes.workflows()} title={longSummary(pipeline) || undefined}> 139 + <PipelineWorkflows {ownerHandle} {repoName} {pipeline} /> 140 + </span> 141 + </span> 142 + 143 + <span class={classes.metaSub()}> 144 + <TimeAgo value={pipeline.createdAt} /> 145 + 146 + {#if duration > 0} 147 + <Separator variant="dot" /> 148 + <span class={classes.duration()}> 149 + <Clock class={classes.durationIcon()} aria-hidden="true" /> 150 + {formatDuration(duration)} 151 + </span> 152 + {/if} 153 + </span> 154 + </div>
+77
web/src/lib/components/repo/pipelines/PipelineEmptyState.svelte
··· 1 + <script module lang="ts"> 2 + import { tv } from "tailwind-variants"; 3 + 4 + export const pipelineEmptyState = tv({ 5 + slots: { 6 + root: "flex flex-col items-center gap-6 rounded-sm border border-border-default bg-background-default px-6 py-12 text-center", 7 + icon: "size-16 text-foreground-disabled", 8 + intro: "flex flex-col gap-2", 9 + heading: "typography-paragraph-large font-semibold text-foreground-default", 10 + body: "max-w-md typography-paragraph-small text-foreground-muted", 11 + steps: "flex max-w-md flex-col gap-3 text-left", 12 + step: "flex items-start gap-3", 13 + badge: 14 + "flex size-6 shrink-0 items-center justify-center rounded-full bg-background-inset typography-paragraph-small font-semibold text-foreground-default", 15 + stepBody: "typography-paragraph-small text-foreground-muted", 16 + link: "text-foreground-default underline hover:no-underline" 17 + } 18 + }); 19 + </script> 20 + 21 + <script lang="ts"> 22 + import { resolve } from "$app/paths"; 23 + import Layers2 from "$icon/layers-2"; 24 + import type { Snippet } from "svelte"; 25 + 26 + interface Props { 27 + ownerHandle: string; 28 + repoName: string; 29 + } 30 + 31 + let { ownerHandle, repoName }: Props = $props(); 32 + 33 + const settingsHref = $derived(resolve(`/${ownerHandle}/${repoName}/settings/pipelines` as "/")); 34 + const classes = pipelineEmptyState(); 35 + </script> 36 + 37 + {#snippet step(index: number, body: Snippet)} 38 + <li class={classes.step()}> 39 + <span class={classes.badge()}>{index}</span> 40 + <span class={classes.stepBody()}>{@render body()}</span> 41 + </li> 42 + {/snippet} 43 + 44 + <!-- an empty tab almost always means CI was never set up, so show the setup steps --> 45 + <div class={classes.root()}> 46 + <Layers2 class={classes.icon()} aria-hidden="true" /> 47 + 48 + <div class={classes.intro()}> 49 + <p class={classes.heading()}>No pipelines have been run yet</p> 50 + <p class={classes.body()}>Get started by configuring CI/CD for this repository.</p> 51 + </div> 52 + 53 + <ol class={classes.steps()}> 54 + {@render step(1, spindle)} 55 + {@render step(2, configure)} 56 + {@render step(3, trigger)} 57 + </ol> 58 + </div> 59 + 60 + {#snippet spindle()} 61 + Choose a spindle in your <a href={settingsHref} class={classes.link()}>repository settings</a> 62 + {/snippet} 63 + 64 + {#snippet configure()} 65 + <!-- plain anchor: Button's href only takes internal paths --> 66 + Configure your CI/CD 67 + <a 68 + href="https://docs.tangled.org/spindles.html#pipelines" 69 + target="_blank" 70 + rel="noopener" 71 + class={classes.link()}>pipeline</a 72 + > 73 + {/snippet} 74 + 75 + {#snippet trigger()} 76 + Trigger a workflow with a push or pull request 77 + {/snippet}
+22
web/src/lib/components/repo/pipelines/PipelineList.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import PipelineList from "./PipelineList.svelte"; 4 + import { pipelines } from "./mock"; 5 + 6 + const { Story } = defineMeta({ 7 + title: "Repo/Pipelines/PipelineList", 8 + component: PipelineList, 9 + tags: ["autodocs"], 10 + args: { 11 + ownerHandle: "tangled.org", 12 + repoName: "core", 13 + pipelines 14 + } 15 + }); 16 + </script> 17 + 18 + <Story name="Default" /> 19 + <!-- no runs at all: the setup steps --> 20 + <Story name="Empty" args={{ pipelines: [] }} /> 21 + <!-- runs exist, none match the trigger filter --> 22 + <Story name="Filtered out" args={{ pipelines: [], filtered: true }} />
+28
web/src/lib/components/repo/pipelines/PipelineList.svelte
··· 1 + <script lang="ts"> 2 + import EmptyState from "$lib/components/ui/EmptyState.svelte"; 3 + import PipelineCard from "./PipelineCard.svelte"; 4 + import PipelineEmptyState from "./PipelineEmptyState.svelte"; 5 + import type { PipelineSummary } from "$lib/components/repo/types"; 6 + 7 + interface Props { 8 + ownerHandle: string; 9 + repoName: string; 10 + pipelines: PipelineSummary[]; 11 + /** repo has runs, they just do not match — show "no match" instead of the setup steps */ 12 + filtered?: boolean; 13 + } 14 + 15 + let { ownerHandle, repoName, pipelines, filtered = false }: Props = $props(); 16 + </script> 17 + 18 + {#if pipelines.length > 0} 19 + <div class="flex flex-col gap-2"> 20 + {#each pipelines as pipeline (pipeline.id)} 21 + <PipelineCard {ownerHandle} {repoName} {pipeline} /> 22 + {/each} 23 + </div> 24 + {:else if filtered} 25 + <EmptyState message="No pipelines match your search." /> 26 + {:else} 27 + <PipelineEmptyState {ownerHandle} {repoName} /> 28 + {/if}
+17
web/src/lib/components/repo/pipelines/PipelineSearch.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import PipelineSearch from "./PipelineSearch.svelte"; 4 + 5 + const { Story } = defineMeta({ 6 + title: "Repo/Pipelines/PipelineSearch", 7 + component: PipelineSearch, 8 + tags: ["autodocs"], 9 + args: { 10 + filter: "all" 11 + } 12 + }); 13 + </script> 14 + 15 + <Story name="Default" /> 16 + <!-- a non-default tab rides along in a hidden field so searching keeps the filter --> 17 + <Story name="Within a trigger tab" args={{ filter: "pull_request" }} />
+78
web/src/lib/components/repo/pipelines/PipelineSearch.svelte
··· 1 + <script module lang="ts"> 2 + import { tv } from "tailwind-variants"; 3 + 4 + export const pipelineSearch = tv({ 5 + slots: { 6 + form: "flex w-full", 7 + field: "relative flex w-full items-center", 8 + input: "w-full", 9 + clear: "absolute right-0.5" 10 + }, 11 + variants: { 12 + // room for the clear button so the text does not run under it 13 + filled: { 14 + true: { input: "pr-9" }, 15 + false: {} 16 + } 17 + }, 18 + defaultVariants: { 19 + filled: false 20 + } 21 + }); 22 + </script> 23 + 24 + <script lang="ts"> 25 + import { page } from "$app/state"; 26 + import Search from "$icon/search"; 27 + import X from "$icon/x"; 28 + import Button from "$lib/components/ui/Button.svelte"; 29 + import Input from "$lib/components/ui/Input.svelte"; 30 + import type { PipelineFilter } from "./pipeline"; 31 + 32 + interface Props { 33 + /** kept in the form so searching does not reset the tab */ 34 + filter: PipelineFilter; 35 + } 36 + 37 + let { filter }: Props = $props(); 38 + 39 + let form: HTMLFormElement; 40 + // seed from the url so the query (and the clear button) survive a submit 41 + let value = $state(page.url.searchParams.get("q") ?? ""); 42 + 43 + const clear = () => { 44 + value = ""; 45 + form.requestSubmit(); 46 + }; 47 + 48 + const classes = $derived(pipelineSearch({ filled: value !== "" })); 49 + </script> 50 + 51 + <form bind:this={form} class={classes.form()} method="GET"> 52 + {#if filter !== "all"} 53 + <input type="hidden" name="trigger" value={filter} /> 54 + {/if} 55 + 56 + <div class={classes.field()}> 57 + <Input 58 + bind:value 59 + type="text" 60 + name="q" 61 + placeholder="Search pipelines..." 62 + aria-label="Search pipelines" 63 + iconLeft={Search} 64 + class={classes.input()} 65 + /> 66 + {#if value} 67 + <Button 68 + type="button" 69 + variant="ghost" 70 + size="sm" 71 + icon={X} 72 + onclick={clear} 73 + aria-label="Clear search" 74 + class={classes.clear()} 75 + /> 76 + {/if} 77 + </div> 78 + </form>
+57
web/src/lib/components/repo/pipelines/PipelineStatusIcon.svelte
··· 1 + <script module lang="ts"> 2 + import type { Component } from "svelte"; 3 + import type { SvelteHTMLElements } from "svelte/elements"; 4 + import { tv, type VariantProps } from "tailwind-variants"; 5 + import Check from "$icon/check"; 6 + import CircleDashed from "$icon/circle-dashed"; 7 + import CircleSlash from "$icon/circle-slash"; 8 + import ClockAlert from "$icon/clock-alert"; 9 + import RefreshCw from "$icon/refresh-cw"; 10 + import X from "$icon/x"; 11 + import type { PipelineStatus } from "$lib/components/repo/types"; 12 + 13 + /** same icon per status as the appview's workflowSymbol fragment */ 14 + export const STATUS_ICONS: Record<PipelineStatus, Component<SvelteHTMLElements["svg"]>> = { 15 + pending: CircleDashed, 16 + running: RefreshCw, 17 + success: Check, 18 + failed: X, 19 + timeout: ClockAlert, 20 + cancelled: CircleSlash 21 + }; 22 + 23 + // leave `status` off to let the icon inherit its colour, e.g. inside a Tag 24 + export const pipelineStatusIcon = tv({ 25 + base: "size-3 shrink-0", 26 + variants: { 27 + status: { 28 + pending: "text-foreground-muted", 29 + running: "text-foreground-warning", 30 + success: "text-foreground-success", 31 + failed: "text-foreground-danger", 32 + timeout: "text-foreground-warning", 33 + cancelled: "text-foreground-muted" 34 + } 35 + } 36 + }); 37 + 38 + export type PipelineStatusIconVariants = VariantProps<typeof pipelineStatusIcon>; 39 + </script> 40 + 41 + <script lang="ts"> 42 + interface Props { 43 + status: PipelineStatus; 44 + /** colour the icon itself; off inside a Tag */ 45 + colored?: boolean; 46 + class?: string; 47 + } 48 + 49 + let { status, colored = false, class: className }: Props = $props(); 50 + 51 + const Icon = $derived(STATUS_ICONS[status]); 52 + const classes = $derived( 53 + pipelineStatusIcon({ status: colored ? status : undefined, class: className }) 54 + ); 55 + </script> 56 + 57 + <Icon class={classes} aria-hidden="true" />
+21
web/src/lib/components/repo/pipelines/PipelineToolbar.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import PipelineToolbar from "./PipelineToolbar.svelte"; 4 + 5 + const { Story } = defineMeta({ 6 + title: "Repo/Pipelines/PipelineToolbar", 7 + component: PipelineToolbar, 8 + tags: ["autodocs"], 9 + args: { 10 + ownerHandle: "tangled.org", 11 + repoName: "core", 12 + filter: "all", 13 + total: 6 14 + } 15 + }); 16 + </script> 17 + 18 + <Story name="All" /> 19 + <Story name="Push" args={{ filter: "push", total: 3 }} /> 20 + <Story name="Pull request" args={{ filter: "pull_request", total: 2 }} /> 21 + <Story name="Single run" args={{ total: 1 }} />
+80
web/src/lib/components/repo/pipelines/PipelineToolbar.svelte
··· 1 + <script module lang="ts"> 2 + import { tv } from "tailwind-variants"; 3 + 4 + export const pipelineToolbar = tv({ 5 + slots: { 6 + // same grid as IssueToolbar: group, search, count. search gets its own row on mobile. 7 + root: "grid grid-cols-[auto_1fr_auto] gap-2", 8 + search: "col-span-3 sm:col-span-1 sm:col-start-2", 9 + group: "sm:row-start-1", 10 + count: 11 + "col-start-3 row-start-2 self-center justify-self-end typography-paragraph-small whitespace-nowrap text-foreground-muted sm:row-start-1", 12 + qualifier: "hidden sm:inline" 13 + } 14 + }); 15 + </script> 16 + 17 + <script lang="ts"> 18 + import { page } from "$app/state"; 19 + import { resolve } from "$app/paths"; 20 + import GitCommitHorizontal from "$icon/git-commit-horizontal"; 21 + import GitPullRequest from "$icon/git-pull-request"; 22 + import Layers2 from "$icon/layers-2"; 23 + import Button from "$lib/components/ui/Button.svelte"; 24 + import ButtonGroup, { segmentProps } from "$lib/components/ui/ButtonGroup.svelte"; 25 + import type { PipelineFilter } from "./pipeline"; 26 + import PipelineSearch from "./PipelineSearch.svelte"; 27 + 28 + interface Props { 29 + ownerHandle: string; 30 + repoName: string; 31 + filter: PipelineFilter; 32 + /** how many runs match the tab and query */ 33 + total: number; 34 + } 35 + 36 + let { ownerHandle, repoName, filter, total }: Props = $props(); 37 + 38 + const base = $derived(`/${ownerHandle}/${repoName}/pipelines`); 39 + 40 + // keep the query when switching tabs 41 + const segmentHref = (kind: PipelineFilter) => { 42 + const query = page.url.searchParams.get("q"); 43 + const suffix = query ? `&q=${encodeURIComponent(query)}` : ""; 44 + return resolve(`${base}?trigger=${kind}${suffix}` as "/"); 45 + }; 46 + 47 + const classes = pipelineToolbar(); 48 + </script> 49 + 50 + <div class={classes.root()}> 51 + <div class={classes.search()}> 52 + <PipelineSearch {filter} /> 53 + </div> 54 + 55 + <ButtonGroup class={classes.group()}> 56 + <!-- no manual segment: the appview only filters push and pull request --> 57 + <Button href={segmentHref("all")} {...segmentProps(filter === "all")} icon={Layers2}> 58 + All 59 + </Button> 60 + <Button 61 + href={segmentHref("push")} 62 + {...segmentProps(filter === "push")} 63 + icon={GitCommitHorizontal} 64 + > 65 + Push 66 + </Button> 67 + <Button 68 + href={segmentHref("pull_request")} 69 + {...segmentProps(filter === "pull_request")} 70 + icon={GitPullRequest} 71 + > 72 + <!-- nbsp: a plain space between the spans gets collapsed away --> 73 + <span>Pull<span class={classes.qualifier()}>&nbsp;request</span></span> 74 + </Button> 75 + </ButtonGroup> 76 + 77 + <span class={classes.count()}> 78 + {total} pipeline {total === 1 ? "run" : "runs"} 79 + </span> 80 + </div>
+81
web/src/lib/components/repo/pipelines/PipelineWorkflows.svelte
··· 1 + <script module lang="ts"> 2 + import { tv } from "tailwind-variants"; 3 + 4 + export const pipelineWorkflows = tv({ 5 + slots: { 6 + waiting: "inline-flex items-center gap-1 italic", 7 + trigger: "flex items-center gap-1 text-foreground-muted hover:underline", 8 + icon: "size-3 shrink-0", 9 + // Button hugs its children, so stretch that span to get a two-column row 10 + item: "[&>span]:w-full", 11 + row: "flex w-full items-center justify-between gap-2", 12 + name: "flex min-w-0 items-center gap-1.5", 13 + label: "truncate", 14 + outcome: "shrink-0 text-foreground-muted" 15 + } 16 + }); 17 + </script> 18 + 19 + <script lang="ts"> 20 + import ChevronDown from "$icon/chevron-down"; 21 + import Hourglass from "$icon/hourglass"; 22 + import Dropdown from "$lib/components/ui/Dropdown.svelte"; 23 + import DropdownItem from "$lib/components/ui/DropdownItem.svelte"; 24 + import { formatDuration } from "$lib/format"; 25 + import type { PipelineSummary } from "$lib/components/repo/types"; 26 + import { finishedCount, longSummary, STATUS_LABELS } from "./pipeline"; 27 + import PipelineStatusIcon from "./PipelineStatusIcon.svelte"; 28 + 29 + interface Props { 30 + ownerHandle: string; 31 + repoName: string; 32 + pipeline: PipelineSummary; 33 + } 34 + 35 + let { ownerHandle, repoName, pipeline }: Props = $props(); 36 + 37 + const count = $derived(pipeline.workflows.length); 38 + const finished = $derived(finishedCount(pipeline)); 39 + const noun = $derived(count === 1 ? "workflow" : "workflows"); 40 + // show the ratio only while something is still running 41 + const label = $derived(finished === count ? `${count} ${noun}` : `${finished}/${count} ${noun}`); 42 + 43 + const classes = pipelineWorkflows(); 44 + </script> 45 + 46 + {#if count === 0} 47 + <!-- no workflows yet, so nothing to open --> 48 + <span class={classes.waiting()}> 49 + <Hourglass class={classes.icon()} aria-hidden="true" /> 50 + Waiting for a spindle… 51 + </span> 52 + {:else} 53 + <Dropdown group="pipeline-workflows" align="left" menuClass="w-72" label={longSummary(pipeline)}> 54 + {#snippet trigger()} 55 + <span class={classes.trigger()}> 56 + {label} 57 + <ChevronDown class={classes.icon()} aria-hidden="true" /> 58 + </span> 59 + {/snippet} 60 + 61 + {#each pipeline.workflows as workflow (workflow.name)} 62 + <!-- TODO: the workflow log route does not exist in the svelte app yet --> 63 + <DropdownItem 64 + href={`/${ownerHandle}/${repoName}/pipelines/${pipeline.id}/workflow/${workflow.name}`} 65 + class={classes.item()} 66 + > 67 + <span class={classes.row()}> 68 + <span class={classes.name()}> 69 + <PipelineStatusIcon status={workflow.status} colored /> 70 + <span class={classes.label()}>{workflow.name}</span> 71 + </span> 72 + <span class={classes.outcome()}> 73 + {workflow.duration > 0 74 + ? formatDuration(workflow.duration) 75 + : STATUS_LABELS[workflow.status]} 76 + </span> 77 + </span> 78 + </DropdownItem> 79 + {/each} 80 + </Dropdown> 81 + {/if}
+88
web/src/lib/components/repo/pipelines/mock.ts
··· 1 + // fake data for the pipelines tab until it is wired to bobbin 2 + 3 + import type { PipelineSummary } from "$lib/components/repo/types"; 4 + 5 + const minutes = (n: number) => n * 60_000; 6 + 7 + // fixed base so the times do not depend on when this runs 8 + const base = Date.parse("2026-08-04T09:00:00Z"); 9 + const ago = (mins: number) => new Date(base - minutes(mins)).toISOString(); 10 + 11 + export const pipelines: PipelineSummary[] = [ 12 + // a run still in flight 13 + { 14 + id: "01K1XJ4WPQ0000000000000001", 15 + sha: "9f2c1ab4d8e7350f6b1c2d9e4a5b6c7d8e9f0a1b", 16 + createdAt: ago(3), 17 + trigger: { kind: "push", targetRef: "master" }, 18 + workflows: [ 19 + { name: "build", status: "success", duration: minutes(1) + 12_000 }, 20 + { name: "test", status: "running", duration: 0 }, 21 + { name: "lint", status: "pending", duration: 0 } 22 + ] 23 + }, 24 + // mixed outcome 25 + { 26 + id: "01K1XJ4WPQ0000000000000002", 27 + sha: "3d7e91c05fa2b8461d0e5c7a9b3f2e1d4c6a8b0f", 28 + createdAt: ago(47), 29 + trigger: { 30 + kind: "pull_request", 31 + targetRef: "master", 32 + sourceLabel: "oppi.li/core:fix-packfile-timeout", 33 + pullPath: "/tangled.org/core/pulls/42" 34 + }, 35 + workflows: [ 36 + { name: "build", status: "success", duration: minutes(2) + 4_000 }, 37 + { name: "test", status: "failed", duration: minutes(6) + 31_000, error: "3 tests failed" }, 38 + { name: "lint", status: "success", duration: 41_000 } 39 + ] 40 + }, 41 + // all green 42 + { 43 + id: "01K1XJ4WPQ0000000000000003", 44 + sha: "c1b4a7e2f9308d65b2a1c4e7f0d3b6a9c2e5f8b1", 45 + createdAt: ago(190), 46 + trigger: { kind: "push", targetRef: "master" }, 47 + workflows: [ 48 + { name: "build", status: "success", duration: minutes(1) + 58_000 }, 49 + { name: "test", status: "success", duration: minutes(5) + 12_000 }, 50 + { name: "lint", status: "success", duration: 39_000 } 51 + ] 52 + }, 53 + // a spindle that never answered 54 + { 55 + id: "01K1XJ4WPQ0000000000000004", 56 + sha: "7a0f3b8c1d2e4f5a6b7c8d9e0f1a2b3c4d5e6f70", 57 + createdAt: ago(320), 58 + trigger: { kind: "manual" }, 59 + workflows: [] 60 + }, 61 + // timed out 62 + { 63 + id: "01K1XJ4WPQ0000000000000005", 64 + sha: "e5d4c3b2a1908f7e6d5c4b3a2918f7e6d5c4b3a2", 65 + createdAt: ago(1_450), 66 + trigger: { 67 + kind: "pull_request", 68 + targetRef: "release-1.4", 69 + sourceLabel: "bump-deps" 70 + }, 71 + workflows: [ 72 + { name: "build", status: "success", duration: minutes(2) + 9_000 }, 73 + { name: "e2e", status: "timeout", duration: minutes(30) } 74 + ] 75 + }, 76 + // cancelled mid-run 77 + { 78 + id: "01K1XJ4WPQ0000000000000006", 79 + sha: "b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9", 80 + createdAt: ago(2_880), 81 + // the appview strips refs/heads/ and refs/tags/ before it reaches the card 82 + trigger: { kind: "push", targetRef: "v1.3.0" }, 83 + workflows: [ 84 + { name: "build", status: "cancelled", duration: 18_000 }, 85 + { name: "release", status: "cancelled", duration: 0 } 86 + ] 87 + } 88 + ];
+89
web/src/lib/components/repo/pipelines/pipeline.ts
··· 1 + // mirrors the helpers on types.Pipeline in types/pipeline.go 2 + 3 + import type { 4 + PipelineStatus, 5 + PipelineSummary, 6 + PipelineTriggerKind 7 + } from "$lib/components/repo/types"; 8 + 9 + /** the trigger tabs */ 10 + export type PipelineFilter = "all" | PipelineTriggerKind; 11 + 12 + /** what search looks at. a real bobbin query has to cover the same fields. */ 13 + export const matchesQuery = (pipeline: PipelineSummary, query: string): boolean => { 14 + const needle = query.trim().toLowerCase(); 15 + if (!needle) return true; 16 + 17 + const haystack = [ 18 + pipeline.sha, 19 + pipeline.trigger.kind === "manual" ? "manual dispatch" : pipeline.trigger.targetRef, 20 + pipeline.trigger.kind === "pull_request" ? pipeline.trigger.sourceLabel : "", 21 + ...pipeline.workflows.map((workflow) => workflow.name) 22 + ]; 23 + return haystack.some((field) => field.toLowerCase().includes(needle)); 24 + }; 25 + 26 + /** the order the breakdown lists statuses in */ 27 + export const STATUS_ORDER: PipelineStatus[] = [ 28 + "success", 29 + "failed", 30 + "timeout", 31 + "cancelled", 32 + "running", 33 + "pending" 34 + ]; 35 + 36 + export const STATUS_LABELS: Record<PipelineStatus, string> = { 37 + pending: "Pending", 38 + running: "Running", 39 + success: "Passed", 40 + failed: "Failed", 41 + timeout: "Timed out", 42 + cancelled: "Cancelled" 43 + }; 44 + 45 + export const statusCounts = (pipeline: PipelineSummary): Record<PipelineStatus, number> => { 46 + const counts = { 47 + pending: 0, 48 + running: 0, 49 + success: 0, 50 + failed: 0, 51 + timeout: 0, 52 + cancelled: 0 53 + }; 54 + for (const workflow of pipeline.workflows) counts[workflow.status]++; 55 + return counts; 56 + }; 57 + 58 + /** one status for the whole run. anything still going wins, so an in-flight run says so. */ 59 + export const aggregateStatus = (pipeline: PipelineSummary): PipelineStatus => { 60 + // no workflows yet means no spindle has picked it up 61 + if (pipeline.workflows.length === 0) return "pending"; 62 + 63 + const counts = statusCounts(pipeline); 64 + if (counts.running > 0) return "running"; 65 + if (counts.pending > 0) return "pending"; 66 + if (counts.failed > 0) return "failed"; 67 + if (counts.timeout > 0) return "timeout"; 68 + if (counts.cancelled > 0) return "cancelled"; 69 + return "success"; 70 + }; 71 + 72 + /** how many workflows are done — everything except pending and running */ 73 + export const finishedCount = (pipeline: PipelineSummary): number => { 74 + const counts = statusCounts(pipeline); 75 + return counts.success + counts.failed + counts.timeout + counts.cancelled; 76 + }; 77 + 78 + /** "2/3 passed, 1/3 failed" */ 79 + export const longSummary = (pipeline: PipelineSummary): string => { 80 + const counts = statusCounts(pipeline); 81 + const total = pipeline.workflows.length; 82 + return STATUS_ORDER.filter((status) => counts[status] > 0) 83 + .map((status) => `${counts[status]}/${total} ${STATUS_LABELS[status].toLowerCase()}`) 84 + .join(", "); 85 + }; 86 + 87 + /** sum of the workflows, not wall time across the run */ 88 + export const totalDuration = (pipeline: PipelineSummary): number => 89 + pipeline.workflows.reduce((sum, workflow) => sum + workflow.duration, 0);
+36
web/src/lib/components/repo/types.ts
··· 49 49 createdAt: string; 50 50 commentCount: number; 51 51 } 52 + 53 + /** the spindle's workflow states, same strings as sh.tangled.ci.pipeline */ 54 + export type PipelineStatus = "pending" | "running" | "success" | "failed" | "timeout" | "cancelled"; 55 + 56 + export interface WorkflowSummary { 57 + name: string; 58 + status: PipelineStatus; 59 + /** wall time in ms; 0 until the workflow finishes */ 60 + duration: number; 61 + /** first line of the spindle's error, when it failed before running */ 62 + error?: string; 63 + } 64 + 65 + export type PipelineTrigger = 66 + | { kind: "push"; targetRef: string } 67 + | { 68 + kind: "pull_request"; 69 + targetRef: string; 70 + /** where the head came from — `handle/repo:branch` when it is a fork */ 71 + sourceLabel: string; 72 + /** the pull page, absent when the pull record is gone */ 73 + pullPath?: string; 74 + } 75 + | { kind: "manual" }; 76 + 77 + export type PipelineTriggerKind = PipelineTrigger["kind"]; 78 + 79 + export interface PipelineSummary { 80 + id: string; 81 + /** the commit the spindle checked out */ 82 + sha: string; 83 + createdAt: string; 84 + trigger: PipelineTrigger; 85 + /** empty while the spindle has not reported back yet */ 86 + workflows: WorkflowSummary[]; 87 + }
+4
web/src/lib/components/ui/Button.svelte
··· 126 126 class?: string; 127 127 loading?: boolean; 128 128 spinnerClass?: string; 129 + ref?: HTMLAnchorElement | HTMLButtonElement; 129 130 children?: Snippet; 130 131 } 131 132 ··· 141 142 class: className, 142 143 loading = false, 143 144 spinnerClass, 145 + ref = $bindable(), 144 146 children, 145 147 ...rest 146 148 }: Props = $props(); ··· 176 178 177 179 {#if href} 178 180 <a 181 + bind:this={ref} 179 182 {href} 180 183 class={classes} 181 184 data-variant={variant} ··· 187 190 </a> 188 191 {:else} 189 192 <button 193 + bind:this={ref} 190 194 {type} 191 195 disabled={disabled || loading} 192 196 class={classes}
+4 -2
web/src/lib/components/ui/DropdownItem.svelte
··· 29 29 icon?: Component<SvelteHTMLElements["svg"]>; 30 30 danger?: DropdownItemVariants["danger"]; 31 31 onclick?: (event: MouseEvent) => void; 32 + /** Button hugs its children, so a two-column row needs `[&>span]:w-full` here. */ 33 + class?: string; 32 34 children: Snippet; 33 35 } 34 36 35 - let { href, icon, danger = false, onclick, children }: Props = $props(); 36 - const classes = $derived(dropdownItem({ danger })); 37 + let { href, icon, danger = false, onclick, class: className, children }: Props = $props(); 38 + const classes = $derived(dropdownItem({ danger, class: className })); 37 39 38 40 const closeDropdown = getContext<(() => void) | undefined>("dropdown-close"); 39 41 const registerItem = getContext<((el: HTMLElement) => () => void) | undefined>(
+7 -2
web/src/lib/components/ui/Separator.svelte
··· 1 1 <script lang="ts"> 2 2 interface Props { 3 + variant?: string; 3 4 class?: string; 4 5 } 5 6 6 - let { class: className = "" }: Props = $props(); 7 + let { variant: variant = "line", class: className = "" }: Props = $props(); 7 8 </script> 8 9 9 - <hr class="border-none bg-border-default w-full min-h-px {className}" /> 10 + {#if variant === "line"} 11 + <hr class="min-h-px w-full border-none bg-border-default {className}" /> 12 + {:else if variant === "dot"} 13 + <span class="text-inherit {className}">·</span> 14 + {/if}
+5 -1
web/src/lib/components/ui/Tag.stories.svelte
··· 10 10 argTypes: { 11 11 color: { 12 12 control: { type: "inline-radio" }, 13 - options: ["default", "gray"] 13 + options: ["default", "gray", "success", "danger", "warning", "info"] 14 14 } 15 15 }, 16 16 args: { ··· 21 21 22 22 <Story name="Default">Tag</Story> 23 23 <Story name="Gray" args={{ color: "gray" }}>Tag</Story> 24 + <Story name="Success" args={{ color: "success" }}>Passed</Story> 25 + <Story name="Danger" args={{ color: "danger" }}>Failed</Story> 26 + <Story name="Warning" args={{ color: "warning" }}>Timed out</Story> 27 + <Story name="Info" args={{ color: "info" }}>Primary</Story> 24 28 <Story name="WithIcon" args={{ icon: Hash }}>topic</Story>
+5 -2
web/src/lib/components/ui/Tag.svelte
··· 9 9 gray: "border border-transparent bg-background-inset text-foreground-default", 10 10 success: 11 11 "border border-transparent bg-background-success-subtle text-foreground-success-strong", 12 + danger: 13 + "border border-transparent bg-background-danger-subtle text-foreground-danger-strong", 12 14 warning: "border border-transparent bg-background-warning-subtle text-foreground-warning", 13 15 info: "border border-transparent bg-background-info-subtle text-foreground-info-strong" 14 16 }, 15 17 size: { 16 18 sm: "px-1 typography-paragraph-mini", 17 - md: "px-1.5 py-0.5 typography-paragraph-small" 19 + md: "px-1.5 py-0.5 typography-paragraph-small", 20 + lg: "px-2 py-1 typography-paragraph-regular font-normal" 18 21 } 19 22 }, 20 23 defaultVariants: { ··· 55 58 56 59 <span class={classes} {...rest}> 57 60 {#if Icon} 58 - <Icon class="size-3" aria-hidden="true" /> 61 + <Icon class="size-3 mt-px" aria-hidden="true" /> 59 62 {/if} 60 63 {#if children} 61 64 {@render children()}
+17
web/src/lib/format.ts
··· 53 53 return `${Math.floor(duration)}y ${suffix}`; 54 54 }; 55 55 56 + // the appview's durationFmt: non-zero chunks, largest first ("1h 3m 20s"). 57 + // under a second reads "0s", so an instant run still differs from one that never ran. 58 + export const formatDuration = (ms: number): string => { 59 + if (!Number.isFinite(ms) || ms < 0) return ""; 60 + const total = Math.floor(ms / 1000); 61 + const chunks: [number, string][] = [ 62 + [Math.floor(total / 86400), "d"], 63 + [Math.floor(total / 3600) % 24, "h"], 64 + [Math.floor(total / 60) % 60, "m"], 65 + [total % 60, "s"] 66 + ]; 67 + const parts = chunks 68 + .filter(([amount]) => amount !== 0) 69 + .map(([amount, unit]) => `${amount}${unit}`); 70 + return parts.length > 0 ? parts.join(" ") : "0s"; 71 + }; 72 + 56 73 export const formatDate = (input: string | Date): string => { 57 74 const date = typeof input === "string" ? new Date(input) : input; 58 75 return Number.isNaN(date.getTime()) ? "" : dtf.format(date);
+25
web/src/routes/[handle]/[repo]/pipelines/+page.svelte
··· 1 + <script lang="ts"> 2 + import PipelineList from "$lib/components/repo/pipelines/PipelineList.svelte"; 3 + import PipelineToolbar from "$lib/components/repo/pipelines/PipelineToolbar.svelte"; 4 + import TabPanel from "$lib/components/ui/TabPanel.svelte"; 5 + 6 + let { data } = $props(); 7 + </script> 8 + 9 + <TabPanel padded={false} class="p-4"> 10 + <PipelineToolbar 11 + ownerHandle={data.repo.ownerHandle} 12 + repoName={data.repo.name} 13 + filter={data.filter} 14 + total={data.pipelines.length} 15 + /> 16 + </TabPanel> 17 + 18 + <div class="mt-2"> 19 + <PipelineList 20 + ownerHandle={data.repo.ownerHandle} 21 + repoName={data.repo.name} 22 + pipelines={data.pipelines} 23 + filtered={data.hasAny} 24 + /> 25 + </div>
+25
web/src/routes/[handle]/[repo]/pipelines/+page.ts
··· 1 + // TODO: mocked. swap for sh.tangled.pipeline.listPipelines via bobbin, the way 2 + // issues/+page.ts does. a real load hands `q` to the server instead of matching here. 3 + import { pipelines as mockPipelines } from "$lib/components/repo/pipelines/mock"; 4 + import { matchesQuery, type PipelineFilter } from "$lib/components/repo/pipelines/pipeline"; 5 + import type { PageLoad } from "./$types"; 6 + 7 + const FILTERS: PipelineFilter[] = ["all", "push", "pull_request"]; 8 + 9 + export const load: PageLoad = async (event) => { 10 + const requested = event.url.searchParams.get("trigger") as PipelineFilter | null; 11 + const filter = requested && FILTERS.includes(requested) ? requested : "all"; 12 + const query = event.url.searchParams.get("q") ?? ""; 13 + 14 + const pipelines = mockPipelines 15 + .filter((pipeline) => filter === "all" || pipeline.trigger.kind === filter) 16 + .filter((pipeline) => matchesQuery(pipeline, query)); 17 + 18 + return { 19 + filter, 20 + query, 21 + pipelines, 22 + // lets the list tell "no runs at all" from "nothing matched" 23 + hasAny: mockPipelines.length > 0 24 + }; 25 + };