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" .}}