From bdc146dd8d793a3fc797fb95dea23af42b9ea04a Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 16 Mar 2026 20:44:32 +0100 Subject: [PATCH 01/37] Enhance workflow graph: add job 103 to mock actions and improve layout calculations --- routers/web/devtest/mock_actions.go | 9 + web_src/js/components/WorkflowGraph.vue | 411 +++++++----------------- 2 files changed, 121 insertions(+), 299 deletions(-) diff --git a/routers/web/devtest/mock_actions.go b/routers/web/devtest/mock_actions.go index 0dd33425dc267..908bdf206ad9e 100644 --- a/routers/web/devtest/mock_actions.go +++ b/routers/web/devtest/mock_actions.go @@ -138,6 +138,15 @@ func MockActionsRunsJobs(ctx *context.Context) { Duration: "3h", Needs: []string{"job-100", "job-101"}, }) + resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{ + ID: runID*10 + 3, + JobID: "job-103", + Name: "job 103", + Status: actions_model.StatusCancelled.String(), + CanRerun: false, + Duration: "2m", + Needs: []string{"job-100"}, + }) var mockLogOptions []generateMockStepsLogOptions resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{ diff --git a/web_src/js/components/WorkflowGraph.vue b/web_src/js/components/WorkflowGraph.vue index 571db2cd03604..f46359e8823af 100644 --- a/web_src/js/components/WorkflowGraph.vue +++ b/web_src/js/components/WorkflowGraph.vue @@ -29,6 +29,10 @@ interface BezierEdge extends Edge { path: string; fromNode: JobNode; toNode: JobNode; + startX: number; + startY: number; + endX: number; + endY: number; } interface StoredState { @@ -95,7 +99,7 @@ const nodeWidth = computed(() => { return Math.min(Math.max(140, maxNameLength * 8), 180); }); -const horizontalSpacing = computed(() => nodeWidth.value + 20); +const horizontalSpacing = computed(() => nodeWidth.value + 84); const graphWidth = computed(() => { if (jobsWithLayout.value.length === 0) return 800; const maxX = Math.max(...jobsWithLayout.value.map(j => j.x + nodeWidth.value)); @@ -135,8 +139,8 @@ const jobsWithLayout = computed(() => { return; } - const levelWidth = (levelJobs.length - 1) * currentHorizontalSpacing; - const startX = margin + (maxJobsPerLevel * currentHorizontalSpacing - levelWidth) / 2; + const levelHeight = (levelJobs.length - 1) * verticalSpacing; + const startY = margin + (maxJobsPerLevel * verticalSpacing - levelHeight) / 2; levelJobs.forEach((job, jobIndex) => { result.push({ @@ -148,8 +152,8 @@ const jobsWithLayout = computed(() => { index: props.jobs.findIndex(j => j.id === job.id), - x: startX + jobIndex * currentHorizontalSpacing, - y: margin + levelIndex * verticalSpacing, + x: margin + levelIndex * currentHorizontalSpacing, + y: startY + jobIndex * verticalSpacing, level: levelIndex, }); }); @@ -166,7 +170,7 @@ const jobsWithLayout = computed(() => { index: index, - x: margin + index * (nodeWidth.value + 40), + x: margin + index * horizontalSpacing.value, y: margin, level: 0, })); @@ -211,26 +215,24 @@ const bezierEdges = computed(() => { return; } - const startX = fromNode.x + nodeWidth.value / 2; - const startY = fromNode.y + nodeHeight; - const endX = toNode.x + nodeWidth.value / 2; - const endY = toNode.y; + const startX = fromNode.x + nodeWidth.value; + const startY = fromNode.y + nodeHeight / 2; + const endX = toNode.x; + const endY = toNode.y + nodeHeight / 2; - const levelDiff = toNode.level - fromNode.level; - const curveStrength = 30 + Math.abs(levelDiff) * 15; - - const controlX1 = startX; - const controlY1 = startY + curveStrength; - const controlX2 = endX; - const controlY2 = endY - curveStrength; - - const path = `M ${startX} ${startY} C ${controlX1} ${controlY1}, ${controlX2} ${controlY2}, ${endX} ${endY}`; + const elbowOffset = Math.max(18, Math.min((endX - startX) / 2, 28)); + const midX = startX + elbowOffset; + const path = `M ${startX} ${startY} L ${midX} ${startY} L ${midX} ${endY} L ${endX} ${endY}`; bezierEdgesList.push({ ...edge, path, fromNode, toNode, + startX, + startY, + endX, + endY, }); }); @@ -253,8 +255,8 @@ const graphMetrics = computed(() => { }; }) -const nodeHeight = 50; -const verticalSpacing = 120; +const nodeHeight = 64; +const verticalSpacing = 104; const margin = 40; function zoomIn() { @@ -338,17 +340,12 @@ function isEdgeHighlighted(edge: BezierEdge): boolean { return edge.from === hoveredJob.name || edge.to === hoveredJob.name; } -function getNodeColor(status: ActionsRunStatus): string { - if (status === 'success') { - return 'var(--color-green-dark-2)'; - } else if (status === 'failure') { - return 'var(--color-red-dark-2)'; - } else if (status === 'running') { - return 'var(--color-yellow-dark-2)'; - } else if (status === 'blocked') { - return 'var(--color-purple)'; - } - return 'var(--color-text-light-3)'; +function hasIncomingEdge(job: JobNode): boolean { + return edges.value.some((edge) => edge.to === job.name); +} + +function hasOutgoingEdge(job: JobNode): boolean { + return edges.value.some((edge) => edge.from === job.name); } function getStatusDotColor(status: ActionsRunStatus): string { @@ -362,45 +359,6 @@ function getStatusDotColor(status: ActionsRunStatus): string { return 'var(--color-text-light-2)'; } -function getEdgeColor(edge: BezierEdge): string { - if (!edge.fromNode || !edge.toNode) { - return 'var(--color-secondary)'; - } - - const fromStatus = edge.fromNode.status; - const toStatus = edge.toNode.status; - - if (fromStatus === 'failure' || toStatus === 'failure') { - return 'var(--color-red)'; - } - - if (fromStatus === 'running') { - return 'var(--color-yellow)'; - } - - if (toStatus === 'running' && fromStatus === 'success') { - return 'var(--color-primary)'; - } - - if (fromStatus === 'success' && toStatus === 'success') { - return 'var(--color-green)'; - } - - if (fromStatus === 'success' && (toStatus === 'waiting' || toStatus === 'blocked')) { - return 'var(--color-primary-light)'; - } - - if (fromStatus === 'waiting' || fromStatus === 'blocked') { - return 'var(--color-text-light-2)'; - } - - if (fromStatus === 'cancelled' || toStatus === 'cancelled') { - return 'var(--color-text-light-2)'; - } - - return 'var(--color-secondary)'; -} - function getDisplayName(name: string): string { const maxChars = 26; if (name.length <= maxChars) { @@ -433,88 +391,19 @@ function getEdgeStyle(edge: BezierEdge) { }; } - const fromStatus = edge.fromNode.status; - const toStatus = edge.toNode.status; const isHighlighted = isEdgeHighlighted(edge); return { - 'stroke': getEdgeColor(edge), - 'stroke-width': isHighlighted ? '3' : getStrokeWidth(fromStatus, toStatus), - 'stroke-dasharray': getDashArray(fromStatus, toStatus), - 'opacity': isHighlighted ? 1 : getEdgeOpacity(fromStatus, toStatus), + 'stroke': (!edge.fromNode || !edge.toNode) ? 'var(--color-secondary-alpha-50)' : 'var(--color-secondary-alpha-50)', + 'stroke-width': isHighlighted ? '3' : '1.75', + 'stroke-dasharray': 'none', + 'opacity': isHighlighted ? 1 : 0.6, 'transition': 'all 0.2s ease', }; } -function getStrokeWidth(fromStatus: ActionsRunStatus, toStatus: ActionsRunStatus): string { - if (fromStatus === 'running' || toStatus === 'running') { - return '3'; - } - - if (fromStatus === 'failure' || toStatus === 'failure') { - return '2.5'; - } - - return '2'; -} - -function getDashArray(fromStatus: ActionsRunStatus, toStatus: ActionsRunStatus): string { - if (fromStatus === 'waiting' || toStatus === 'waiting') { - return '5,3'; - } - - if (fromStatus === 'blocked') { - return '8,4'; - } - - if (fromStatus === 'cancelled' || toStatus === 'cancelled') { - return '3,6'; - } - - return 'none'; -} - -function getEdgeOpacity(fromStatus: ActionsRunStatus, toStatus: ActionsRunStatus): number { - if (fromStatus === 'success' && toStatus === 'success') { - return 0.6; - } - - if (fromStatus === 'failure' || toStatus === 'failure') { - return 1; - } - - if (fromStatus === 'running' || toStatus === 'running') { - return 1; - } - - return 0.8; -} - function getEdgeClass(edge: BezierEdge): string { - if (!edge.fromNode || !edge.toNode) return ''; - - const fromStatus = edge.fromNode.status; - const toStatus = edge.toNode.status; - - const classes: string[] = ['node-edge']; - - if (fromStatus === 'running' || toStatus === 'running') { - classes.push('running-edge'); - } - - if (fromStatus === 'success' && toStatus === 'success') { - classes.push('success-edge'); - } - - if (fromStatus === 'failure' || toStatus === 'failure') { - classes.push('failure-edge'); - } - - if (fromStatus === 'waiting' || toStatus === 'waiting') { - classes.push('waiting-edge'); - } - - return classes.join(' '); + return edge.fromNode && edge.toNode ? 'node-edge' : ''; } function computeJobLevels(jobs: ActionsJob[]): Map { @@ -663,29 +552,52 @@ function onNodeClick(job: JobNode, event: MouseEvent) { :y="job.y" :width="nodeWidth" :height="nodeHeight" - rx="8" - :fill="getNodeColor(job.status)" - :stroke="job.id === currentJobId ? 'var(--color-primary)' : 'var(--color-card-border)'" - :stroke-width="job.id === currentJobId ? '3' : '2'" + rx="10" + fill="var(--color-box-body)" + :stroke="job.id === currentJobId ? 'var(--color-primary)' : 'var(--color-secondary-alpha-20)'" + :stroke-width="job.id === currentJobId ? '1.75' : '1'" class="job-rect" /> - + + + + + + + @@ -694,10 +606,10 @@ function onNodeClick(job: JobNode, event: MouseEvent) { @@ -705,56 +617,17 @@ function onNodeClick(job: JobNode, event: MouseEvent) { {{ formatStatus(job.status) }} - - - - - - ← {{ job.needs.length }} deps - - - - - - - - - @@ -804,12 +677,13 @@ function onNodeClick(job: JobNode, event: MouseEvent) { .graph-container { overflow: auto; - padding: 12px; - border-radius: 8px; + padding: 12px 16px 20px; + border-radius: 10px; cursor: grab; min-height: 300px; max-height: 600px; position: relative; + background: var(--color-body); } .graph-container.dragging { @@ -823,12 +697,14 @@ function onNodeClick(job: JobNode, event: MouseEvent) { .graph-svg path { transition: all 0.2s ease; + stroke-linecap: round; + stroke-linejoin: round; } .highlighted-edge { - stroke-width: 3 !important; - opacity: 1 !important; - stroke: var(--color-primary) !important; + stroke-width: 2.25 !important; + opacity: 0.9 !important; + stroke: color-mix(in srgb, var(--color-primary) 35%, var(--color-secondary)) !important; } .job-node-group { @@ -838,10 +714,8 @@ function onNodeClick(job: JobNode, event: MouseEvent) { } .job-node-group:hover .job-rect { - filter: brightness(1.1); - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - z-index: 10; + filter: brightness(1.01); + transform: translateX(1px); } .job-node-group.current-job { @@ -849,11 +723,11 @@ function onNodeClick(job: JobNode, event: MouseEvent) { } .job-node-group.current-job .job-rect { - filter: drop-shadow(0 0 8px color-mix(in srgb, var(--color-primary) 30%, transparent)); + filter: drop-shadow(0 2px 8px color-mix(in srgb, var(--color-primary) 12%, transparent)); } .job-name { - max-width: calc(var(--node-width, 150px) - 50px); + max-width: calc(var(--node-width, 150px) - 92px); text-overflow: ellipsis; overflow: hidden; white-space: nowrap; @@ -862,95 +736,34 @@ function onNodeClick(job: JobNode, event: MouseEvent) { } .job-status, -.job-duration, -.job-deps-label { +.job-duration { user-select: none; pointer-events: none; } -@keyframes shimmer { - 0% { - background-position: -200px 0; - } - 100% { - background-position: calc(200px + 100%) 0; - } -} - -.running-background { - animation: shimmer 2s infinite linear; - background-size: 200px 100%; -} - -@keyframes flowRunning { - 0% { - stroke-dashoffset: 20; - stroke-opacity: 0.7; - } - 50% { - stroke-opacity: 1; - } - 100% { - stroke-dashoffset: 0; - stroke-opacity: 0.7; - } -} - -@keyframes pulseFailure { - 0%, 100% { - stroke-width: 2.5; - opacity: 0.7; - } - 50% { - stroke-width: 3; - opacity: 1; - filter: drop-shadow(0 0 4px color-mix(in srgb, var(--color-red) 50%, transparent)); - } -} - -@keyframes shimmerEdge { - 0% { - stroke-dashoffset: 20; - } - 100% { - stroke-dashoffset: 0; - } -} - -.node-edge.running-edge { - stroke-dasharray: 10, 5; - animation: flowRunning 1s linear infinite; -} - -.node-edge.failure-edge { - animation: pulseFailure 0.8s ease-in-out infinite; -} - -.node-edge.waiting-edge { - stroke-dasharray: 5, 3; - animation: shimmerEdge 2s linear infinite; +.job-status-dot { + pointer-events: none; + stroke: var(--color-box-body); + stroke-width: 1.5; } -.node-edge.success-edge { - transition: stroke-width 0.3s ease, opacity 0.3s ease; +.job-status-dot-inner { + pointer-events: none; } -.node-edge.success-edge:hover { - stroke-width: 3; - opacity: 1; +.job-rect { + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.08)); } -.progress-bar { - animation: progressPulse 2s ease-in-out infinite; +.node-port { + fill: var(--color-box-body); + stroke: var(--color-secondary-alpha-90); + stroke-width: 1.5; + pointer-events: none; } -@keyframes progressPulse { - 0%, 100% { - opacity: 0.8; - } - 50% { - opacity: 1; - } +.node-edge { + transition: stroke-width 0.2s ease, opacity 0.2s ease; } @media (max-width: 768px) { From 69b9f2b0d4af8b6e3fbbf412e718d3c3baffc943 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 16 Mar 2026 21:01:41 +0100 Subject: [PATCH 02/37] Use commit status icons --- routers/web/devtest/mock_actions.go | 9 +++ web_src/js/components/WorkflowGraph.vue | 88 ++++++++----------------- 2 files changed, 35 insertions(+), 62 deletions(-) diff --git a/routers/web/devtest/mock_actions.go b/routers/web/devtest/mock_actions.go index 908bdf206ad9e..d86ce68d79850 100644 --- a/routers/web/devtest/mock_actions.go +++ b/routers/web/devtest/mock_actions.go @@ -147,6 +147,15 @@ func MockActionsRunsJobs(ctx *context.Context) { Duration: "2m", Needs: []string{"job-100"}, }) + resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{ + ID: runID*10 + 4, + JobID: "job-104", + Name: "job 104", + Status: actions_model.StatusSuccess.String(), + CanRerun: false, + Duration: "2m", + Needs: []string{"job-103"}, + }) var mockLogOptions []generateMockStepsLogOptions resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{ diff --git a/web_src/js/components/WorkflowGraph.vue b/web_src/js/components/WorkflowGraph.vue index f46359e8823af..ec3300c38687f 100644 --- a/web_src/js/components/WorkflowGraph.vue +++ b/web_src/js/components/WorkflowGraph.vue @@ -1,6 +1,7 @@ + + diff --git a/web_src/js/components/WorkflowGraph.vue b/web_src/js/components/WorkflowGraph.vue index 39d2c04616043..2d4da9adb7057 100644 --- a/web_src/js/components/WorkflowGraph.vue +++ b/web_src/js/components/WorkflowGraph.vue @@ -2,9 +2,9 @@ import {computed, onMounted, onUnmounted, ref, watch} from 'vue'; import {SvgIcon} from '../svg.ts'; import ActionRunStatus from './ActionRunStatus.vue'; -import {localUserSettings} from "../modules/user-settings.ts"; +import {localUserSettings} from '../modules/user-settings.ts'; import {isPlainClick} from '../utils/dom.ts'; -import {debounce} from "throttle-debounce"; +import {debounce} from 'throttle-debounce'; import type {ActionsJob, ActionsRunStatus} from '../modules/gitea-actions.ts'; import type {ActionRunViewStore} from './ActionRunView.ts'; @@ -52,13 +52,11 @@ const scale = ref(1); const translateX = ref(0); const translateY = ref(0); const isDragging = ref(false); -const lastMousePos = ref({ x: 0, y: 0 }); +const lastMousePos = ref({x: 0, y: 0}); const graphContainer = ref(null); const hoveredJobId = ref(null); -const stateKey = () => { - return `${props.store.viewData.currentRun.repoId}-${props.workflowId}`; -} +const stateKey = () => `${props.store.viewData.currentRun.repoId}-${props.workflowId}`; const loadSavedState = () => { const allStates = localUserSettings.getJsonObject>(settingKeyStates, {}); @@ -67,7 +65,7 @@ const loadSavedState = () => { scale.value = saved.scale ?? scale.value; translateX.value = saved.translateX ?? translateX.value; translateY.value = saved.translateY ?? translateY.value; -} +}; const saveState = () => { const allStates = localUserSettings.getJsonObject>(settingKeyStates, {}); @@ -82,11 +80,10 @@ const saveState = () => { .sort(([, a], [, b]) => b.timestamp - a.timestamp) .slice(0, maxStoredStates); - const limitedStates = Object.fromEntries(sortedStates); - localUserSettings.setJsonObject(settingKeyStates, limitedStates); + localUserSettings.setJsonObject(settingKeyStates, Object.fromEntries(sortedStates)); }; -watch([translateX, translateY, scale], debounce(500, saveState)) +watch([translateX, translateY, scale], debounce(500, saveState)); const nodeWidth = computed(() => { const maxNameLength = Math.max(...props.jobs.map(j => j.name.length)); @@ -378,14 +375,13 @@ function resetView() { function handleMouseDown(e: MouseEvent) { if (!isPlainClick(e)) return; const target = e.target as Element; - // don't start drag on interactive/text elements inside the SVG const interactive = target.closest('div, p, a, span, button, input, text, .job-node-group'); if (interactive?.closest('svg')) return; e.preventDefault(); isDragging.value = true; - lastMousePos.value = { x: e.clientX, y: e.clientY }; + lastMousePos.value = {x: e.clientX, y: e.clientY}; graphContainer.value!.style.cursor = 'grabbing'; } @@ -398,7 +394,7 @@ function handleMouseMoveOnDocument(event: MouseEvent) { translateX.value += dx; translateY.value += dy; - lastMousePos.value = { x: event.clientX, y: event.clientY }; + lastMousePos.value = {x: event.clientX, y: event.clientY}; } function handleMouseUpOnDocument() { @@ -407,6 +403,12 @@ function handleMouseUpOnDocument() { graphContainer.value!.style.cursor = 'grab'; } +function handleWheel(event: WheelEvent) { + event.preventDefault(); + const zoomFactor = Math.exp(-event.deltaY * 0.0015); + zoomTo(scale.value * zoomFactor); +} + onMounted(() => { loadSavedState(); @@ -538,13 +540,13 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
- - -
@@ -554,7 +556,8 @@ function onNodeClick(job: JobNode, event: MouseEvent) { class="graph-container" ref="graphContainer" @mousedown="handleMouseDown" - :class="{ 'dragging': isDragging }" + @wheel.prevent="handleWheel" + :class="{dragging: isDragging}" > .workflow-graph { position: relative; + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; } .graph-header { @@ -660,6 +667,7 @@ function onNodeClick(job: JobNode, event: MouseEvent) { border-bottom: 1px solid var(--color-secondary); gap: 20px; flex-wrap: wrap; + flex-shrink: 0; } .graph-title { @@ -688,12 +696,12 @@ function onNodeClick(job: JobNode, event: MouseEvent) { .graph-container { overflow: hidden; padding: 12px 16px 20px; - border-radius: 10px; - cursor: grab; - min-height: 300px; - max-height: 70vh; + border-radius: 0 0 10px 10px; + flex: 1; + min-height: 0; position: relative; background: var(--color-body); + cursor: grab; } .graph-container.dragging { diff --git a/web_src/js/features/repo-actions.ts b/web_src/js/features/repo-actions.ts index 1467250c1aaa7..e6bbfbe2add99 100644 --- a/web_src/js/features/repo-actions.ts +++ b/web_src/js/features/repo-actions.ts @@ -4,15 +4,19 @@ import RepoActionView from '../components/RepoActionView.vue'; export function initRepositoryActionView() { const el = document.querySelector('#repo-action-view'); if (!el) return; + const runId = parseInt(el.getAttribute('data-run-id')!); + const jobId = parseInt(el.getAttribute('data-job-id')!); + const isSummaryView = !el.getAttribute('data-job-id'); // TODO: the parent element's full height doesn't work well now, // but we can not pollute the global style at the moment, only fix the height problem for pages with this component const parentFullHeight = document.querySelector('body > div.full.height'); if (parentFullHeight) parentFullHeight.classList.add('tw-pb-0'); + if (isSummaryView) document.body.classList.add('action-run-summary-page'); const view = createApp(RepoActionView, { - runId: parseInt(el.getAttribute('data-run-id')!), - jobId: parseInt(el.getAttribute('data-job-id')!), + runId, + jobId, actionsUrl: el.getAttribute('data-actions-url'), locale: { approve: el.getAttribute('data-locale-approve'), From 1b844ee60e2bf48268ebdf6da186697f256c79ab Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 27 Mar 2026 21:46:33 +0100 Subject: [PATCH 27/37] suggestion --- web_src/js/components/WorkflowGraph.vue | 31 ++++++++++++++++++------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/web_src/js/components/WorkflowGraph.vue b/web_src/js/components/WorkflowGraph.vue index 2d4da9adb7057..dc3b0c356b0df 100644 --- a/web_src/js/components/WorkflowGraph.vue +++ b/web_src/js/components/WorkflowGraph.vue @@ -58,11 +58,18 @@ const hoveredJobId = ref(null); const stateKey = () => `${props.store.viewData.currentRun.repoId}-${props.workflowId}`; +const minScale = 0.1; +const maxScale = 1; + +function clampScale(nextScale: number): number { + return Math.min(Math.max(nextScale, minScale), maxScale); +} + const loadSavedState = () => { const allStates = localUserSettings.getJsonObject>(settingKeyStates, {}); const saved = allStates[stateKey()]; if (!saved) return; - scale.value = saved.scale ?? scale.value; + scale.value = clampScale(saved.scale ?? scale.value); translateX.value = saved.translateX ?? translateX.value; translateY.value = saved.translateY ?? translateY.value; }; @@ -347,12 +354,8 @@ const graphMetrics = computed(() => { const nodeHeight = 48; const verticalSpacing = 88; const margin = 40; -const minScale = 0.1; -const maxScale = 3; -function clampScale(nextScale: number): number { - return Math.min(Math.max(nextScale, minScale), maxScale); -} +const canZoomIn = computed(() => scale.value < maxScale - 1e-9); function zoomTo(nextScale: number) { scale.value = clampScale(nextScale); @@ -404,6 +407,10 @@ function handleMouseUpOnDocument() { } function handleWheel(event: WheelEvent) { + // Without a modifier, let the wheel scroll the page + if (!event.ctrlKey && !event.metaKey) { + return; + } event.preventDefault(); const zoomFactor = Math.exp(-event.deltaY * 0.0015); zoomTo(scale.value * zoomFactor); @@ -540,13 +547,19 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
- -
@@ -556,7 +569,7 @@ function onNodeClick(job: JobNode, event: MouseEvent) { class="graph-container" ref="graphContainer" @mousedown="handleMouseDown" - @wheel.prevent="handleWheel" + @wheel="handleWheel" :class="{dragging: isDragging}" > Date: Sat, 28 Mar 2026 14:19:22 +0800 Subject: [PATCH 28/37] fix --- .../js/components/ActionRunSummaryView.vue | 6 -- web_src/js/components/RepoActionView.vue | 55 ++----------------- web_src/js/components/WorkflowGraph.vue | 15 +---- web_src/js/features/repo-actions.ts | 2 - 4 files changed, 8 insertions(+), 70 deletions(-) diff --git a/web_src/js/components/ActionRunSummaryView.vue b/web_src/js/components/ActionRunSummaryView.vue index e6ea947e03a71..6ceb986b36ffa 100644 --- a/web_src/js/components/ActionRunSummaryView.vue +++ b/web_src/js/components/ActionRunSummaryView.vue @@ -45,7 +45,6 @@ onBeforeUnmount(() => { :jobs="run.jobs" :run-link="run.link" :workflow-id="run.workflowID" - class="workflow-graph-container" /> @@ -71,11 +70,6 @@ onBeforeUnmount(() => { color: var(--color-text-light-2); } -.workflow-graph-container { - flex: 1; - min-height: 0; -} - @media (max-width: 767.98px) { .action-run-summary-block { flex-direction: column; diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue index 09c6838301871..a8e94f6e6d87e 100644 --- a/web_src/js/components/RepoActionView.vue +++ b/web_src/js/components/RepoActionView.vue @@ -164,7 +164,6 @@ async function deleteArtifact(name: string) { padding-bottom: 12px; display: flex; gap: 12px; - min-height: 0; } /* ================ */ @@ -223,7 +222,11 @@ async function deleteArtifact(name: string) { max-width: 400px; position: sticky; top: 12px; - max-height: 100vh; + + /* about 12px top padding + 12px bottom padding + 37px footer height, + TODO: need to use JS to calculate the height for better scrolling experience*/ + max-height: calc(100vh - 62px); + overflow-y: auto; background: var(--color-body); z-index: 2; /* above .job-info-header */ @@ -320,8 +323,6 @@ async function deleteArtifact(name: string) { border: 1px solid var(--color-console-border); border-radius: var(--border-radius); background: var(--color-console-bg); - align-self: stretch; - min-height: 0; } /* begin fomantic button overrides */ @@ -356,49 +357,3 @@ async function deleteArtifact(name: string) { } } - - diff --git a/web_src/js/components/WorkflowGraph.vue b/web_src/js/components/WorkflowGraph.vue index dc3b0c356b0df..5903deb8fddd5 100644 --- a/web_src/js/components/WorkflowGraph.vue +++ b/web_src/js/components/WorkflowGraph.vue @@ -377,6 +377,8 @@ function resetView() { function handleMouseDown(e: MouseEvent) { if (!isPlainClick(e)) return; + + // don't start drag on interactive/text elements inside the SVG const target = e.target as Element; const interactive = target.closest('div, p, a, span, button, input, text, .job-node-group'); if (interactive?.closest('svg')) return; @@ -663,14 +665,6 @@ function onNodeClick(job: JobNode, event: MouseEvent) { From 158c075be74e0b0710322766805240d3952c8528 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sat, 28 Mar 2026 14:32:39 +0800 Subject: [PATCH 30/37] fix double scroll bars --- web_src/js/components/RepoActionView.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue index a8e94f6e6d87e..3637763b90e95 100644 --- a/web_src/js/components/RepoActionView.vue +++ b/web_src/js/components/RepoActionView.vue @@ -235,6 +235,7 @@ async function deleteArtifact(name: string) { @media (max-width: 767.98px) { .action-view-left { position: static; /* can not sticky because multiple jobs would overlap into right view */ + max-height: unset; } } From bd789158b675a64478a8e540a1d027059031b495 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sat, 28 Mar 2026 15:08:35 +0800 Subject: [PATCH 31/37] fix hover color and reduce palette --- web_src/css/themes/theme-gitea-dark.css | 5 +---- web_src/css/themes/theme-gitea-light.css | 5 +---- web_src/js/components/WorkflowGraph.vue | 18 +++++++----------- 3 files changed, 9 insertions(+), 19 deletions(-) diff --git a/web_src/css/themes/theme-gitea-dark.css b/web_src/css/themes/theme-gitea-dark.css index 26f904486b3f6..d8416ae8113bc 100644 --- a/web_src/css/themes/theme-gitea-dark.css +++ b/web_src/css/themes/theme-gitea-dark.css @@ -195,9 +195,6 @@ gitea-theme-meta-info { --color-box-header: #1a1d1f; --color-box-body: #14171a; --color-box-body-highlight: #1e2226; - --color-workflow-box-bg: #1d2226; - --color-workflow-box-hover-bg: #242a2f; - --color-workflow-box-border: #3b444c; --color-text-dark: #f7f8f9; --color-text: #d0d5da; --color-text-light: #bcc3cb; @@ -211,7 +208,6 @@ gitea-theme-meta-info { --color-input-toggle-background: #2e353c; --color-input-border: var(--color-secondary-dark-1); --color-light: #00001728; - --color-light-mimic-enabled: rgba(0, 0, 0, calc(40 / 255 * 222 / 255 / var(--opacity-disabled))); --color-light-border: #e8f3ff28; --color-hover: #e8f3ff19; --color-hover-opaque: #21252a; /* TODO: color-mix(in srgb, var(--color-body), var(--color-hover)); */ @@ -252,6 +248,7 @@ gitea-theme-meta-info { --color-danger: var(--color-red); --color-transparency-grid-light: #2a2a2a; --color-transparency-grid-dark: #1a1a1a; + --color-workflow-edge-hover: #616e78; accent-color: var(--color-accent); color-scheme: dark; } diff --git a/web_src/css/themes/theme-gitea-light.css b/web_src/css/themes/theme-gitea-light.css index 527a9d517ac60..f4b46f2da4032 100644 --- a/web_src/css/themes/theme-gitea-light.css +++ b/web_src/css/themes/theme-gitea-light.css @@ -195,9 +195,6 @@ gitea-theme-meta-info { --color-box-header: #f1f3f5; --color-box-body: #ffffff; --color-box-body-highlight: #ecf5fd; - --color-workflow-box-bg: #f8f9fb; - --color-workflow-box-hover-bg: #eef1f5; - --color-workflow-box-border: #d0d7de; --color-text-dark: #01050a; --color-text: #181c21; --color-text-light: #30363b; @@ -211,7 +208,6 @@ gitea-theme-meta-info { --color-input-toggle-background: #d0d7de; --color-input-border: var(--color-secondary-dark-1); --color-light: #00001706; - --color-light-mimic-enabled: rgba(0, 0, 0, calc(6 / 255 * 222 / 255 / var(--opacity-disabled))); --color-light-border: #0000171d; --color-hover: #00001708; --color-hover-opaque: #f1f3f5; /* TODO: color-mix(in srgb, var(--color-body), var(--color-hover)); */ @@ -252,6 +248,7 @@ gitea-theme-meta-info { --color-danger: var(--color-red); --color-transparency-grid-light: #fafafa; --color-transparency-grid-dark: #e2e2e2; + --color-workflow-edge-hover: #b1b7bd; accent-color: var(--color-accent); color-scheme: light; } diff --git a/web_src/js/components/WorkflowGraph.vue b/web_src/js/components/WorkflowGraph.vue index b6f270a7e78e1..33399dc087bd8 100644 --- a/web_src/js/components/WorkflowGraph.vue +++ b/web_src/js/components/WorkflowGraph.vue @@ -609,8 +609,8 @@ function onNodeClick(job: JobNode, event: MouseEvent) { :width="nodeWidth" :height="nodeHeight" rx="10" - fill="var(--color-workflow-box-bg)" - stroke="var(--color-workflow-box-border)" + fill="var(--color-button)" + stroke="var(--color-light-border)" stroke-width="1.25" class="job-rect" /> @@ -725,7 +725,7 @@ function onNodeClick(job: JobNode, event: MouseEvent) { .highlighted-edge { stroke-width: 2.25 !important; - stroke: var(--color-workflow-box-border) !important; + stroke: var(--color-workflow-edge-hover) !important; } .job-node-group { @@ -734,8 +734,9 @@ function onNodeClick(job: JobNode, event: MouseEvent) { } .job-node-group:hover .job-rect { - fill: var(--color-workflow-box-hover-bg); - transform: translateX(1px); + /* due to SVG rendering limitation, only one of fill and drop-shadow can work */ + fill: var(--color-hover); + /* filter: drop-shadow(0 1px 3px var(--color-shadow-opaque)); */ } .job-text-wrap { @@ -782,14 +783,9 @@ function onNodeClick(job: JobNode, event: MouseEvent) { justify-content: center; } -.job-rect { - filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.15)); - transition: fill 0.2s ease, transform 0.2s ease; -} - .node-port { fill: var(--color-box-body); - stroke: var(--color-workflow-box-border); + stroke: var(--color-light-border); stroke-width: 1.5; pointer-events: none; } From 2822fb1301cf53a9aec6e072073a7390331d124d Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sat, 28 Mar 2026 15:18:01 +0800 Subject: [PATCH 32/37] fix layout --- web_src/js/components/ActionRunSummaryView.vue | 3 +-- web_src/js/components/WorkflowGraph.vue | 8 +++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/web_src/js/components/ActionRunSummaryView.vue b/web_src/js/components/ActionRunSummaryView.vue index 6ceb986b36ffa..0900b2d808443 100644 --- a/web_src/js/components/ActionRunSummaryView.vue +++ b/web_src/js/components/ActionRunSummaryView.vue @@ -50,8 +50,7 @@ onBeforeUnmount(() => {
-

- {{ locale.triggeredVia.replace('%s', run.triggerEvent) }} •  -

-

+

+ {{ locale.triggeredVia.replace('%s', run.triggerEvent) }} • +
+
- {{ locale.status[run.status] }} • {{ locale.totalDuration }} {{ run.duration || '–' }} -

+ {{ locale.status[run.status] }}{{ locale.totalDuration }} {{ run.duration || '–' }} +
{ border-bottom: 1px solid var(--color-secondary); } -.action-run-summary-trigger { - margin-bottom: 0; - color: var(--color-text-light-2); -} - @media (max-width: 767.98px) { .action-run-summary-block { flex-direction: column; From 4a68b00b6c812919a49e1f28ba4dd31cc500e88d Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sat, 28 Mar 2026 15:59:25 +0800 Subject: [PATCH 34/37] fine tune --- web_src/js/components/WorkflowGraph.vue | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/web_src/js/components/WorkflowGraph.vue b/web_src/js/components/WorkflowGraph.vue index 894cae9749b22..bec4aee12d62a 100644 --- a/web_src/js/components/WorkflowGraph.vue +++ b/web_src/js/components/WorkflowGraph.vue @@ -58,13 +58,6 @@ const hoveredJobId = ref(null); const stateKey = () => `${props.store.viewData.currentRun.repoId}-${props.workflowId}`; -const minScale = 0.3; -const maxScale = 1; - -function clampScale(nextScale: number): number { - return Math.min(Math.max(Math.round(nextScale * 100) / 100, minScale), maxScale); -} - const loadSavedState = () => { const allStates = localUserSettings.getJsonObject>(settingKeyStates, {}); const saved = allStates[stateKey()]; @@ -90,8 +83,6 @@ const saveState = () => { localUserSettings.setJsonObject(settingKeyStates, Object.fromEntries(sortedStates)); }; -watch([translateX, translateY, scale], debounce(500, saveState)); - const nodeWidth = computed(() => { const maxNameLength = Math.max(...props.jobs.map(j => j.name.length)); return Math.min(Math.max(140, maxNameLength * 8), 180); @@ -355,6 +346,13 @@ const nodeHeight = 48; const verticalSpacing = 88; const margin = 40; +const minScale = 0.3; +const maxScale = 1; + +function clampScale(nextScale: number): number { + return Math.min(Math.max(Math.round(nextScale * 100) / 100, minScale), maxScale); +} + const canZoomIn = computed(() => scale.value < maxScale); function zoomTo(nextScale: number) { @@ -420,6 +418,8 @@ function handleWheel(event: WheelEvent) { onMounted(() => { loadSavedState(); + watch([translateX, translateY, scale], debounce(500, saveState)); + watch([scale], debounce(100, saveState)); document.addEventListener('mousemove', handleMouseMoveOnDocument); document.addEventListener('mouseup', handleMouseUpOnDocument); From 4d10ed96e5d4bfadab78f686f54a1efc66b9e36a Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sat, 28 Mar 2026 16:01:21 +0800 Subject: [PATCH 35/37] fix css name --- web_src/js/components/WorkflowGraph.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web_src/js/components/WorkflowGraph.vue b/web_src/js/components/WorkflowGraph.vue index bec4aee12d62a..d1d66430455fd 100644 --- a/web_src/js/components/WorkflowGraph.vue +++ b/web_src/js/components/WorkflowGraph.vue @@ -636,7 +636,7 @@ function onNodeClick(job: JobNode, event: MouseEvent) { :y="job.y + 14" width="20" height="20" - class="job-status-fo" + class="job-status-fg-obj" >
@@ -776,7 +776,7 @@ function onNodeClick(job: JobNode, event: MouseEvent) { pointer-events: none; } -.job-status-fo, +.job-status-fg-obj, .job-status-icon-wrap { pointer-events: none; } From 585fafb29e15adb41e2d5a43808dad996bd031a3 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sat, 28 Mar 2026 16:25:23 +0800 Subject: [PATCH 36/37] use wrap instead of media query --- web_src/js/components/ActionRunSummaryView.vue | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/web_src/js/components/ActionRunSummaryView.vue b/web_src/js/components/ActionRunSummaryView.vue index 8a80f6a304d91..91c1dfe567faa 100644 --- a/web_src/js/components/ActionRunSummaryView.vue +++ b/web_src/js/components/ActionRunSummaryView.vue @@ -59,15 +59,9 @@ onBeforeUnmount(() => { display: flex; justify-content: space-between; align-items: center; + flex-wrap: wrap; gap: 6px; padding: 12px; border-bottom: 1px solid var(--color-secondary); } - -@media (max-width: 767.98px) { - .action-run-summary-block { - flex-direction: column; - align-items: flex-start; - } -} From 2fd826969819a8b6502dc5f0f592e1e41e7de1da Mon Sep 17 00:00:00 2001 From: silverwind Date: Sat, 28 Mar 2026 09:47:55 +0100 Subject: [PATCH 37/37] Use theme color variables for workflow graph and summary Use --color-box-body for graph container, --color-box-header for summary block, and --color-text-light-1 for summary text instead of Tailwind utility classes and --color-body. Co-Authored-By: Claude (Opus 4.6) --- web_src/js/components/ActionRunSummaryView.vue | 5 ++++- web_src/js/components/WorkflowGraph.vue | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/web_src/js/components/ActionRunSummaryView.vue b/web_src/js/components/ActionRunSummaryView.vue index 91c1dfe567faa..48af966c94be8 100644 --- a/web_src/js/components/ActionRunSummaryView.vue +++ b/web_src/js/components/ActionRunSummaryView.vue @@ -31,7 +31,7 @@ onBeforeUnmount(() => {