From 430dd7320497b9e7c44b4059882bf228f3131831 Mon Sep 17 00:00:00 2001 From: whereareiam Date: Thu, 22 Oct 2026 20:09:00 +0200 Subject: [PATCH] feat(actions): GitHub's Actions sidebar, run list, job steps and job graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The run list moves onto the shared sidebar layout: All workflows with a home icon, a "Workflows" group, Runners for administrators and the collapse toggle, with the list centred beside it. The heading gets GitHub's New workflow button and a full-width filter; the box header and rows take GitHub's heights and weights, and the meta line reads "#2: Commit 9dac64a" with linked commit and actor. In a run, the link back to the workflow gets GitHub's arrow icon and padded hover area, finished steps are grey and a failed job opens on its failing step, the open step uses the console's active background, steps are 36px, the job list drops durations, Delete moves into a "…" menu beside Re-run, the summary shows Artifacts instead of a job count, and the log search no longer covers the job's status line on phones. The summary's jobs become GitHub's graph: stages from each job's `needs`, read from the workflow file the run used, 260px cards 56px apart with dots on their edges and rounded 2px links, jobs without links stacked in one card, and the hovered job's links highlighted while unrelated jobs dim. The shared sidebar gains a group label and a bottom footer, and its toggle uses a delegated click so it survives Forgejo refreshing the run list. Co-Authored-By: Claude Opus 5.5 --- components/common/sidebar/sidebar.css | 16 ++ components/common/sidebar/sidebar.js | 28 ++-- components/common/sidebar/sidebar.test.ts | 13 +- public/actions-list.css | 174 +++----------------- public/actions-run.css | 189 ++++++++++++++++++---- public/assets/js/actions-run-graph.js | 120 ++++++++++++++ public/assets/js/actions-run.js | 153 ++++++++++++++++-- src/actions-run-graph.test.ts | 95 +++++++++++ templates/repo/actions/list.tmpl | 7 +- templates/repo/actions/list_inner.tmpl | 31 +--- templates/repo/actions/runs_list.tmpl | 8 +- templates/repo/actions/sidebar.tmpl | 23 +++ templates/repo/actions/view.tmpl | 3 +- 13 files changed, 616 insertions(+), 244 deletions(-) create mode 100644 public/assets/js/actions-run-graph.js create mode 100644 src/actions-run-graph.test.ts create mode 100644 templates/repo/actions/sidebar.tmpl diff --git a/components/common/sidebar/sidebar.css b/components/common/sidebar/sidebar.css index 30775e2c..3215d1fa 100644 --- a/components/common/sidebar/sidebar.css +++ b/components/common/sidebar/sidebar.css @@ -144,3 +144,19 @@ text-overflow: ellipsis; white-space: nowrap; } + +/* A small heading inside a nav group, like GitHub's "Workflows". */ +.theme-sidebar-label { + display: block; + padding: 8px 16px 4px; + color: var(--color-text-light-1); + font-size: 12px; + font-weight: 600; + line-height: 18px; +} + +/* Secondary links pinned to the bottom of the sidebar, above the collapse toggle. */ +.theme-sidebar-content > .theme-sidebar-footer { + margin-top: auto; + border-top: 1px solid color-mix(in srgb, var(--color-light-border) 70%, transparent); +} diff --git a/components/common/sidebar/sidebar.js b/components/common/sidebar/sidebar.js index e66203eb..f3bbe8b3 100644 --- a/components/common/sidebar/sidebar.js +++ b/components/common/sidebar/sidebar.js @@ -12,20 +12,20 @@ for (const button of page.querySelectorAll("[data-theme-sidebar-toggle]")) button.setAttribute("aria-expanded", String(!collapsed)); }; - for (const button of page.querySelectorAll("[data-theme-sidebar-toggle]")) { - button.addEventListener("click", () => { - collapsed = !collapsed; - update(); - const next = page.querySelector(collapsed ? "[data-theme-sidebar-expand]" : ".theme-sidebar-toggle"); - next?.focus(); - if (!mobile.matches) - try { - localStorage.setItem(page.dataset.themeSidebar, String(collapsed)); - } catch { - /* Optional desktop preference. */ - } - }); - } + // Delegated, so toggles inside content that Forgejo refreshes in place (the Actions run list) keep working. + page.addEventListener("click", event => { + if (!event.target.closest?.("[data-theme-sidebar-toggle]")) return; + collapsed = !collapsed; + update(); + const next = page.querySelector(collapsed ? "[data-theme-sidebar-expand]" : ".theme-sidebar-toggle"); + next?.focus(); + if (!mobile.matches) + try { + localStorage.setItem(page.dataset.themeSidebar, String(collapsed)); + } catch { + /* Optional desktop preference. */ + } + }); mobile.addEventListener("change", () => { collapsed = mobile.matches; update(); diff --git a/components/common/sidebar/sidebar.test.ts b/components/common/sidebar/sidebar.test.ts index 0c4e178c..702c904b 100644 --- a/components/common/sidebar/sidebar.test.ts +++ b/components/common/sidebar/sidebar.test.ts @@ -27,6 +27,7 @@ function fixture({ mobile = false, stored = "false", storageBlocked = false } = const collapse = control(); const classes = new Set(); const page = { + ...events(), dataset: { themeSidebar: "org_repositories_sidebar_collapsed" }, classList: { toggle: (name: string, value: boolean) => (value ? classes.add(name) : classes.delete(name)) }, querySelectorAll: () => [expand, collapse], @@ -37,19 +38,21 @@ function fixture({ mobile = false, stored = "false", storageBlocked = false } = querySelectorAll: () => [page], }; runInNewContext(script, { document, matchMedia: () => media, localStorage: storage }); - return { media, storage, expand, collapse, classes }; + // Clicks reach the page, which finds the toggle the event came from. + const click = (control: unknown) => page.emit("click", { target: { closest: () => control } }); + return { media, storage, expand, collapse, classes, click }; } describe("shared theme components", () => { it("restores the desktop sidebar preference and moves focus to the available toggle", () => { const f = fixture({ stored: "true" }); expect(f.classes.has("theme-sidebar-collapsed")).toBe(true); - f.expand.emit("click"); + f.click(f.expand); expect(f.classes.has("theme-sidebar-collapsed")).toBe(false); expect(f.collapse.focus).toHaveBeenCalled(); expect(f.expand.setAttribute).toHaveBeenLastCalledWith("aria-expanded", "true"); expect(f.storage.setItem).toHaveBeenCalledWith("org_repositories_sidebar_collapsed", "false"); - f.collapse.emit("click"); + f.click(f.collapse); expect(f.expand.focus).toHaveBeenCalled(); expect(f.classes.has("theme-sidebar-collapsed")).toBe(true); }); @@ -57,7 +60,7 @@ describe("shared theme components", () => { it("starts collapsed on mobile and never overwrites the desktop preference there", () => { const f = fixture({ mobile: true }); expect(f.classes.has("theme-sidebar-collapsed")).toBe(true); - f.expand.emit("click"); + f.click(f.expand); expect(f.classes.has("theme-sidebar-collapsed")).toBe(false); expect(f.storage.setItem).not.toHaveBeenCalled(); f.media.matches = false; @@ -67,7 +70,7 @@ describe("shared theme components", () => { it("remains usable when storage is blocked", () => { const f = fixture({ storageBlocked: true }); - f.collapse.emit("click"); + f.click(f.collapse); expect(f.classes.has("theme-sidebar-collapsed")).toBe(true); }); }); diff --git a/public/actions-list.css b/public/actions-list.css index 08dab760..21132b51 100644 --- a/public/actions-list.css +++ b/public/actions-list.css @@ -1,132 +1,39 @@ .page-content.repository.actions.github-actions-list:not(.settings) { - --actions-sidebar-width: 336px; padding-top: 0; margin-top: 0 !important; - .actions-list-shell { - width: 100%; - } - .actions-layout { - display: grid; - grid-template-columns: var(--actions-sidebar-width) minmax(0, 1fr); - min-height: calc(100dvh - 100px); - } - .actions-sidebar { - padding: 16px 8px; - border-right: 1px solid var(--color-light-border); - } - .actions-sidebar-heading { - display: flex; - align-items: center; - justify-content: space-between; - padding: 4px 8px 8px; - gap: 8px; - } - .actions-sidebar-heading h2 { - margin: 0; - font-size: 20px; - line-height: 28px; - font-weight: 600; - } - .actions-sidebar-heading .ui.button { - height: 28px; - min-height: 28px; - padding: 3px 8px; - font-size: 12px; - margin: 0; - } - .actions-menu.ui.menu { - width: 100%; - border: 0; - background: transparent; - box-shadow: none; - margin: 0; - } - .actions-menu::before, - .actions-menu .item::before { - display: none; - } - .actions-menu .item { - overflow: visible; - display: flex; - align-items: center; - gap: 8px; - position: relative; - min-height: 32px; - margin: 0 8px; - padding: 6px 8px; - border: 0; - border-radius: var(--border-radius); - color: var(--color-text); - font-size: 14px; - line-height: 20px; - background: transparent; - } - .actions-workflow-divider { - height: 1px; - margin: 8px 0; - background: var(--color-light-border); - } - .actions-menu .item .content { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - .actions-menu .item:hover { - text-decoration: none; - background: var(--github-control-transparent-bgColor-hover); - } - .actions-menu .item.active { - background: var(--github-control-transparent-bgColor-active); - font-weight: 600; - } - .actions-menu.ui.menu .item.active::before { - display: block !important; - content: ""; - position: absolute; - left: -8px; - top: 4px; - height: 24px; - width: 4px; - background: var(--color-primary); - border-radius: var(--border-radius); - } + /* Heading, subtitle and a full-width search above the run list, as on GitHub. */ .actions-main { min-width: 0; - padding: 16px 24px 32px; } .actions-heading { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - margin-bottom: 24px; + margin-bottom: 0; } .actions-heading h1 { - font-size: 20px; - line-height: 28px; - font-weight: 400; - margin: 4px 0 0; overflow-wrap: anywhere; } - .actions-heading p { - color: var(--color-text-light-1); + .actions-subtitle { + margin: 0 0 8px; font-size: 14px; line-height: 21px; - margin: 0; } .actions-search { - width: 300px; - flex-shrink: 0; + width: 100%; + margin-bottom: 16px; + } + .theme-sidebar .actions-workflow-error { + display: inline-flex; + margin-left: auto; + & > .svg { + color: var(--color-red); + } } .actions-filters.ui.menu { display: flex; align-items: center; gap: 8px; - min-height: 64px; + min-height: 48px; margin: 0; - padding: 16px; + padding: 8px 8px 8px 16px; border: 1px solid var(--color-light-border); border-bottom: 0; border-radius: var(--border-radius) var(--border-radius) 0 0; @@ -134,6 +41,7 @@ } .actions-run-count { margin-right: auto; + color: var(--color-text-light-1); font-size: 14px; line-height: 21px; white-space: nowrap; @@ -202,7 +110,6 @@ visibility: hidden; } } - .actions-menu .item:focus-visible, .actions-run-options:focus-visible { outline: 2px solid var(--color-primary); outline-offset: -2px; @@ -225,8 +132,8 @@ display: grid; grid-template-columns: 16px minmax(0, 1fr) minmax(250px, 45%); gap: 8px; - min-height: 78px; - padding: 16px; + min-height: 67px; + padding: 12px 16px; } .run-list > .flex-item[hidden] { display: none; @@ -241,7 +148,7 @@ .run-list .flex-item-title { font-size: 16px; line-height: 24px; - font-weight: 600; + font-weight: 500; min-width: 0; } .run-list .flex-item-title > a { @@ -256,13 +163,13 @@ overflow-wrap: anywhere; } .run-list .flex-item-body a { - text-decoration: underline; + color: var(--color-primary); } .run-list .flex-item-trailing { width: 100%; max-width: none; display: grid; - grid-template-columns: minmax(0, 1fr) 156px 28px; + grid-template-columns: minmax(0, 1fr) 156px 32px; gap: 8px; margin: 0; min-width: 0; @@ -272,6 +179,7 @@ justify-self: start; min-width: 0; max-width: 100%; + padding: 2px 6px; font-size: 12px; line-height: 18px; border: 0; @@ -288,8 +196,8 @@ .actions-run-options { display: grid; place-items: center; - width: 28px; - height: 28px; + width: 32px; + height: 32px; padding: 0; border: 0; border-radius: var(--border-radius); @@ -328,12 +236,11 @@ display: none; } @media (max-width: 1200px) { - --actions-sidebar-width: 256px; .run-list > .flex-item { grid-template-columns: 16px minmax(0, 1fr) 210px; } .run-list .flex-item-trailing { - grid-template-columns: minmax(0, 1fr) 28px; + grid-template-columns: minmax(0, 1fr) 32px; } .run-list .run-list-ref { grid-column: 1; @@ -360,37 +267,6 @@ margin-top: 4px; } - .actions-layout { - display: block; - } - .actions-sidebar { - border-right: 0; - border-bottom: 1px solid var(--color-light-border); - padding: 8px; - } - .actions-menu.ui.menu { - display: flex; - flex-direction: row; - overflow: auto; - } - .actions-menu .item { - flex: 0 0 auto; - } - .actions-workflow-divider, - .actions-menu.ui.menu .item.active::before { - display: none !important; - } - .actions-main { - padding: 16px; - } - .actions-heading { - flex-direction: column; - gap: 12px; - margin-bottom: 16px; - } - .actions-search { - width: 100%; - } .actions-filters.ui.menu { flex-wrap: wrap; padding: 8px; diff --git a/public/actions-run.css b/public/actions-run.css index de9f487f..3d2ecc4e 100644 --- a/public/actions-run.css +++ b/public/actions-run.css @@ -10,12 +10,29 @@ .action-view-header.action-view-header { margin: 0 0 16px; } + /* GitHub's parent link: an arrow icon and the workflow, with a padded hover area that lines up with the title. */ .actions-run-back { - display: inline-block; + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: -8px; + padding: 4px 8px; + border-radius: var(--border-radius); color: var(--color-text-light-1); font-size: 14px; - line-height: 21px; - margin-bottom: 8px; + line-height: 17.5px; + &:hover { + color: var(--color-text); + background: var(--github-control-transparent-bgColor-hover); + text-decoration: none; + } + &:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: -2px; + } + & > svg { + flex-shrink: 0; + } } .action-info-summary { gap: 16px; @@ -27,7 +44,7 @@ } .action-info-summary-title-text { font-size: 20px; - line-height: 28px; + line-height: 30px; font-weight: 600; margin: 0; overflow-wrap: anywhere; @@ -48,6 +65,24 @@ margin: 0; font-size: 14px; } + /* Delete run lives in the "…" menu, which clicks this hidden button. */ + .action-info-summary-actions #delete-run { + display: none; + } + .actions-run-menu { + order: 1; + } + .actions-run-menu > summary.theme-control { + justify-content: center; + width: 32px; + height: 32px; + min-height: 32px; + padding: 0; + color: var(--color-text-light-1); + } + .actions-run-menu .theme-dropdown-panel { + width: 200px; + } .action-summary { display: none; font-size: 12px; @@ -130,8 +165,9 @@ font-size: 14px; line-height: 20px; } + /* GitHub's job list carries no durations; the summary's job cards keep them. */ .job-brief-item .step-summary-duration { - font-size: 12px; + display: none; } .actions-summary-link, .actions-workflow-source, @@ -155,7 +191,6 @@ text-decoration: none; &:hover { text-decoration: none; - color: var(--color-primary); } &:focus-visible { outline: 2px solid var(--color-primary); @@ -184,14 +219,14 @@ } .job-info-header { height: 80px !important; - padding: 16px !important; + padding: 16px 16px 16px 24px !important; background: var(--color-menu); border-bottom: 1px solid var(--color-light-border); } .job-info-header-title { color: var(--color-primary) !important; font-size: 16px; - line-height: 28px; + line-height: 24px; font-weight: 600; } .job-info-header-right { @@ -215,24 +250,29 @@ margin: 0 !important; } .job-step-summary.job-step-summary { - height: 40px; - min-height: 40px; + height: 36px; + min-height: 36px; padding: 8px !important; gap: 8px; color: var(--color-text-light-1); - font: 14px/21px var(--fonts-proportional); + font: 14px/20px var(--fonts-proportional); border-radius: var(--border-radius); } /* Skipped steps are muted on GitHub; the theme's grey text is the regular text colour. */ .job-step-summary .octicon-skip.text.grey { color: var(--color-text-light-2) !important; } + /* Finished steps are grey on GitHub; only failures keep their colour. */ + .job-step-summary .octicon-check-circle-fill { + color: var(--color-text-light-1) !important; + } .job-step-summary:hover { background: var(--github-control-transparent-bgColor-hover); } .job-step-summary.selected { top: 80px; - background: var(--color-box-header) !important; + color: var(--color-text); + background: var(--color-console-active-bg) !important; } .job-step-summary .step-summary-duration { font: 12px/18px var(--fonts-proportional); @@ -310,8 +350,8 @@ border-radius: var(--border-radius); } .actions-run-fact strong { - font-size: 14px; - line-height: 21px; + font-size: 16px; + line-height: 24px; text-transform: capitalize; } .actions-workflow-card { @@ -326,33 +366,119 @@ font-weight: 600; color: var(--color-text); } - .actions-job-cards { + /* GitHub's job graph: a 260px card per linked job in stage columns 56px apart, jobs without links stacked in one + card, and 2px links between the dots on the cards' edges. It scrolls sideways when it is wider than the box. */ + .actions-job-graph { + overflow-x: auto; + overflow-y: hidden; + padding: 24px 0 8px; + } + .actions-job-canvas { + position: relative; + display: inline-flex; + align-items: flex-start; + gap: 56px; + padding: 16px 8px; + } + .actions-job-links { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; + } + .actions-job-links path { + fill: none; + stroke: var(--color-light-border); + stroke-width: 2px; + } + .actions-job-links path.is-active { + stroke: var(--github-bgColor-accent-emphasis); + stroke-width: 3px; + } + .actions-job-stage { display: flex; - flex-wrap: wrap; - gap: 24px; - padding: 48px 0; + flex-direction: column; + gap: 56px; + } + .actions-job-node { + position: relative; + width: 260px; + border: 1px solid var(--color-light-border); + border-radius: var(--border-radius); + background: var(--color-box-header); } .actions-job-card { display: flex; align-items: center; gap: 8px; - min-width: 200px; - max-width: 100%; padding: 12px; - border: 1px solid var(--color-light-border); border-radius: var(--border-radius); - background: var(--color-box-header); color: var(--color-text); } .actions-job-card strong { - overflow-wrap: anywhere; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; font-size: 14px; + line-height: 20px; + font-weight: 500; } .actions-job-duration { + flex-shrink: 0; margin-left: auto; font-size: 12px; + line-height: 18px; color: var(--color-text-light-1); } + .actions-job-port { + position: absolute; + top: 50%; + width: 16px; + height: 16px; + margin-top: -8px; + border-radius: 50%; + pointer-events: none; + &.is-input { + left: -9px; + } + &.is-output { + right: -9px; + } + &::before, + &::after { + content: ""; + position: absolute; + border-radius: 50%; + } + &::before { + inset: 1px; + background: var(--color-box-header); + } + &::after { + inset: 4px; + background: var(--github-borderColor-emphasis); + } + &.is-active::after { + background: var(--github-bgColor-accent-emphasis); + } + } + .actions-job-canvas.is-focused .actions-job-node:not(.is-related) { + border-color: color-mix(in srgb, var(--color-light-border) 70%, transparent); + background: var(--color-menu); + .actions-job-port::before { + background: var(--color-menu); + } + .actions-job-card { + color: var(--color-text-light-1); + } + .actions-job-card > span:first-child { + opacity: 0.5; + } + } &:not(.actions-summary-mode) .actions-run-overview { display: none; } @@ -426,15 +552,22 @@ .actions-workflow-card { padding: 16px; } - .actions-job-cards { - padding: 24px 0; - gap: 12px; + /* The log search moves under the job title instead of covering its status line. */ + .job-info-header { + flex-wrap: wrap; + gap: 8px; + height: auto !important; + padding: 12px 16px !important; } - .actions-job-card { + .job-info-header-right { width: 100%; } + .actions-log-search { + flex: 1; + min-width: 0; + } .actions-log-search > .theme-search-field { - width: 170px; + width: 100%; } .actions-log-search span { display: none; diff --git a/public/assets/js/actions-run-graph.js b/public/assets/js/actions-run-graph.js new file mode 100644 index 00000000..d8813b43 --- /dev/null +++ b/public/assets/js/actions-run-graph.js @@ -0,0 +1,120 @@ +// GitHub's job graph for the Actions run summary. Forgejo's run data has no job dependencies, so they come from the +// workflow file itself; the run page builds the cards and uses this to place them in stages and draw the links. +window.themeActionsGraph = (() => { + const unquote = value => value.trim().replace(/^(["'])(.*)\1$/, "$2"); + + // Reads each job's `name` and `needs` from the workflow's `jobs` mapping. This is a small line reader, not a YAML + // parser: a job whose `needs` it cannot read simply has no dependencies. + const parseNeeds = text => { + const jobs = new Map(); + let inJobs = false; + let jobIndent = -1; + let propertyIndent = -1; + let job = null; + let readingNeeds = false; + for (const raw of text.split(/\r?\n/)) { + const line = raw.replace(/\s+#.*$/, ""); + if (!line.trim() || line.trim().startsWith("#")) continue; + const indent = line.length - line.trimStart().length; + const content = line.trim(); + if (indent === 0) { + inJobs = /^jobs:\s*$/.test(content); + job = null; + jobIndent = -1; + continue; + } + if (!inJobs) continue; + if (jobIndent < 0) jobIndent = indent; + if (indent <= jobIndent) { + const id = content.match(/^([^:]+):\s*$/)?.[1]; + job = id ? { name: unquote(id), needs: [] } : null; + if (job) jobs.set(unquote(id), job); + propertyIndent = -1; + readingNeeds = false; + continue; + } + if (!job) continue; + if (propertyIndent < 0) propertyIndent = indent; + if (indent === propertyIndent) { + readingNeeds = false; + const [, key, value = ""] = content.match(/^([\w-]+):\s*(.*)$/) ?? []; + if (key === "name" && value) job.name = unquote(value); + if (key !== "needs") continue; + if (!value) readingNeeds = true; + else if (value.startsWith("[")) + job.needs = value + .replace(/^\[|\]$/g, "") + .split(",") + .map(unquote) + .filter(Boolean); + else job.needs = [unquote(value)]; + } else if (readingNeeds) { + const item = content.match(/^-\s*(.+)$/)?.[1]; + if (item) job.needs.push(unquote(item)); + } + } + return jobs; + }; + + // Places every run job one stage after the deepest job it needs. Within a stage jobs are sorted by name, as on + // GitHub; jobs with no links at all share one card at the end of their stage. + const layout = (runJobs, workflow) => { + const ids = new Map(); + for (const [id, job] of workflow) { + ids.set(id, id); + if (!job.name.includes("${{")) ids.set(job.name, id); + } + // Matrix jobs carry their values in parentheses after the job's name. + const resolve = name => ids.get(name) ?? ids.get(name.replace(/\s*\(.*\)$/, "")); + const nodes = runJobs.map(job => ({ ...job, id: resolve(job.name) })); + const byId = new Map(); + for (const node of nodes) if (node.id) byId.set(node.id, [...(byId.get(node.id) ?? []), node]); + const edges = []; + for (const node of nodes) + for (const need of node.id ? workflow.get(node.id).needs : []) + for (const from of byId.get(need) ?? []) if (from !== node) edges.push([from, node]); + const stage = new Map(); + const depth = (node, path = new Set()) => { + if (stage.has(node)) return stage.get(node); + if (path.has(node)) return 0; + path.add(node); + const parents = edges.filter(([, to]) => to === node).map(([from]) => depth(from, path)); + const value = parents.length ? Math.max(...parents) + 1 : 0; + stage.set(node, value); + return value; + }; + for (const node of nodes) depth(node); + const linked = new Set(edges.flat()); + const stages = []; + for (const node of nodes) (stages[stage.get(node)] ??= []).push(node); + return { + edges, + stages: stages.filter(Boolean).map(list => { + const sorted = [...list].sort((a, b) => a.name.localeCompare(b.name)); + const cards = sorted.filter(node => linked.has(node)).map(node => [node]); + const loose = sorted.filter(node => !linked.has(node)); + if (loose.length) cards.push(loose); + return cards; + }), + }; + }; + + // GitHub's link: straight across, or along the gap's middle with 12px rounded bends when the rows differ. + const linkPath = (x1, y1, x2, y2) => { + const dy = y2 - y1; + if (Math.abs(dy) < 1) return `M ${x1} ${y1} H ${x2}`; + const mid = x1 + 28; + const r = Math.min(12, Math.abs(dy) / 4) * Math.sign(dy); + const ar = Math.abs(r); + return [ + `M ${x1} ${y1}`, + `H ${mid - 2 * ar}`, + `C ${mid - ar} ${y1} ${mid} ${y1 + r} ${mid} ${y1 + 2 * r}`, + `V ${y2 - 2 * r}`, + `C ${mid} ${y2 - r} ${mid + ar} ${y2} ${mid + 2 * ar} ${y2}`, + `H ${x2}`, + ].join(" "); + }; + + return { parseNeeds, layout, linkPath }; +})(); diff --git a/public/assets/js/actions-run.js b/public/assets/js/actions-run.js index 46bd1139..26de7848 100644 --- a/public/assets/js/actions-run.js +++ b/public/assets/js/actions-run.js @@ -5,13 +5,112 @@ const initial = JSON.parse(mount.dataset.initialPostResponse); const run = initial.state.run; let fingerprint = ""; + let expandedJob = ""; let isSummary = location.hash === "#summary"; + const artifacts = JSON.parse(mount.dataset.initialArtifactsResponse || "{}").artifacts ?? []; const make = (tag, className, text) => { const node = document.createElement(tag); node.className = className; if (text !== undefined) node.textContent = text; return node; }; + // The graph's stages come from the workflow file this run used; until it loads, or if it cannot be read, the jobs + // have no links and share one card, which is how GitHub shows a workflow without `needs`. + let workflowJobs = new Map(); + fetch(mount.dataset.workflowSourceUrl.replace(/\/src\/(commit|branch|tag)\//, "/raw/$1/")) + .then(response => (response.ok ? response.text() : "")) + .then(text => { + workflowJobs = window.themeActionsGraph?.parseNeeds(text) ?? new Map(); + fingerprint = ""; + render(); + }) + .catch(() => {}); + const svgElement = name => document.createElementNS("http://www.w3.org/2000/svg", name); + function jobGraph(jobs) { + const scroller = make("div", "actions-job-graph"); + const canvas = make("div", "actions-job-canvas"); + const links = svgElement("svg"); + links.classList.add("actions-job-links"); + links.setAttribute("aria-hidden", "true"); + canvas.append(links); + scroller.append(canvas); + const runJobs = jobs.map(job => ({ name: job.querySelector(".job-brief-name")?.textContent.trim() ?? "", job })); + const { stages, edges } = window.themeActionsGraph?.layout(runJobs, workflowJobs) ?? { + stages: [[runJobs]], + edges: [], + }; + const cardOf = new Map(); + for (const stage of stages) { + const column = make("div", "actions-job-stage"); + for (const group of stage) { + const card = make("div", "actions-job-node"); + for (const node of group) { + const row = make("a", "actions-job-card"); + row.href = node.job.href; + const icon = node.job.querySelector(".job-brief-item-left > span"); + if (icon) row.append(icon.cloneNode(true)); + row.append(make("strong", "", node.name)); + const duration = node.job.querySelector(".step-summary-duration")?.textContent.trim(); + row.append(make("span", "actions-job-duration", duration)); + card.append(row); + cardOf.set(node, card); + } + column.append(card); + } + canvas.append(column); + } + const port = (card, side) => + card.querySelector(`.actions-job-port.is-${side}`) ?? + card.appendChild(make("span", `actions-job-port is-${side}`)); + const paths = edges.map(([from, to]) => { + const link = { from: cardOf.get(from), to: cardOf.get(to), path: svgElement("path") }; + link.out = port(link.from, "output"); + link.in = port(link.to, "input"); + links.append(link.path); + return link; + }); + const draw = () => { + const box = canvas.getBoundingClientRect(); + if (!box.width) return; + for (const { from, to, path } of paths) { + const a = from.getBoundingClientRect(); + const b = to.getBoundingClientRect(); + path.setAttribute( + "d", + window.themeActionsGraph.linkPath( + Math.round(a.right - box.left), + Math.round(a.top + a.height / 2 - box.top), + Math.round(b.left - box.left), + Math.round(b.top + b.height / 2 - box.top) + ) + ); + } + }; + new ResizeObserver(draw).observe(canvas); + // GitHub highlights a linked job's own links and dims the jobs it is not linked to. + const highlight = card => { + const active = card ? paths.filter(link => link.from === card || link.to === card) : []; + const related = new Set(active.flatMap(link => [link.from, link.to])); + canvas.classList.toggle("is-focused", active.length > 0); + for (const node of canvas.querySelectorAll(".actions-job-node")) + node.classList.toggle("is-related", related.has(node)); + for (const link of paths) { + const on = active.includes(link); + link.path.classList.toggle("is-active", on); + if (on) links.append(link.path); + } + for (const item of canvas.querySelectorAll(".actions-job-port")) item.classList.remove("is-active"); + for (const link of active) { + link.out.classList.add("is-active"); + link.in.classList.add("is-active"); + } + }; + canvas.addEventListener("pointerover", event => highlight(event.target.closest(".actions-job-node"))); + canvas.addEventListener("pointerleave", () => highlight(null)); + canvas.addEventListener("focusin", event => highlight(event.target.closest(".actions-job-node"))); + canvas.addEventListener("focusout", () => highlight(null)); + return scroller; + } function render() { const header = mount.querySelector(".action-view-header"); const sidebar = mount.querySelector(".action-view-left"); @@ -20,8 +119,11 @@ if (!header || !sidebar || !right || !body) return; page.classList.toggle("actions-summary-mode", isSummary); if (!header.querySelector(".actions-run-back")) { - const back = make("a", "actions-run-back", "← " + mount.dataset.workflowName); + const back = make("a", "actions-run-back"); back.href = mount.dataset.workflowUrl; + const arrow = document.querySelector("#actions-nav-icons")?.content.querySelector('[data-icon="back"] svg'); + if (arrow) back.append(arrow.cloneNode(true)); + back.append(make("span", "", mount.dataset.workflowName)); header.prepend(back); } const title = header.querySelector(".action-info-summary-title-text"); @@ -42,6 +144,33 @@ sidebar.append(make("h3", "actions-details-heading", "Run details"), source); } sidebar.querySelector(".actions-summary-link").setAttribute("aria-current", isSummary ? "page" : "false"); + // GitHub keeps Delete in the run's "…" menu beside the re-run buttons; the menu drives Forgejo's own button. + const actions = header.querySelector(".action-info-summary-actions"); + if (actions && !actions.querySelector(".actions-run-menu")) { + const menu = make("details", "theme-dropdown actions-run-menu"); + menu.dataset.themeDropdown = "actions"; + const summary = make("summary", "theme-control button"); + summary.setAttribute("aria-label", "More actions"); + const kebab = document.querySelector("#actions-nav-icons")?.content.querySelector('[data-icon="kebab"] svg'); + if (kebab) summary.append(kebab.cloneNode(true)); + const panel = make("div", "theme-dropdown-panel"); + // Forgejo renders Delete only once it knows the permissions, so the items are built when the menu opens. + menu.addEventListener("toggle", () => { + if (!menu.open) return; + const source = make("a", "theme-dropdown-action", "Workflow file"); + source.href = mount.dataset.workflowSourceUrl; + panel.replaceChildren(source); + const remove = mount.querySelector("#delete-run"); + if (remove) { + const item = make("button", "theme-dropdown-action theme-dropdown-danger", remove.textContent.trim()); + item.type = "button"; + item.addEventListener("click", () => remove.click()); + panel.append(item); + } + }); + menu.append(summary, panel); + actions.append(menu); + } const tools = right.querySelector(".job-info-header-right"); if (tools && !tools.querySelector(".actions-log-search")) { const search = make("div", "actions-log-search"); @@ -57,6 +186,12 @@ tools.prepend(search); } filterLogs(); + // GitHub opens a failed job on its failing step. + const failedStep = right.querySelector(".job-step-summary.step-expandable:has(.octicon-x-circle-fill)"); + if (!isSummary && failedStep && expandedJob !== location.pathname) { + expandedJob = location.pathname; + if (!right.querySelector(".job-step-summary.selected")) failedStep.click(); + } const jobs = [...sidebar.querySelectorAll(".job-brief-item")]; const status = header.querySelector(".action-info-summary-title > span")?.getAttribute("aria-label") || run.status; const next = JSON.stringify([ @@ -84,7 +219,7 @@ facts.append(triggered); for (const [name, value] of [ ["Status", status], - ["Jobs", String(jobs.length)], + ["Artifacts", artifacts.length ? String(artifacts.length) : "–"], ]) { const fact = make("div", "actions-run-fact"); fact.append(make("span", "actions-fact-label", name), make("strong", "", value)); @@ -95,19 +230,7 @@ const heading = make("a", "actions-workflow-title", mount.dataset.workflowName); heading.href = mount.dataset.workflowSourceUrl; workflow.append(heading); - const cards = make("div", "actions-job-cards"); - for (const job of jobs) { - const card = make("a", "actions-job-card"); - card.href = job.href; - const icon = job.querySelector(".job-brief-item-left > span"); - if (icon) card.append(icon.cloneNode(true)); - card.append(make("strong", "", job.querySelector(".job-brief-name")?.textContent.trim())); - card.append( - make("span", "actions-job-duration", job.querySelector(".step-summary-duration")?.textContent.trim()) - ); - cards.append(card); - } - workflow.append(cards); + workflow.append(jobGraph(jobs)); overview.append(workflow); body.querySelector(".actions-run-overview")?.remove(); body.append(overview); diff --git a/src/actions-run-graph.test.ts b/src/actions-run-graph.test.ts new file mode 100644 index 00000000..a06fec77 --- /dev/null +++ b/src/actions-run-graph.test.ts @@ -0,0 +1,95 @@ +import { readFileSync } from "node:fs"; +import { runInNewContext } from "node:vm"; +import { describe, expect, it } from "vitest"; + +const script = readFileSync(new URL("../public/assets/js/actions-run-graph.js", import.meta.url), "utf8"); + +type Job = { name: string; needs: string[] }; +type Node = { name: string; id?: string }; +type Graph = { + parseNeeds: (text: string) => Map; + layout: (runJobs: { name: string }[], workflow: Map) => { stages: Node[][][]; edges: Node[][] }; + linkPath: (x1: number, y1: number, x2: number, y2: number) => string; +}; + +function graph(): Graph { + const window: { themeActionsGraph?: Graph } = {}; + runInNewContext(script, { window }); + return window.themeActionsGraph as Graph; +} + +const workflow = `name: Graph +on: + push: +jobs: + lint: + runs-on: docker + steps: + - run: echo lint # needs: nothing here + build: + name: "Build" + runs-on: docker + unit: + needs: [lint, build] + e2e: + needs: build + deploy: + needs: + - unit + - 'e2e' + steps: + - name: needs + run: echo deploy +env: + needs: none +`; + +const names = (stages: Node[][][]) => stages.map(stage => stage.map(card => card.map(node => node.name))); + +describe("Actions run graph", () => { + it("reads job names and the three ways to write needs", () => { + const jobs = graph().parseNeeds(workflow); + expect([...jobs.keys()]).toEqual(["lint", "build", "unit", "e2e", "deploy"]); + expect(jobs.get("build")).toEqual({ name: "Build", needs: [] }); + expect(jobs.get("unit")?.needs).toEqual(["lint", "build"]); + expect(jobs.get("e2e")?.needs).toEqual(["build"]); + expect(jobs.get("deploy")?.needs).toEqual(["unit", "e2e"]); + }); + + it("places jobs in stages after the jobs they need, sorted by name", () => { + const g = graph(); + const run = ["lint", "Build", "unit", "e2e", "deploy"].map(name => ({ name })); + const { stages, edges } = g.layout(run, g.parseNeeds(workflow)); + expect(names(stages)).toEqual([[["Build"], ["lint"]], [["e2e"], ["unit"]], [["deploy"]]]); + expect(edges.map(([from, to]) => `${from.name}>${to.name}`)).toEqual([ + "lint>unit", + "Build>unit", + "Build>e2e", + "unit>deploy", + "e2e>deploy", + ]); + }); + + it("puts jobs without links in one card and keeps matrix jobs with their job", () => { + const g = graph(); + const jobs = g.parseNeeds( + "jobs:\n build:\n runs-on: docker\n test:\n needs: build\n docs:\n runs-on: docker\n" + ); + const run = ["build", "docs", "test (1)", "test (2)", "notes"].map(name => ({ name })); + expect(names(g.layout(run, jobs).stages)).toEqual([ + [["build"], ["docs", "notes"]], + [["test (1)"], ["test (2)"]], + ]); + }); + + it("draws GitHub's straight and bent links", () => { + const { linkPath } = graph(); + expect(linkPath(260, 63, 316, 63)).toBe("M 260 63 H 316"); + expect(linkPath(260, 63, 316, 165)).toBe( + "M 260 63 H 264 C 276 63 288 75 288 87 V 141 C 288 153 300 165 312 165 H 316" + ); + expect(linkPath(576, 165, 632, 63)).toBe( + "M 576 165 H 580 C 592 165 604 153 604 141 V 87 C 604 75 616 63 628 63 H 632" + ); + }); +}); diff --git a/templates/repo/actions/list.tmpl b/templates/repo/actions/list.tmpl index f76a4326..f71606d4 100644 --- a/templates/repo/actions/list.tmpl +++ b/templates/repo/actions/list.tmpl @@ -1,8 +1,9 @@ {{template "base/head" .}} -
+{{/* GitHub lists runs beside the shared sidebar layout; the sidebar stays outside the part Forgejo refreshes. */}} +
- -
+ {{if .HasWorkflowsOrRuns}}{{template "repo/actions/sidebar" .}}{{end}} +
{{template "base/alert" .}} {{/* Refresh the list every interval (30s) unless the document isn't visible or a dropdown is open; refresh diff --git a/templates/repo/actions/list_inner.tmpl b/templates/repo/actions/list_inner.tmpl index c0ffb8c6..a8f951a2 100644 --- a/templates/repo/actions/list_inner.tmpl +++ b/templates/repo/actions/list_inner.tmpl @@ -1,31 +1,12 @@ {{if .HasWorkflowsOrRuns}}
-
-

{{if .CurWorkflow}}{{.CurWorkflow}}{{else}}All workflows{{end}}

{{if .CurWorkflow}}Workflow runs{{else}}Showing runs from all workflows{{end}}

{{template "components/common/search/search" dict "Kind" "icon" "Local" true "ID" "actions-run-filter" "Class" "actions-search" "Placeholder" "Filter runs on this page"}}
+
+

{{if .CurWorkflow}}{{.CurWorkflow}}{{else}}{{ctx.Locale.Tr "actions.runs.all_workflows"}}{{end}}

+ {{if and .CanWriteCode .CanWriteActions}}New workflow{{end}} +
+

{{if .CurWorkflow}}Workflow runs{{else}}Showing runs from all workflows{{end}}

+ {{template "components/common/search/search" dict "Kind" "icon" "Local" true "ID" "actions-run-filter" "Class" "actions-search" "Placeholder" "Filter runs on this page"}}
- {{if not $.CurWorkflow}}{{.WorkflowID}} {{end}}#{{.Index}} - + {{if not $.CurWorkflow}}{{.WorkflowID}} {{end}}#{{.Index}}: {{if .IsScheduledRun -}} - {{ctx.Locale.Tr "actions.runs.scheduled_description" .CommitLink (ShortSha .CommitSHA)}} + {{ctx.Locale.Tr "actions.runs.scheduled_description" .CommitLink (slice .CommitSHA 0 7)}} {{else if .IsDispatchedRun -}} - {{ctx.Locale.Tr "actions.runs.workflow_dispatch_description" .CommitLink (ShortSha .CommitSHA) .TriggerUser.HomeLink .TriggerUser.GetDisplayName}} + {{ctx.Locale.Tr "actions.runs.workflow_dispatch_description" .CommitLink (slice .CommitSHA 0 7) .TriggerUser.HomeLink .TriggerUser.GetDisplayName}} {{else -}} - {{ctx.Locale.Tr "actions.runs.on_push_description" .CommitLink (ShortSha .CommitSHA) .TriggerUser.HomeLink .TriggerUser.GetDisplayName}} + {{ctx.Locale.Tr "actions.runs.on_push_description" .CommitLink (slice .CommitSHA 0 7) .TriggerUser.HomeLink .TriggerUser.GetDisplayName}} {{end -}}
diff --git a/templates/repo/actions/sidebar.tmpl b/templates/repo/actions/sidebar.tmpl new file mode 100644 index 00000000..93e521a8 --- /dev/null +++ b/templates/repo/actions/sidebar.tmpl @@ -0,0 +1,23 @@ +{{/* GitHub's Actions sidebar: All workflows, then the repository's workflows, with Runners at the bottom for + administrators. Forgejo has no string for the "Workflows" group. */}} + diff --git a/templates/repo/actions/view.tmpl b/templates/repo/actions/view.tmpl index f4368bfd..29048d8b 100644 --- a/templates/repo/actions/view.tmpl +++ b/templates/repo/actions/view.tmpl @@ -44,6 +44,7 @@
- + + {{template "base/footer" .}}