feat: update gitea vendor
runner nix smoke / nix label and flake smoke (push) Failing after 1m28s

This commit is contained in:
2026-09-26 21:18:24 +00:00
parent c439c1b948
commit d9b2a4e787
3538 changed files with 116131 additions and 44340 deletions
+37 -9
View File
@@ -43,26 +43,54 @@
align-items: center;
}
.run-list .flex-item-trailing {
.run-list-item-trailing {
display: flex;
gap: var(--gap-block);
align-items: center;
justify-content: end;
flex-wrap: nowrap;
width: 280px;
flex: 0 0 280px;
}
.run-list-ref {
display: inline-block !important;
max-width: 105px;
.ui.label.run-list-ref {
display: inline-block;
max-width: 110px;
}
@media (max-width: 767.98px) {
.run-list .flex-item-trailing {
.run-list-item-trailing {
flex-direction: column;
align-items: flex-end;
width: auto;
flex-basis: auto;
max-width: 30%;
flex: 0 0 30%;
}
.run-list-item-right,
.run-list-ref {
.run-list-item-right {
max-width: 110px;
}
}
.repository.actions .ui.vertical.menu details.scoped-workflow-group > .item {
display: flex;
padding-left: 2.5em;
}
.repository.actions .ui.vertical.menu details.scoped-workflow-group > .item > .gt-ellipsis {
font-size: 0.85714286em;
}
.repository.actions .ui.vertical.menu details.scoped-workflow-group > .item > .label {
margin-left: auto;
flex-shrink: 0;
}
.repository.actions .ui.vertical.menu details.scoped-workflow-group > summary {
justify-content: flex-start;
gap: var(--gap-block);
padding: 0.92857143em 1.14285714em;
}
.repository.actions .ui.vertical.menu details.scoped-workflow-group > summary::after {
order: 1;
}
.repository.actions .ui.vertical.menu details.scoped-workflow-group > summary > .label {
order: 2;
margin-left: auto;
}
@@ -1,9 +1,3 @@
.admin.hooks .list > .item:not(:first-child) {
border-top: 1px solid var(--color-secondary);
padding: 0.25rem 1rem;
margin: 12px -1rem -1rem;
}
.admin dl.admin-dl-horizontal {
padding: 1em;
margin: 0;
+126
View File
@@ -0,0 +1,126 @@
img.ui.avatar,
.ui.avatar img,
.ui.avatar svg {
border-radius: var(--border-radius);
object-fit: contain;
aspect-ratio: 1;
}
.avatar-stack-names {
display: inline-flex;
align-items: center;
align-self: center;
gap: 4px;
white-space: nowrap;
vertical-align: middle;
}
.avatar-stack-names > a.muted,
.avatar-stack-names > .avatar-stack-name,
.avatar-stack-names > .avatar-stack-popup-trigger {
overflow: hidden;
text-overflow: ellipsis;
max-width: 240px;
}
/* use semibold for latest commit author */
.latest-commit .avatar-stack-names > a,
.latest-commit .avatar-stack-names > .avatar-stack-popup-trigger {
font-weight: var(--font-weight-semibold);
}
/* template emits children reversed; row-reverse re-orders visually and keeps the author last-painted (on top) */
.avatar-stack {
display: inline-flex;
align-items: center;
flex-direction: row-reverse;
}
.avatar-stack > * {
margin-left: -16px;
transition: transform 0.15s ease, opacity 0.15s ease;
position: relative;
display: inline-flex;
}
.avatar-stack > *:last-child { margin-left: 0; }
.avatar-stack > *:nth-last-child(2) { margin-left: -14px; }
/* hover spreads via transform (no layout shift); positions count from visual-left = last DOM child = :nth-last-child */
.avatar-stack:hover > *:nth-last-child(2) { transform: translateX(14px); }
.avatar-stack:hover > *:nth-last-child(3) { transform: translateX(30px); }
.avatar-stack:hover > *:nth-last-child(4) { transform: translateX(46px); }
.avatar-stack:hover > *:nth-last-child(5) { transform: translateX(62px); }
.avatar-stack:hover > *:nth-last-child(6) { transform: translateX(78px); }
.avatar-stack:hover > *:nth-last-child(7) { transform: translateX(94px); }
.avatar-stack:hover > *:nth-last-child(8) { transform: translateX(110px); }
.avatar-stack:hover > *:nth-last-child(9) { transform: translateX(126px); }
.avatar-stack:hover > *:nth-last-child(10) { transform: translateX(142px); }
.avatar-stack:hover > *:nth-last-child(11) { transform: translateX(158px); }
.avatar-stack .avatar {
border: 1px solid var(--color-body);
background: var(--color-body);
transition: border-color 0.15s ease, background-color 0.15s ease;
}
.avatar-stack:hover .avatar {
background-color: var(--color-body);
}
.avatar-stack-overflow-chip {
align-items: center;
justify-content: center;
width: 0;
height: 20px;
margin-left: 0;
border: 0 solid var(--color-body);
border-radius: var(--border-radius);
color: var(--color-text);
font-weight: var(--font-weight-semibold);
overflow: hidden;
opacity: 0;
transition: all 0.15s ease;
}
.avatar-stack:hover .avatar-stack-overflow-chip {
width: 20px;
margin-left: -16px;
border-width: 1px;
opacity: 1;
}
.avatar-stack-popup-trigger {
cursor: pointer;
background: none;
border: none;
padding: 0;
font: inherit;
color: inherit;
}
.avatar-stack-popup-trigger:hover {
color: var(--color-primary);
}
.avatar-stack-popup {
min-width: 200px;
display: flex;
flex-direction: column;
padding: 4px 0;
}
.avatar-stack-popup > a {
padding: 6px 12px;
gap: 8px;
}
.avatar-stack-popup > a:hover {
background: var(--color-hover);
}
@media (max-width: 767.98px) {
.avatar-stack-names {
max-width: 80px;
}
}
+32 -37
View File
@@ -273,7 +273,6 @@ a {
color: var(--color-primary);
cursor: pointer;
text-decoration-line: none;
text-decoration-skip-ink: all;
}
a:hover {
@@ -293,9 +292,7 @@ a.silenced,
}
a:hover,
a.suppressed:hover,
a.muted:hover,
a.muted:hover [class*="color-text"],
.muted-links a:hover {
color: var(--color-primary);
}
@@ -305,6 +302,15 @@ a.suppressed:hover {
color: inherit;
}
/* keyboard focus ring for interactive elements */
a:focus-visible,
button:focus-visible,
.ui.button:focus-visible,
[role="button"]:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
a.silenced:hover {
text-decoration-line: none;
}
@@ -341,9 +347,7 @@ a.label,
width: 16px;
height: 16px;
mask-size: cover;
-webkit-mask-size: cover;
mask-image: var(--select-arrows);
-webkit-mask-image: var(--select-arrows);
background: currentcolor;
}
@@ -375,28 +379,10 @@ a.label,
color: var(--color-text-light-2);
}
/* styles from removed fomantic transition module */
.hidden.transition {
visibility: hidden;
display: none;
}
.visible.transition {
display: block !important;
visibility: visible !important;
}
.ui.comments .comment .metadata {
color: var(--color-text-light-2);
}
img.ui.avatar,
.ui.avatar img,
.ui.avatar svg {
border-radius: var(--border-radius);
object-fit: contain;
aspect-ratio: 1;
}
.full.height {
flex-grow: 1;
padding-bottom: var(--page-space-bottom);
@@ -431,10 +417,6 @@ img.ui.avatar,
margin-top: calc(var(--page-spacing) - 1rem);
}
.ui .message.flash-message {
text-align: center;
}
.ui .header > i + .content {
padding-left: 0.75rem;
vertical-align: middle;
@@ -461,11 +443,6 @@ img.ui.avatar,
margin-right: 4px;
}
.ui.inline.delete-button {
padding: 8px 15px;
font-weight: var(--font-weight-normal);
}
.ui .migrate {
color: var(--color-text-light-2) !important;
}
@@ -478,10 +455,6 @@ img.ui.avatar,
color: var(--color-text) !important;
}
.ui .border {
border: 1px solid;
}
.user-menu > .item {
width: 100%;
border-radius: 0 !important;
@@ -781,11 +754,17 @@ tr.top-line-blame:first-of-type {
.color-icon {
display: inline-block;
flex-shrink: 0;
border-radius: var(--border-radius-full);
height: 14px;
width: 14px;
}
.icon-size-8 {
width: 8px;
height: 8px;
}
.rss-icon {
display: inline-flex;
color: var(--color-text-light-1);
@@ -857,7 +836,8 @@ table th[data-sortt-desc] .svg {
align-items: center;
gap: var(--gap-inline);
vertical-align: middle;
min-width: 0; /* make ellipsis work */
max-width: 100%; /* the inner part might have "gt-ellipsis" */
min-width: 0; /* if it is the top flex container, "max-width" works; but if it is inside another flex container, the parent needs to handle the x-axis and here also needs "min-width" */
}
.ui.ui.labeled.button {
@@ -865,17 +845,32 @@ table th[data-sortt-desc] .svg {
align-items: stretch;
}
/* this is useful to make a left-right (e.g.: title .... operations) layout with default gap, and it wrap for small widths */
.ui.modal .header.flex-left-right,
.flex-left-right {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: var(--gap-block);
max-width: 100%;
min-width: 0;
}
.ui.list.flex-items-block > .item,
.ui.vertical.menu.flex-items-block > .item,
.ui.vertical.menu .item.flex-text-block,
.ui.form .field > label.flex-text-block, /* override fomantic "block" style */
.flex-items-block > .item,
.flex-text-block {
display: flex;
align-items: center;
gap: var(--gap-block);
max-width: 100%;
min-width: 0;
}
.flex-left-right > .ui.button,
.flex-text-block > .ui.button,
.flex-text-inline > .ui.button {
margin: 0; /* fomantic buttons have default margin, when we use them in a flex container with gap, we do not need these margins */
+7 -3
View File
@@ -1,3 +1,8 @@
h1, h2 {
margin: 0;
padding: 10px 0;
}
.button-sample-groups {
margin: 0; padding: 0;
}
@@ -10,7 +15,6 @@
margin-bottom: 5px;
}
h1, h2 {
margin: 0;
padding: 10px 0;
.fetch-action-demo-forms .form-fetch-action {
border: 1px red dashed; /* show the border for demo purpose */
}
+7 -20
View File
@@ -441,25 +441,12 @@
padding: 5px;
}
.EasyMDEContainer .CodeMirror .cm-keyword { color: var(--color-syntax-keyword); }
.EasyMDEContainer .CodeMirror .cm-atom { color: var(--color-syntax-keyword); }
.EasyMDEContainer .CodeMirror .cm-number { color: var(--color-syntax-number); }
.EasyMDEContainer .CodeMirror .cm-def { color: var(--color-syntax-name); }
.EasyMDEContainer .CodeMirror .cm-variable-2 { color: var(--color-syntax-name); }
.EasyMDEContainer .CodeMirror .cm-variable-3 { color: var(--color-syntax-type); }
.EasyMDEContainer .CodeMirror .cm-property { color: var(--color-syntax-name); }
.EasyMDEContainer .CodeMirror .cm-comment { color: var(--color-syntax-comment); }
.EasyMDEContainer .CodeMirror .cm-string { color: var(--color-syntax-string); }
.EasyMDEContainer .CodeMirror .cm-string-2 { color: var(--color-syntax-regexp); }
.EasyMDEContainer .CodeMirror .cm-meta { color: var(--color-syntax-control); }
.EasyMDEContainer .CodeMirror .cm-qualifier { color: var(--color-syntax-control); }
.EasyMDEContainer .CodeMirror .cm-builtin { color: var(--color-syntax-keyword); }
.EasyMDEContainer .CodeMirror .cm-bracket { color: var(--color-syntax-operator); }
.EasyMDEContainer .CodeMirror .cm-tag { color: var(--color-syntax-type); }
.EasyMDEContainer .CodeMirror .cm-attribute { color: var(--color-syntax-name); }
.EasyMDEContainer .CodeMirror .cm-header { color: var(--color-syntax-type); }
.EasyMDEContainer .CodeMirror .cm-quote { color: var(--color-syntax-comment); }
.EasyMDEContainer .CodeMirror .cm-hr { color: var(--color-syntax-operator); }
.EasyMDEContainer .CodeMirror .cm-url { color: var(--color-syntax-link); }
.EasyMDEContainer .CodeMirror .cm-link { color: var(--color-syntax-link); }
.EasyMDEContainer .CodeMirror .cm-bracket { color: var(--color-syntax-operator); }
.EasyMDEContainer .CodeMirror .cm-comment { color: var(--color-syntax-comment); }
.EasyMDEContainer .CodeMirror .cm-error { color: var(--color-syntax-invalid); }
.EasyMDEContainer .CodeMirror .cm-hr { color: var(--color-syntax-operator); }
.EasyMDEContainer .CodeMirror .cm-link { color: var(--color-syntax-link); }
.EasyMDEContainer .CodeMirror .cm-quote { color: var(--color-syntax-comment); }
.EasyMDEContainer .CodeMirror .cm-tag { color: var(--color-syntax-type); }
.EasyMDEContainer .CodeMirror .cm-url { color: var(--color-syntax-link); }
@@ -1,4 +1,5 @@
/* Based on https://github.com/buildkite/terminal-to-html/blob/697ff23bd8dc48b9d23f11f259f5256dae2455f0/assets/terminal.css */
/* stylelint-disable scale-unlimited/declaration-strict-value -- terminal ANSI palette uses literal hex values */
.console {
background: var(--color-console-bg);
@@ -84,162 +84,82 @@
fill: var(--color-secondary-dark-5);
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.flow-color-16-0 {
stroke: var(--color-series-16-0);
fill: var(--color-series-16-0);
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.flow-color-16-1 {
stroke: #499a37;
fill: #499a37;
stroke: var(--color-series-16-1);
fill: var(--color-series-16-1);
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.flow-color-16-2 {
stroke: #ce4751;
fill: #ce4751;
stroke: var(--color-series-16-2);
fill: var(--color-series-16-2);
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.flow-color-16-3 {
stroke: #8f9121;
fill: #8f9121;
stroke: var(--color-series-16-3);
fill: var(--color-series-16-3);
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.flow-color-16-4 {
stroke: #ac32a6;
fill: #ac32a6;
stroke: var(--color-series-16-4);
fill: var(--color-series-16-4);
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.flow-color-16-5 {
stroke: #7445e9;
fill: #7445e9;
stroke: var(--color-series-16-5);
fill: var(--color-series-16-5);
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.flow-color-16-6 {
stroke: #c67d28;
fill: #c67d28;
stroke: var(--color-series-16-6);
fill: var(--color-series-16-6);
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.flow-color-16-7 {
stroke: #4db392;
fill: #4db392;
stroke: var(--color-series-16-7);
fill: var(--color-series-16-7);
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.flow-color-16-8 {
stroke: #aa4d30;
fill: #aa4d30;
stroke: var(--color-series-16-8);
fill: var(--color-series-16-8);
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.flow-color-16-9 {
stroke: #2a6f84;
fill: #2a6f84;
stroke: var(--color-series-16-9);
fill: var(--color-series-16-9);
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.flow-color-16-10 {
stroke: #c45327;
fill: #c45327;
stroke: var(--color-series-16-10);
fill: var(--color-series-16-10);
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.flow-color-16-11 {
stroke: #3d965c;
fill: #3d965c;
stroke: var(--color-series-16-11);
fill: var(--color-series-16-11);
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.flow-color-16-12 {
stroke: #792a93;
fill: #792a93;
stroke: var(--color-series-16-12);
fill: var(--color-series-16-12);
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.flow-color-16-13 {
stroke: #439d73;
fill: #439d73;
stroke: var(--color-series-16-13);
fill: var(--color-series-16-13);
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.flow-color-16-14 {
stroke: #103aad;
fill: #103aad;
stroke: var(--color-series-16-14);
fill: var(--color-series-16-14);
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.flow-color-16-15 {
stroke: #982e85;
fill: #982e85;
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.flow-color-16-0 {
stroke: #7db233;
fill: #7db233;
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.highlight.flow-color-16-1 {
stroke: #5ac144;
fill: #5ac144;
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.highlight.flow-color-16-2 {
stroke: #ed5a8b;
fill: #ed5a8b;
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.highlight.flow-color-16-3 {
stroke: #ced049;
fill: #ced048;
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.highlight.flow-color-16-4 {
stroke: #db61d7;
fill: #db62d6;
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.highlight.flow-color-16-5 {
stroke: #8455f9;
fill: #8455f9;
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.highlight.flow-color-16-6 {
stroke: #e6a151;
fill: #e6a151;
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.highlight.flow-color-16-7 {
stroke: #44daaa;
fill: #44daaa;
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.highlight.flow-color-16-8 {
stroke: #dd7a5c;
fill: #dd7a5c;
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.highlight.flow-color-16-9 {
stroke: #38859c;
fill: #38859c;
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.highlight.flow-color-16-10 {
stroke: #d95520;
fill: #d95520;
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.highlight.flow-color-16-11 {
stroke: #42ae68;
fill: #42ae68;
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.highlight.flow-color-16-12 {
stroke: #9126b5;
fill: #9126b5;
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.highlight.flow-color-16-13 {
stroke: #4ab080;
fill: #4ab080;
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.highlight.flow-color-16-14 {
stroke: #284fb8;
fill: #284fb8;
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.highlight.flow-color-16-15 {
stroke: #971c80;
fill: #971c80;
}
#git-graph-container:not(.monochrome) #rel-container .flow-group.highlight.flow-color-16-0 {
stroke: #87ca28;
fill: #87ca28;
stroke: var(--color-series-16-15);
fill: var(--color-series-16-15);
}
@@ -32,26 +32,29 @@
fill: currentcolor !important;
}
/* root legend */
#user-heatmap .vch__container > .vch__legend {
#user-heatmap .heatmap-footer {
display: flex;
font-size: 11px;
justify-content: space-between;
}
/* for the "Less" and "More" legend */
#user-heatmap .vch__legend .vch__legend {
/* "Less [colors] More" scale */
#user-heatmap .heatmap-legend {
display: flex;
align-items: center;
justify-content: right;
}
#user-heatmap .vch__legend .vch__legend div:first-child,
#user-heatmap .vch__legend .vch__legend div:last-child {
#user-heatmap .heatmap-legend-svg {
margin-right: -12px;
}
#user-heatmap .heatmap-legend > div:first-child,
#user-heatmap .heatmap-legend > div:last-child {
display: inline-block;
padding: 0 5px;
}
#user-heatmap .vch__day__square:hover {
#user-heatmap .heatmap-day:hover {
outline: 1.5px solid var(--color-text);
}
+20 -20
View File
@@ -51,7 +51,7 @@
local("SourceHanSans-Light"), local("Yu Gothic Regular"),
local("YuGothic Regular"), local("Droid Sans Japanese"), local("Meiryo"),
local("MS PGothic");
font-weight: 300;
font-weight: 300; /* stylelint-disable-line scale-unlimited/declaration-strict-value */
unicode-range: U+11??, U+2E80-4DBF, U+4E00-9FFF, U+A960-A97F, U+AC00-D7FF,
U+F900-FAFF, U+FE00-FE6F, U+FF00-FFEF, U+1F2??, U+2????;
}
@@ -66,7 +66,7 @@
local("SourceHanSans-Regular"), local("Yu Gothic Medium"),
local("YuGothic Medium"), local("Droid Sans Japanese"), local("Meiryo"),
local("MS PGothic");
font-weight: 400;
font-weight: 400; /* stylelint-disable-line scale-unlimited/declaration-strict-value */
unicode-range: U+11??, U+2E80-4DBF, U+4E00-9FFF, U+A960-A97F, U+AC00-D7FF,
U+F900-FAFF, U+FE00-FE6F, U+FF00-FFEF, U+1F2??, U+2????;
}
@@ -81,7 +81,7 @@
local("SourceHanSans-Medium"), local("Yu Gothic Medium"),
local("YuGothic Medium"), local("Droid Sans Japanese"), local("Meiryo"),
local("MS PGothic");
font-weight: 500;
font-weight: 500; /* stylelint-disable-line scale-unlimited/declaration-strict-value */
unicode-range: U+11??, U+2E80-4DBF, U+4E00-9FFF, U+A960-A97F, U+AC00-D7FF,
U+F900-FAFF, U+FE00-FE6F, U+FF00-FFEF, U+1F2??, U+2????;
}
@@ -95,7 +95,7 @@
local("NotoSansCJKJP-Bold"), local("Source Han Sans Bold"),
local("SourceHanSans-Bold"), local("Yu Gothic Bold"), local("YuGothic Bold"),
local("Droid Sans Japanese"), local("Meiryo Bold"), local("MS PGothic");
font-weight: 600;
font-weight: 600; /* stylelint-disable-line scale-unlimited/declaration-strict-value */
unicode-range: U+11??, U+2E80-4DBF, U+4E00-9FFF, U+A960-A97F, U+AC00-D7FF,
U+F900-FAFF, U+FE00-FE6F, U+FF00-FFEF, U+1F2??, U+2????;
}
@@ -124,7 +124,7 @@
local("NotoSansCJKSC-Light"), local("HiraginoSansGB-W3"),
local("Hiragino Sans GB W3"), local("Microsoft YaHei Light"),
local("Heiti SC Light"), local("SimHei");
font-weight: 300;
font-weight: 300; /* stylelint-disable-line scale-unlimited/declaration-strict-value */
unicode-range: U+11??, U+2E80-4DBF, U+4E00-9FFF, U+A960-A97F, U+AC00-D7FF,
U+F900-FAFF, U+FE00-FE6F, U+FF00-FFEF, U+1F2??, U+2????;
}
@@ -137,7 +137,7 @@
local("NotoSansCJKSC-Regular"), local("HiraginoSansGB-W3"),
local("Hiragino Sans GB W3"), local("Microsoft YaHei"),
local("Heiti SC Light"), local("SimHei");
font-weight: 400;
font-weight: 400; /* stylelint-disable-line scale-unlimited/declaration-strict-value */
unicode-range: U+11??, U+2E80-4DBF, U+4E00-9FFF, U+A960-A97F, U+AC00-D7FF,
U+F900-FAFF, U+FE00-FE6F, U+FF00-FFEF, U+1F2??, U+2????;
}
@@ -150,7 +150,7 @@
local("NotoSansCJKSC-Medium"), local("HiraginoSansGB-W3"),
local("Hiragino Sans GB W3"), local("Microsoft YaHei"),
local("Heiti SC Light"), local("SimHei");
font-weight: 500;
font-weight: 500; /* stylelint-disable-line scale-unlimited/declaration-strict-value */
unicode-range: U+11??, U+2E80-4DBF, U+4E00-9FFF, U+A960-A97F, U+AC00-D7FF,
U+F900-FAFF, U+FE00-FE6F, U+FF00-FFEF, U+1F2??, U+2????;
}
@@ -163,7 +163,7 @@
local("NotoSansCJKSC-Bold"), local("HiraginoSansGB-W6"),
local("Hiragino Sans GB W6"), local("Microsoft YaHei Bold"),
local("Heiti SC Medium"), local("SimHei");
font-weight: 600;
font-weight: 600; /* stylelint-disable-line scale-unlimited/declaration-strict-value */
unicode-range: U+11??, U+2E80-4DBF, U+4E00-9FFF, U+A960-A97F, U+AC00-D7FF,
U+F900-FAFF, U+FE00-FE6F, U+FF00-FFEF, U+1F2??, U+2????;
}
@@ -192,7 +192,7 @@
local("NotoSansCJKTC-Light"), local("HiraginoSansTC-W3"),
local("Hiragino Sans TC W3"), local("Microsoft JhengHei Light"),
local("Heiti TC Light"), local("PMingLiU");
font-weight: 300;
font-weight: 300; /* stylelint-disable-line scale-unlimited/declaration-strict-value */
unicode-range: U+11??, U+2E80-4DBF, U+4E00-9FFF, U+A960-A97F, U+AC00-D7FF,
U+F900-FAFF, U+FE00-FE6F, U+FF00-FFEF, U+1F2??, U+2????;
}
@@ -205,7 +205,7 @@
local("NotoSansCJKTC-Regular"), local("HiraginoSansTC-W3"),
local("Hiragino Sans TC W3"), local("Microsoft JhengHei"),
local("Heiti TC Light"), local("PMingLiU");
font-weight: 400;
font-weight: 400; /* stylelint-disable-line scale-unlimited/declaration-strict-value */
unicode-range: U+11??, U+2E80-4DBF, U+4E00-9FFF, U+A960-A97F, U+AC00-D7FF,
U+F900-FAFF, U+FE00-FE6F, U+FF00-FFEF, U+1F2??, U+2????;
}
@@ -218,7 +218,7 @@
local("NotoSansCJKTC-Medium"), local("HiraginoSansTC-W3"),
local("Hiragino Sans TC W3"), local("Microsoft JhengHei"),
local("Heiti TC Light"), local("PMingLiU");
font-weight: 500;
font-weight: 500; /* stylelint-disable-line scale-unlimited/declaration-strict-value */
unicode-range: U+11??, U+2E80-4DBF, U+4E00-9FFF, U+A960-A97F, U+AC00-D7FF,
U+F900-FAFF, U+FE00-FE6F, U+FF00-FFEF, U+1F2??, U+2????;
}
@@ -231,7 +231,7 @@
local("NotoSansCJKTC-Bold"), local("HiraginoSansTC-W6"),
local("Hiragino Sans TC W6"), local("Microsoft JhengHei Bold"),
local("Heiti TC Medium"), local("PMingLiU");
font-weight: 600;
font-weight: 600; /* stylelint-disable-line scale-unlimited/declaration-strict-value */
unicode-range: U+11??, U+2E80-4DBF, U+4E00-9FFF, U+A960-A97F, U+AC00-D7FF,
U+F900-FAFF, U+FE00-FE6F, U+FF00-FFEF, U+1F2??, U+2????;
}
@@ -262,7 +262,7 @@
local("NotoSansCJKTC-Light"), local("HiraginoSansTC-W3"),
local("Hiragino Sans TC W3"), local("Microsoft JhengHei Light"),
local("Heiti TC Light"), local("PMingLiU_HKSCS"), local("PMingLiU");
font-weight: 300;
font-weight: 300; /* stylelint-disable-line scale-unlimited/declaration-strict-value */
unicode-range: U+11??, U+2E80-4DBF, U+4E00-9FFF, U+A960-A97F, U+AC00-D7FF,
U+F900-FAFF, U+FE00-FE6F, U+FF00-FFEF, U+1F2??, U+2????;
}
@@ -277,7 +277,7 @@
local("NotoSansCJKTC-Regular"), local("HiraginoSansTC-W3"),
local("Hiragino Sans TC W3"), local("Microsoft JhengHei"),
local("Heiti TC Light"), local("PMingLiU_HKSCS"), local("PMingLiU");
font-weight: 400;
font-weight: 400; /* stylelint-disable-line scale-unlimited/declaration-strict-value */
unicode-range: U+11??, U+2E80-4DBF, U+4E00-9FFF, U+A960-A97F, U+AC00-D7FF,
U+F900-FAFF, U+FE00-FE6F, U+FF00-FFEF, U+1F2??, U+2????;
}
@@ -292,7 +292,7 @@
local("NotoSansCJKTC-Medium"), local("HiraginoSansTC-W3"),
local("Hiragino Sans TC W3"), local("Microsoft JhengHei"),
local("Heiti TC Light"), local("PMingLiU_HKSCS"), local("PMingLiU");
font-weight: 500;
font-weight: 500; /* stylelint-disable-line scale-unlimited/declaration-strict-value */
unicode-range: U+11??, U+2E80-4DBF, U+4E00-9FFF, U+A960-A97F, U+AC00-D7FF,
U+F900-FAFF, U+FE00-FE6F, U+FF00-FFEF, U+1F2??, U+2????;
}
@@ -307,7 +307,7 @@
local("NotoSansCJKTC-Bold"), local("HiraginoSansTC-W6"),
local("Hiragino Sans TC W6"), local("Microsoft JhengHei Bold"),
local("Heiti TC Medium"), local("PMingLiU_HKSCS"), local("PMingLiU");
font-weight: 600;
font-weight: 600; /* stylelint-disable-line scale-unlimited/declaration-strict-value */
unicode-range: U+11??, U+2E80-4DBF, U+4E00-9FFF, U+A960-A97F, U+AC00-D7FF,
U+F900-FAFF, U+FE00-FE6F, U+FF00-FFEF, U+1F2??, U+2????;
}
@@ -335,7 +335,7 @@
local("SourceHanSansK-Light"), local("Noto Sans CJK KR Light"),
local("NotoSansCJKKR-Light"), local("NanumBarunGothic Light"),
local("Malgun Gothic Semilight"), local("Nanum Gothic"), local("Dotum");
font-weight: 300;
font-weight: 300; /* stylelint-disable-line scale-unlimited/declaration-strict-value */
unicode-range: U+11??, U+2E80-4DBF, U+4E00-9FFF, U+A960-A97F, U+AC00-D7FF,
U+F900-FAFF, U+FE00-FE6F, U+FF00-FFEF, U+1F2??, U+2????;
}
@@ -347,7 +347,7 @@
local("SourceHanSansK-Regular"), local("Noto Sans CJK KR Regular"),
local("NotoSansCJKKR-Regular"), local("NanumBarunGothic"),
local("Malgun Gothic"), local("Nanum Gothic"), local("Dotum");
font-weight: 400;
font-weight: 400; /* stylelint-disable-line scale-unlimited/declaration-strict-value */
unicode-range: U+11??, U+2E80-4DBF, U+4E00-9FFF, U+A960-A97F, U+AC00-D7FF,
U+F900-FAFF, U+FE00-FE6F, U+FF00-FFEF, U+1F2??, U+2????;
}
@@ -359,7 +359,7 @@
local("SourceHanSansK-Medium"), local("Noto Sans CJK KR Medium"),
local("NotoSansCJKKR-Medium"), local("NanumBarunGothic"),
local("Malgun Gothic"), local("Nanum Gothic"), local("Dotum");
font-weight: 500;
font-weight: 500; /* stylelint-disable-line scale-unlimited/declaration-strict-value */
unicode-range: U+11??, U+2E80-4DBF, U+4E00-9FFF, U+A960-A97F, U+AC00-D7FF,
U+F900-FAFF, U+FE00-FE6F, U+FF00-FFEF, U+1F2??, U+2????;
}
@@ -371,7 +371,7 @@
local("SourceHanSansK-Bold"), local("Noto Sans CJK KR Bold"),
local("NotoSansCJKKR-Bold"), local("NanumBarunGothic Bold"),
local("Malgun Gothic Bold"), local("Nanum Gothic Bold"), local("Dotum");
font-weight: 600;
font-weight: 600; /* stylelint-disable-line scale-unlimited/declaration-strict-value */
unicode-range: U+11??, U+2E80-4DBF, U+4E00-9FFF, U+A960-A97F, U+AC00-D7FF,
U+F900-FAFF, U+FE00-FE6F, U+FF00-FFEF, U+1F2??, U+2????;
}
+4 -5
View File
@@ -22,8 +22,9 @@
@import "./modules/tab.css";
@import "./modules/form.css";
@import "./modules/dropdown.css";
@import "./modules/shortcut.css";
@import "./modules/transition.css";
@import "./modules/shortcut.css";
@import "./modules/tippy.css";
@import "./modules/breadcrumb.css";
@import "./modules/comment.css";
@@ -36,8 +37,6 @@
@import "./modules/charescape.css";
@import "./shared/flex-list.css";
@import "./shared/milestone.css";
@import "./shared/repoorg.css";
@import "./shared/settings.css";
@import "./features/dropzone.css";
@@ -53,10 +52,11 @@
@import "./markup/content.css";
@import "./markup/codeblock.css";
@import "./markup/codepreview.css";
@import "./markup/asciicast.css";
@import "./markup/jupyter.css";
@import "./font_i18n.css";
@import "./base.css";
@import "./avatar.css";
@import "./home.css";
@import "./install.css";
@@ -68,7 +68,6 @@
@import "./repo/list-header.css";
@import "./repo/file-view.css";
@import "./repo/wiki.css";
@import "./repo/header.css";
@import "./repo/home.css";
@import "./repo/home-file-list.css";
@import "./repo/reactions.css";
@@ -1,10 +0,0 @@
.asciinema-player-container {
width: 100%;
height: auto;
}
/* Related: https://github.com/asciinema/asciinema-player/blob/develop/src/components/Terminal.js : <div class="ap-term" ...>
Old PR: Fix UI regression of asciinema player https://github.com/go-gitea/gitea/pull/26159 */
.ap-term {
overflow: hidden !important;
}
@@ -366,10 +366,6 @@ html[data-gitea-theme-dark="false"] .markup img[src*="#gh-dark-mode-only"] {
display: none;
}
.markup del code {
text-decoration: inherit;
}
.markup pre > code {
font-size: 100%;
}
@@ -0,0 +1,93 @@
.markup.jupyter-render {
padding: 0;
}
.markup .jupyter-notebook {
padding: 20px;
background: var(--color-body);
border-bottom-left-radius: var(--border-radius);
border-bottom-right-radius: var(--border-radius);
font-family: var(--fonts-monospace);
display: flex;
flex-direction: column;
gap: 2em;
}
/* cell code */
.markup .jupyter-notebook .cell-line {
display: flex;
width: 100%;
gap: 0.5em;
}
.markup .jupyter-notebook .cell-left {
width: 100px;
flex-shrink: 0;
}
.markup .jupyter-notebook .cell-right {
flex: 1;
}
.markup .jupyter-notebook .cell-prompt {
padding: 10px 0;
color: var(--color-text-light-2);
font-size: 13px;
}
.markup .jupyter-notebook .cell-left.cell-prompt {
padding-left: 10px;
text-align: right;
white-space: nowrap;
user-select: none;
}
.markup .jupyter-notebook .cell-right.cell-prompt {
padding-right: 10px;
}
.markup .jupyter-notebook .cell-input,
.markup .jupyter-notebook .cell-output {
overflow-x: auto;
}
.markup .jupyter-notebook .cell-input pre,
.markup .jupyter-notebook .cell-output pre {
padding: 10px 16px;
font-size: 13px;
min-height: 40px;
margin: 0;
}
.markup .jupyter-notebook .cell-input pre {
background-color: var(--color-code-bg);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.markup .jupyter-notebook .cell-output {
display: flex;
flex-direction: column;
gap: 1em;
}
.markup .jupyter-notebook .cell-type-code {
display: flex;
flex-direction: column;
gap: 1em;
}
.markup .jupyter-notebook .cell-output-unsupported {
color: var(--color-text-light-2);
font-style: italic;
font-size: 13px;
}
.markup .jupyter-notebook .cell-output-error {
color: var(--color-red);
}
/* cell markdown */
.markup .jupyter-notebook .cell-right .embedded-markdown {
padding: 0 16px; /* match cell code right padding */
}
@@ -13,6 +13,7 @@
}
.btn.is-loading > *,
.btn-octicon.is-loading > *,
.button.is-loading > * {
opacity: 0;
}
@@ -23,7 +24,7 @@
display: block;
left: 50%;
top: 50%;
height: min(4em, 66.6%);
height: var(--loading-size, min(4em, 66.6%));
width: fit-content; /* compat: safari - https://bugs.webkit.org/show_bug.cgi?id=267625 */
aspect-ratio: 1;
transform: translate(-50%, -50%);
@@ -100,13 +101,6 @@ code.language-math.is-loading::after {
animation: pulse-1p5 200ms linear;
}
.ui.modal,
.ui.dimmer.transition {
animation-name: fadein;
animation-duration: 100ms;
animation-timing-function: ease-in-out;
}
.rotate-clockwise {
animation: rotate-clockwise-keyframes 1s linear infinite;
}
@@ -21,10 +21,6 @@
white-space: nowrap;
}
.ui.button:focus-visible {
box-shadow: inset 0 0 0 2px currentcolor;
}
@media (max-width: 767.98px) {
.ui.button {
white-space: normal;
@@ -330,11 +326,6 @@
margin: 0 0.25em 0 0;
}
.delete-button,
.delete-button:hover {
color: var(--color-red);
}
/* btn is a plain button without any opinionated styling, it only uses flex for vertical alignment like ".ui.button" in base.css */
.btn {
@@ -122,14 +122,3 @@ a.ui.card:hover {
color: var(--color-text);
border-top-color: var(--color-secondary-light-1) !important;
}
.ui.three.cards {
margin-left: -1em;
margin-right: -1em;
}
.ui.three.cards > .card {
width: calc(33.33333333333333% - 2em);
margin-left: 1em;
margin-right: 1em;
}
@@ -35,13 +35,9 @@ input[type="checkbox"]::before {
height: var(--checkbox-size);
clip-path: inset(var(--checkbox-size) 0 0 0);
mask-image: var(--checkbox-mask-checked);
-webkit-mask-image: var(--checkbox-mask-checked);
mask-size: 75%;
-webkit-mask-size: 75%;
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
mask-position: center;
-webkit-mask-position: center;
}
input[type="checkbox"]:checked,
@@ -67,9 +63,7 @@ input[type="radio"]:disabled:checked {
input[type="checkbox"]:indeterminate::before {
clip-path: inset(0);
mask-image: var(--checkbox-mask-indeterminate);
-webkit-mask-image: var(--checkbox-mask-indeterminate);
mask-size: 75%;
-webkit-mask-size: 75%;
}
.ui.checkbox {
@@ -105,6 +99,7 @@ input[type="checkbox"]:indeterminate::before {
vertical-align: middle;
}
.ui.disabled.checkbox input,
.ui.disabled.checkbox label,
.ui.checkbox input[disabled] ~ label {
cursor: default !important;
@@ -13,40 +13,33 @@
height: 90vh;
}
.edit.githook .code-editor-container {
border: 1px solid var(--color-secondary);
.code-editor-container {
position: relative; /* for command panel */
background-color: var(--color-code-bg); /* fully colorize the container and editor */
}
.ui.bottom.attached.segment .code-editor-container,
.ui.bottom.attached.segment .code-editor-container .cm-scroller {
border-radius: 0 0 var(--border-radius) var(--border-radius); /* avoid overflow parent's border with radius */
}
/* editor layout */
.code-editor-container .cm-editor {
color: var(--color-text);
background-color: var(--color-code-bg);
font-family: var(--fonts-monospace);
font-size: 12px;
max-height: 90vh;
/* it was written as "min-height: 90vh; max-height: 90vh", so maybe the author prefer to always use about 90vh.
here, minus 80px to keep spaces for the top toolbar */
height: max(200px, calc(95vh - 80px));
}
.code-editor-container .cm-editor,
.code-editor-container .cm-editor .cm-scroller {
border-radius: 0 0 var(--border-radius) var(--border-radius);
}
.code-editor-container .cm-content,
.code-editor-container .cm-gutter {
min-height: 200px;
}
.code-editor-container .cm-scroller {
overflow: auto;
line-height: var(--line-height-code);
}
.code-editor-container .cm-content * {
caret-color: inherit;
.code-editor-container .cm-editor.cm-focused {
outline: none; /* don't show the default focus outline when the editor gets focused */
}
.code-editor-container .cm-content {
padding: 0;
caret-color: var(--color-caret);
padding: 0; /* avoid the small top and bottom padding */
}
.code-editor-container .cm-cursor,
@@ -54,10 +47,6 @@
border-left-color: var(--color-caret);
}
.code-editor-container .cm-editor.cm-focused {
outline: none;
}
.code-editor-container .cm-editor.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground,
.code-editor-container .cm-selectionBackground {
background-color: var(--color-primary-alpha-30);
@@ -127,8 +116,6 @@
background-color: currentcolor;
mask-image: var(--octicon-x);
mask-size: cover;
-webkit-mask-image: var(--octicon-x);
-webkit-mask-size: cover;
border-radius: var(--border-radius);
}
@@ -338,10 +325,6 @@
}
/* command palette */
.code-editor-container {
position: relative;
min-height: 90vh;
}
.cm-command-palette {
position: absolute;
@@ -32,23 +32,6 @@
padding-top: 0;
}
.ui.comments .comment > .comments {
margin: 0 0 0.5em 0.5em;
padding: 1em 0 1em 1em;
}
.ui.comments .comment > .comments::before {
position: absolute;
top: 0;
left: 0;
}
.ui.comments .comment > .comments .comment {
border: none;
border-top: none;
background: none;
}
.ui.comments .comment > .content {
display: flex;
flex-direction: column;
@@ -56,21 +39,6 @@
min-width: 0;
}
.ui.comments .comment .metadata {
display: inline-block;
margin-left: 0.5em;
font-size: 0.875em;
}
.ui.comments .comment .metadata > * {
display: inline-block;
margin: 0 0.5em 0 0;
}
.ui.comments .comment .metadata > :last-child {
margin-right: 0;
}
.ui.comments .comment .text {
margin: 0.25em 0 0.5em;
font-size: 1em;
@@ -36,3 +36,11 @@ h4.divider {
.divider.divider-text::after {
margin-left: .75em;
}
.inline-divider {
display: inline-block;
border-left: 1px solid var(--color-secondary);
overflow: hidden;
width: 1px;
margin: 0 var(--gap-inline);
}
@@ -80,6 +80,10 @@
border-top-width: 0;
}
.ui.dropdown .menu .hidden { /* for hidden items and dividers */
display: none;
}
.ui.dropdown .menu > .header {
margin: 1rem 0 0.75rem;
padding: 0 1.14285714rem;
@@ -209,7 +213,6 @@ select.ui.dropdown {
overflow-x: hidden;
overflow-y: auto;
backface-visibility: hidden;
-webkit-overflow-scrolling: touch;
border-top-width: 0 !important;
outline: none;
margin: 0 -1px;
@@ -379,7 +382,6 @@ select.ui.dropdown {
overflow-x: hidden;
overflow-y: auto;
backface-visibility: hidden;
-webkit-overflow-scrolling: touch;
}
@media only screen and (max-width: 767.98px) {
@@ -439,7 +441,6 @@ select.ui.dropdown {
}
.ui.multiple.dropdown > .label {
display: inline-block;
white-space: normal;
font-size: 1em;
padding: 0.35714286em 0.78571429em;
@@ -447,6 +448,11 @@ select.ui.dropdown {
box-shadow: 0 0 0 1px var(--color-secondary) inset;
}
.ui.multiple.dropdown > .label img {
width: auto;
max-height: 20px;
}
/* Text */
.ui.multiple.dropdown > .text {
position: static;
@@ -653,7 +659,6 @@ select.ui.dropdown {
overflow-x: hidden;
overflow-y: auto;
backface-visibility: hidden;
-webkit-overflow-scrolling: touch;
min-width: 100% !important;
width: auto !important;
}
@@ -124,18 +124,12 @@ textarea:focus,
.ui.form .fields:not(.grouped):not(.inline) .field:not(:only-child) .ui.toggle.checkbox {
margin-top: 2.21428571em;
}
.ui.form .fields:not(.grouped):not(.inline) .field:not(:only-child) .ui.slider.checkbox {
margin-top: 2.61428571em;
}
.ui.ui.form .field .fields .field:not(:only-child) .ui.checkbox {
margin-top: 0.6em;
}
.ui.ui.form .field .fields .field:not(:only-child) .ui.toggle.checkbox {
margin-top: 0.5em;
}
.ui.ui.form .field .fields .field:not(:only-child) .ui.slider.checkbox {
margin-top: 0.7em;
}
.ui.form .field > .selection.dropdown {
min-width: 14em; /* matches the default min width */
@@ -289,15 +283,11 @@ textarea:focus,
font-weight: var(--font-weight-medium);
text-transform: none;
}
.ui.form .grouped.fields .field,
.ui.form .grouped.inline.fields .field {
.ui.form .grouped.fields .field {
display: block;
margin: 0.5em 0;
padding: 0;
}
.ui.form .grouped.inline.fields .ui.checkbox {
margin-bottom: 0.4em;
}
.ui.form .fields {
display: flex;
@@ -10,10 +10,6 @@
margin: -1rem;
}
.ui.relaxed.grid {
margin-left: -1.5rem;
margin-right: -1.5rem;
}
.ui[class*="very relaxed"].grid {
margin-left: -2.5rem;
margin-right: -2.5rem;
@@ -71,18 +67,10 @@
margin-bottom: 0;
}
.ui.grid .aligned.row > .column > .segment:not(.compact):not(.attached),
.ui.aligned.grid .column > .segment:not(.compact):not(.attached) {
width: 100%;
}
.ui.grid .row + .ui.divider {
flex-grow: 1;
margin: 1rem;
}
.ui.grid .column + .ui.vertical.divider {
height: calc(50% - 1rem);
}
.ui.grid > .row > .column:last-child > .horizontal.segment,
.ui.grid > .column:last-child > .horizontal.segment {
@@ -140,119 +128,14 @@
width: 100%;
}
.ui[class*="one column"].grid > .row > .column,
.ui[class*="one column"].grid > .column:not(.row) {
width: 100%;
}
.ui[class*="two column"].grid > .row > .column,
.ui[class*="two column"].grid > .column:not(.row) {
width: 50%;
}
.ui[class*="three column"].grid > .row > .column,
.ui[class*="three column"].grid > .column:not(.row) {
width: 33.33333333%;
}
.ui[class*="four column"].grid > .row > .column,
.ui[class*="four column"].grid > .column:not(.row) {
width: 25%;
}
.ui[class*="five column"].grid > .row > .column,
.ui[class*="five column"].grid > .column:not(.row) {
width: 20%;
}
.ui[class*="six column"].grid > .row > .column,
.ui[class*="six column"].grid > .column:not(.row) {
width: 16.66666667%;
}
.ui[class*="seven column"].grid > .row > .column,
.ui[class*="seven column"].grid > .column:not(.row) {
width: 14.28571429%;
}
.ui[class*="eight column"].grid > .row > .column,
.ui[class*="eight column"].grid > .column:not(.row) {
width: 12.5%;
}
.ui[class*="nine column"].grid > .row > .column,
.ui[class*="nine column"].grid > .column:not(.row) {
width: 11.11111111%;
}
.ui[class*="ten column"].grid > .row > .column,
.ui[class*="ten column"].grid > .column:not(.row) {
width: 10%;
}
.ui[class*="eleven column"].grid > .row > .column,
.ui[class*="eleven column"].grid > .column:not(.row) {
width: 9.09090909%;
}
.ui[class*="twelve column"].grid > .row > .column,
.ui[class*="twelve column"].grid > .column:not(.row) {
width: 8.33333333%;
}
.ui[class*="thirteen column"].grid > .row > .column,
.ui[class*="thirteen column"].grid > .column:not(.row) {
width: 7.69230769%;
}
.ui[class*="fourteen column"].grid > .row > .column,
.ui[class*="fourteen column"].grid > .column:not(.row) {
width: 7.14285714%;
}
.ui[class*="fifteen column"].grid > .row > .column,
.ui[class*="fifteen column"].grid > .column:not(.row) {
width: 6.66666667%;
}
.ui[class*="sixteen column"].grid > .row > .column,
.ui[class*="sixteen column"].grid > .column:not(.row) {
width: 6.25%;
}
.ui.grid > [class*="one column"].row > .column {
width: 100% !important;
}
.ui.grid > [class*="two column"].row > .column {
width: 50% !important;
}
.ui.grid > [class*="three column"].row > .column {
width: 33.33333333% !important;
}
.ui.grid > [class*="four column"].row > .column {
width: 25% !important;
}
.ui.grid > [class*="five column"].row > .column {
width: 20% !important;
}
.ui.grid > [class*="six column"].row > .column {
width: 16.66666667% !important;
}
.ui.grid > [class*="seven column"].row > .column {
width: 14.28571429% !important;
}
.ui.grid > [class*="eight column"].row > .column {
width: 12.5% !important;
}
.ui.grid > [class*="nine column"].row > .column {
width: 11.11111111% !important;
}
.ui.grid > [class*="ten column"].row > .column {
width: 10% !important;
}
.ui.grid > [class*="eleven column"].row > .column {
width: 9.09090909% !important;
}
.ui.grid > [class*="twelve column"].row > .column {
width: 8.33333333% !important;
}
.ui.grid > [class*="thirteen column"].row > .column {
width: 7.69230769% !important;
}
.ui.grid > [class*="fourteen column"].row > .column {
width: 7.14285714% !important;
}
.ui.grid > [class*="fifteen column"].row > .column {
width: 6.66666667% !important;
}
.ui.grid > [class*="sixteen column"].row > .column {
width: 6.25% !important;
}
.ui.grid > .row > [class*="one wide"].column,
.ui.grid > .column.row > [class*="one wide"].column,
@@ -302,12 +185,6 @@
.ui.column.grid > [class*="eight wide"].column {
width: 50% !important;
}
.ui.grid > .row > [class*="nine wide"].column,
.ui.grid > .column.row > [class*="nine wide"].column,
.ui.grid > [class*="nine wide"].column,
.ui.column.grid > [class*="nine wide"].column {
width: 56.25% !important;
}
.ui.grid > .row > [class*="ten wide"].column,
.ui.grid > .column.row > [class*="ten wide"].column,
.ui.grid > [class*="ten wide"].column,
@@ -338,12 +215,6 @@
.ui.column.grid > [class*="fourteen wide"].column {
width: 87.5% !important;
}
.ui.grid > .row > [class*="fifteen wide"].column,
.ui.grid > .column.row > [class*="fifteen wide"].column,
.ui.grid > [class*="fifteen wide"].column,
.ui.column.grid > [class*="fifteen wide"].column {
width: 93.75% !important;
}
.ui.grid > .row > [class*="sixteen wide"].column,
.ui.grid > .column.row > [class*="sixteen wide"].column,
.ui.grid > [class*="sixteen wide"].column,
@@ -352,14 +223,12 @@
}
.ui.centered.grid,
.ui.centered.grid > .row,
.ui.grid > .centered.row {
.ui.centered.grid > .row {
text-align: center;
justify-content: center;
}
.ui.centered.grid > .column:not(.aligned):not(.justified):not(.row),
.ui.centered.grid > .row > .column:not(.aligned):not(.justified),
.ui.grid .centered.row > .column:not(.aligned):not(.justified) {
.ui.centered.grid > .row > .column:not(.aligned):not(.justified) {
text-align: left;
}
.ui.grid > .centered.column,
@@ -369,12 +238,6 @@
margin-right: auto;
}
.ui.relaxed.grid > .column:not(.row),
.ui.relaxed.grid > .row > .column,
.ui.grid > .relaxed.row > .column {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.ui[class*="very relaxed"].grid > .column:not(.row),
.ui[class*="very relaxed"].grid > .row > .column,
.ui.grid > [class*="very relaxed"].row > .column {
@@ -382,11 +245,6 @@
padding-right: 2.5rem;
}
.ui.relaxed.grid .row + .ui.divider,
.ui.grid .relaxed.row + .ui.divider {
margin-left: 1.5rem;
margin-right: 1.5rem;
}
.ui[class*="very relaxed"].grid .row + .ui.divider,
.ui.grid [class*="very relaxed"].row + .ui.divider {
margin-left: 2.5rem;
@@ -439,23 +297,3 @@
margin-right: -1rem !important;
}
}
.ui.ui.ui.compact.grid > .column:not(.row),
.ui.ui.ui.compact.grid > .row > .column {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.ui.ui.ui.compact.grid > * {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.ui.ui.ui.compact.grid > .row {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.ui.ui.ui.compact.grid > .column:not(.row) {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
@@ -24,10 +24,6 @@
vertical-align: middle;
}
.ui.header > .ui.label.compact {
margin-top: inherit;
}
.ui.header .sub.header {
display: block;
font-weight: var(--font-weight-normal);
@@ -97,6 +93,8 @@ h4.ui.header .sub.header {
background: var(--color-box-header);
padding: 0.78571429rem 1rem;
margin: 0 -1px;
width: calc(100% + 2px);
max-width: calc(100% + 2px); /* the same as "ui attached segment" */
border-radius: 0;
border: 1px solid var(--color-secondary);
}
@@ -69,42 +69,24 @@
visibility: hidden;
}
.ui.ui.ui.ui.icon.input > textarea,
.ui.ui.ui.ui.icon.input > input {
padding-right: 2.67142857em;
}
.ui.icon.input > i.link.icon {
cursor: pointer;
}
.ui.icon.input > i.circular.icon {
top: 0.35em;
right: 0.5em;
}
.ui[class*="left icon"].input > i.icon {
right: auto;
left: 1px;
border-radius: 0.28571429rem 0 0 0.28571429rem;
}
.ui[class*="left icon"].input > i.circular.icon {
right: auto;
left: 0.5em;
}
.ui.ui.ui.ui[class*="left icon"].input > textarea,
.ui.ui.ui.ui[class*="left icon"].input > input {
padding-left: 2.67142857em;
padding-right: 1em;
}
.ui.icon.input > textarea:focus ~ .icon,
.ui.icon.input > input:focus ~ .icon {
opacity: 1;
}
.ui.icon.input > textarea ~ i.icon {
height: 3em;
}
.ui.form .field.error > .ui.action.input > .ui.button,
.ui.action.input.error > .ui.button {
border-top: 1px solid var(--color-error-border);
@@ -33,31 +33,16 @@ a.ui.label {
opacity: 1;
}
.ui.label > img {
width: auto;
vertical-align: middle;
height: 2.1666em;
}
.ui.label > .color-icon {
margin-left: 0;
}
.ui.label > .icon {
width: auto;
margin: 0 0.75em 0 0;
}
.ui.label > .detail {
display: inline-block;
vertical-align: top;
font-weight: var(--font-weight-medium);
margin-left: 1em;
margin-left: 0.5em; /* our .ui.label already provides flex gap, so the margin here shouldn't be too large */
opacity: 0.8;
}
.ui.label > .detail .icon {
margin: 0 0.25em 0 0;
}
.ui.label > .close.icon,
.ui.label > .delete.icon {
@@ -184,12 +169,6 @@ a.ui.ui.ui.basic.yellow.label:hover {
border-color: var(--color-yellow-dark-1);
color: var(--color-yellow-dark-1);
}
.ui.ui.ui.olive.label {
background: var(--color-olive);
border-color: var(--color-olive);
color: var(--color-white);
}
.ui.ui.ui.green.label {
background: var(--color-green);
border-color: var(--color-green);
@@ -221,16 +200,6 @@ a.ui.ui.ui.purple.label:hover {
border-color: var(--color-purple-dark-1);
color: var(--color-white);
}
.ui.ui.ui.basic.purple.label {
background: transparent;
border-color: var(--color-purple);
color: var(--color-purple);
}
a.ui.ui.ui.basic.purple.label:hover {
background: transparent;
border-color: var(--color-purple-dark-1);
color: var(--color-purple-dark-1);
}
.ui.ui.ui.grey.label {
background: var(--color-label-bg);
@@ -242,16 +211,6 @@ a.ui.ui.ui.grey.label:hover {
border-color: var(--color-label-hover-bg);
color: var(--color-white);
}
.ui.ui.ui.basic.grey.label {
background: transparent;
border-color: var(--color-label-bg);
color: var(--color-label-text);
}
a.ui.ui.ui.basic.grey.label:hover {
background: transparent;
border-color: var(--color-label-hover-bg);
color: var(--color-label-hover-bg);
}
/* "horizontal label" is actually "fat label" which has enough padding spaces to be used standalone in headers */
.ui.horizontal.label {
@@ -284,30 +243,37 @@ a.ui.ui.ui.basic.grey.label:hover {
font-size: 1rem;
}
/* To let labels break up and wrap across multiple lines (issue title, comment event), use "display: contents here" to apply parent layout.
If the labels-list itself needs some layouts, use extra classes or "tw" helpers. */
/* labels-list layout: span.labels-list > a.item > span.ui.label,
by default, the labels list is rendered as "display: inline",
it can also be rendered as flex by adding flex-related classes (the general ".item" selector also applies) */
.labels-list {
display: contents;
font-size: var(--font-size-label); /* it must match the label font size, otherwise the height mismatches */
max-width: 100%; /* for ellipsis */
font-size: var(--font-size-label); /* must match the label font size, otherwise the height mismatches */
}
.labels-list a {
display: inline-flex;
.labels-list > .item {
max-width: 100%; /* for ellipsis */
}
.labels-list a:hover {
text-decoration: none;
}
.labels-list .ui.label {
min-height: 20px;
padding-top: 0;
padding-bottom: 0;
}
.with-labels-list-inline .labels-list .ui.label + .ui.label {
margin-left: var(--gap-inline);
/* to align with different font sizes, these styles need to be written separately */
#issue-list .labels-list > .item {
margin-right: 4px; /* margin-right (not margin-left) keeps wrapped labels left-aligned */
vertical-align: 2.5px; /* compensates inline-flex baseline so labels center with text */
}
.with-labels-list-inline .labels-list .ui.label {
line-height: var(--line-height-default);
.timeline-item .labels-list > .item {
margin-right: 4px;
vertical-align: 1.5px;
}
/* Scoped labels with different colors on left and right */
@@ -27,6 +27,7 @@
.ui.menu:not(.vertical) .item {
display: flex;
align-items: center;
gap: var(--gap-inline);
}
.ui.menu .item {
@@ -173,24 +174,6 @@
margin-top: 0.35714286em;
}
.ui.menu .pointing.dropdown.item .menu {
margin-top: 0.75em;
}
.ui.menu .item > .label:not(.floating) {
margin-left: 1em;
padding: 0.3em 0.78571429em;
}
.ui.vertical.menu .item > .label {
margin-top: -0.15em;
margin-bottom: -0.15em;
padding: 0.3em 0.78571429em;
float: right;
text-align: center;
}
.ui.menu .item > .floating.label {
padding: 0.3em 0.78571429em;
}
.ui.menu .item > .label {
background: var(--color-label-bg);
color: var(--color-label-text);
@@ -266,22 +249,12 @@
display: inherit;
}
.ui.menu:not(.vertical) .center.item {
display: flex;
margin-left: auto !important;
margin-right: auto !important;
}
.ui.menu .right.item::before,
.ui.menu .right.menu > .item::before {
right: auto;
left: 0;
}
.ui.menu .center.item:last-child::before {
display: none;
}
.ui.vertical.menu {
display: block;
flex-direction: column;
@@ -302,16 +275,6 @@
border-radius: 0 0 0.28571429rem 0.28571429rem;
}
.ui.vertical.menu .item > i.icon {
width: 1.18em;
float: right;
margin: 0 0 0 0.5em;
}
.ui.vertical.menu .item > .label + i.icon {
float: none;
margin: 0 0.5em 0 0;
}
.ui.vertical.menu .item::before {
position: absolute;
content: "";
@@ -328,6 +291,7 @@
.ui.vertical.menu .item > .menu {
margin: 0.5em -1.14285714em 0;
}
.ui.vertical.menu .menu .item {
background: none;
padding: 0.5em 1.33333333em;
@@ -381,9 +345,6 @@
background: none transparent;
border-bottom: 1px solid var(--color-secondary);
}
.ui.tabular.fluid.menu {
width: calc(100% + 2px) !important;
}
.ui.tabular.menu .item {
background: transparent;
border-bottom: none;
@@ -523,14 +484,6 @@
background: var(--color-active);
}
.ui.secondary.item.menu {
margin-left: 0;
margin-right: 0;
}
.ui.secondary.item.menu .item:last-child {
margin-right: 0;
}
.ui.vertical.secondary.menu .item:not(.dropdown) > .menu {
margin: 0 -0.92857143em;
}
@@ -624,10 +577,6 @@
.ui.stackable.menu .right.item {
margin-left: 0 !important;
}
.ui.stackable.menu .center.item {
margin-left: 0 !important;
margin-right: 0 !important;
}
.ui.stackable.menu .right.menu,
.ui.stackable.menu .left.menu {
flex-direction: column;
@@ -635,8 +584,7 @@
}
.ui.borderless.menu .item::before,
.ui.borderless.menu .item .menu .item::before,
.ui.menu .borderless.item::before {
.ui.borderless.menu .item .menu .item::before {
background: none !important;
}
@@ -646,19 +594,12 @@
vertical-align: middle;
flex-shrink: 0;
}
.ui.compact.vertical.menu {
display: inline-block;
width: auto !important;
}
.ui.compact.menu:not(.secondary) .item:last-child {
border-radius: 0 0.28571429rem 0.28571429rem 0;
}
.ui.compact.menu .item:last-child::before {
display: none;
}
.ui.compact.vertical.menu .item:last-child::before {
display: block;
}
.ui.menu.fluid,
.ui.vertical.menu.fluid {
@@ -675,9 +616,6 @@
text-align: center;
justify-content: center;
}
.ui.attached.item.menu:not(.tabular) {
margin: 0 -1px !important;
}
.ui.item.menu .item:last-child::before {
display: none;
}
@@ -12,6 +12,30 @@
border-radius: var(--border-radius);
}
details.ui.message {
padding: 0;
}
details.ui.message summary {
padding: 1em 1.5em;
}
details.ui.message pre {
margin: -1.25em 0 0;
padding: 0.5em 1.5em;
white-space: pre-wrap;
}
details.ui.message:not(:has(pre)) summary {
list-style: none;
cursor: text;
}
.ui.message.flash-message pre {
white-space: pre-line;
margin: 0;
}
.ui.message:first-child {
margin-top: 0;
}
@@ -65,7 +89,6 @@
.ui.error.message,
.ui.attached.error.message,
.ui.red.message,
.ui.attached.red.message,
.ui.negative.message,
.ui.attached.negative.message {
background: var(--color-error-bg);
@@ -73,9 +96,7 @@
}
.ui.warning.message,
.ui.attached.warning.message,
.ui.yellow.message,
.ui.attached.yellow.message {
.ui.attached.warning.message {
background: var(--color-warning-bg);
border-color: var(--color-warning-border);
}
@@ -179,13 +179,8 @@
margin-bottom: 1rem;
}
.ui.fitted.segment:not(.horizontally) {
padding-top: 0;
padding-bottom: 0;
}
.ui.fitted.segment:not(.vertically) {
padding-left: 0;
padding-right: 0;
.ui.fitted.segment {
padding: 0;
}
.ui.segments .segment,
@@ -2,14 +2,10 @@
and material icons should have no "fill" set explicitly, otherwise some like ".editorconfig" won't render correctly */
.svg:not(.git-entry-icon) {
display: inline-block;
vertical-align: text-top;
vertical-align: middle; /* middle is the best choice for different font sizes from 1px to 36px when no flex */
fill: currentcolor;
}
.middle .svg {
vertical-align: middle;
}
/* some browsers like Chrome have a bug: when a SVG is in a "display: none" container and referenced
somewhere else by `<use href="#id">`, it won't be rendered correctly. e.g.: ".kts -> kotlin" */
.svg-icon-container {
@@ -50,3 +46,9 @@
.svg[width="36"] { min-width: 36px; }
.svg[width="48"] { min-width: 48px; }
.svg[width="56"] { min-width: 56px; }
/* when the svg is used in menu or item, it's certain that we don't want it to be shrunk */
.menu .svg,
.item .svg {
flex-shrink: 0;
}
@@ -161,11 +161,6 @@
.ui.fixed.table {
table-layout: fixed;
}
.ui.fixed.table th,
.ui.fixed.table td {
overflow: hidden;
text-overflow: ellipsis;
}
.ui.selectable.table > tbody > tr:hover,
.ui.table tbody tr td.selectable:hover {
@@ -226,18 +221,10 @@
.ui.table td.six.wide {
width: 37.5%;
}
.ui.table th.seven.wide,
.ui.table td.seven.wide {
width: 43.75%;
}
.ui.table th.eight.wide,
.ui.table td.eight.wide {
width: 50%;
}
.ui.table th.nine.wide,
.ui.table td.nine.wide {
width: 56.25%;
}
.ui.table th.ten.wide,
.ui.table td.ten.wide {
width: 62.5%;
@@ -246,26 +233,6 @@
.ui.table td.eleven.wide {
width: 68.75%;
}
.ui.table th.twelve.wide,
.ui.table td.twelve.wide {
width: 75%;
}
.ui.table th.thirteen.wide,
.ui.table td.thirteen.wide {
width: 81.25%;
}
.ui.table th.fourteen.wide,
.ui.table td.fourteen.wide {
width: 87.5%;
}
.ui.table th.fifteen.wide,
.ui.table td.fifteen.wide {
width: 93.75%;
}
.ui.table th.sixteen.wide,
.ui.table td.sixteen.wide {
width: 100%;
}
.ui.basic.table {
background: transparent;
@@ -22,6 +22,10 @@
overflow-wrap: anywhere;
}
.toast-body pre {
white-space: pre-wrap; /* online editor, when git hook fails, the rendered message uses "pre" */
}
.toast-close {
border-radius: var(--border-radius);
width: 30px;
@@ -0,0 +1,24 @@
/* styles for Fomantic transition (toggle): dimmer, modal, dropdown menu */
/* this is the only place using "hidden" and "visible" classes, it can safely work with tailwind without prefix in the future */
.ui.modal.transition.hidden,
.ui.dropdown > .menu.transition.hidden {
display: none;
visibility: hidden;
}
.ui.modal.transition.visible,
.ui.dropdown > .menu.transition.visible {
display: block;
visibility: visible;
}
/* dimmer uses "active" but not "hidden/visible" classes, no special classes for it here */
/* only modal and dimmer need animation, dropdown menu doesn't */
.ui.modal.transition,
.ui.dimmer.transition {
animation-name: fadein;
animation-duration: 100ms;
animation-timing-function: ease-in-out;
}
+9 -68
View File
@@ -1,71 +1,12 @@
.organization .head .ui.header .ui.right {
margin-top: 5px;
.page-content.organization .team-item-box > .team-item-header {
min-height: 50px; /* the header sometimes contains a mini button, sometimes not, so we set a min-height to make sure the layout is consistent */
}
.page-content.organization .org-avatar {
margin-right: 15px;
}
.page-content.organization #org-info .ui.header {
display: flex;
align-items: center;
font-size: 36px;
margin-bottom: 0;
}
.page-content.organization #org-info .desc {
font-size: 16px;
margin-bottom: 10px;
}
.page-content.organization #org-info .meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.page-content.organization .ui.top.header .ui.right {
margin-top: 0;
}
.page-content.organization .teams .item {
padding: 10px 15px;
}
.page-content.organization .members .ui.avatar {
margin-right: 5px;
margin-bottom: 5px;
}
.organization.invite #invite-box {
margin: 50px auto auto;
width: 500px !important;
}
.organization.invite #invite-box #search-user-box input {
margin-left: 0;
width: 300px;
}
.organization.invite #invite-box .ui.button {
margin-left: 5px;
margin-top: -3px;
}
.organization.invite .ui.avatar {
width: 100%;
height: 100%;
}
.organization.teams .detail .item {
padding: 10px 15px;
}
.organization.teams .detail .item:not(:last-child) {
border-bottom: 1px solid var(--color-secondary);
}
.org-team-navbar .active.item {
background: var(--color-box-body) !important;
.page-content.organization .team-item-box .team-item-description {
padding-top: 0.5em;
padding-bottom: 0.5em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text-light-3);
}
+63 -173
View File
@@ -1,3 +1,12 @@
.repo-header {
padding: 1em 0 0.25em;
}
.secondary-nav .secondary-info {
margin-top: 0.25em;
font-size: 12px;
}
.repository .data-table .line-num,
.repository .diff-file-box .file-body.file-code .lines-num,
.repository .diff-file-box .code-diff tbody tr .lines-type-marker {
@@ -62,12 +71,30 @@
visibility: hidden;
}
.ui.list.issue-sidebar-project-cards > .item {
display: flex;
align-items: center;
gap: var(--gap-block);
}
.ui.list.issue-sidebar-project-cards > .item.sidebar-project-card {
flex-direction: column;
align-items: flex-start;
border: 1px solid var(--color-secondary);
border-radius: var(--border-radius);
padding: 8px;
}
.issue-content-right .ui.list.labels-list {
display: flex;
gap: var(--gap-inline);
flex-wrap: wrap;
}
.issue-content-right .empty-list {
font-size: 12px;
}
@media (max-width: 767.98px) {
.issue-content-left,
.issue-content-right {
@@ -183,14 +210,6 @@ td .commit-summary {
overflow: auto;
}
.non-diff-file-content .asciicast {
padding: 0 !important;
}
.repo-editor-menu {
min-height: auto !important;
}
.repo-editor-header {
/* it should match ".repo-button-row" so the tree toggle button stays aligned */
margin: 8px 0;
@@ -400,28 +419,14 @@ td .commit-summary {
margin-right: 5px;
}
.repository.view.issue .comment-list:not(.prevent-before-timeline)::before {
display: block;
content: "";
position: absolute;
margin-top: 12px;
margin-bottom: 14px;
top: 0;
bottom: 0;
left: 96px;
width: 2px;
background-color: var(--color-timeline);
z-index: -1;
}
.repository.view.issue .comment-list .timeline {
.repository.view.issue .comment-list {
position: relative;
display: block;
margin-left: 40px;
padding-left: 16px;
}
.repository.view.issue .comment-list .timeline::before { /* ciara */
.repository.view.issue .comment-list::before {
display: block;
content: "";
position: absolute;
@@ -506,22 +511,24 @@ td .commit-summary {
margin-left: -16px;
}
.repository.view.issue .comment-list .timeline-item .comment-text-line {
/* TODO: this "line-height" is not ideal (actually it is abused), many layouts depend on this magic value,
for example: alignment of the header arrow and the avatar, view PR commit list left icon layout, dismiss review with reason, etc */
line-height: 32px;
.comment-text-line {
color: var(--color-text-light);
flex: 1;
min-width: 0;
}
.repository.view.issue .comment-list .timeline-item .comment-text-line .ui.label {
.comment-text-line .ui.label {
line-height: 1.5; /* label has background, so it can't use parent's line-height */
}
.repository.view.issue .comment-list .timeline-item .comment-text-line a {
.comment-text-line a {
color: inherit;
}
.comment-text-line a:hover {
color: var(--color-primary);
}
.repository.view.issue .comment-list .timeline-item.commits-list {
padding-left: 15px;
padding-top: 0;
@@ -573,31 +580,6 @@ td .commit-summary {
}
}
.repository.view.issue .comment-list .comment .merge-section {
background-color: var(--color-box-body);
}
.repository.view.issue .comment-list .comment .merge-section .item-section {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
padding: 0;
gap: 0.5em;
}
.repository.view.issue .comment-list .comment .merge-section .divider {
margin-left: -1rem;
width: calc(100% + 2rem);
}
.merge-section-info code {
border: 1px solid var(--color-light-border);
border-radius: var(--border-radius);
padding: 2px 4px;
background: var(--color-light);
}
.repository.view.issue .comment-list .comment .no-content {
color: var(--color-text-light-2);
font-style: italic;
@@ -662,16 +644,15 @@ td .commit-summary {
align-items: center;
}
.repository.view.issue .comment-list .event > .comment-text-line {
flex: 1;
min-width: 0;
}
.repository.view.issue .comment-list .event > .detail {
margin-left: 15px;
width: 100%;
}
.repository.view.issue .ui.depending .item.dependency {
display: flex;
}
.repository.view.issue .ui.depending .item.is-closed .issue-dependency-title {
text-decoration: line-through;
}
@@ -1066,7 +1047,7 @@ td .commit-summary {
/* if the element is for a checkbox, then it should have a padding-left to align to the checkbox's text */
.repository.settings.branches .branch-protection .ui.checkbox .help,
.repository.settings.branches .branch-protection .checkbox-sub-item {
padding-left: 26px;
padding-left: 20px;
}
.repository.settings.branches .branch-protection .status-check-matched-mark {
@@ -1227,38 +1208,6 @@ td .commit-summary {
}
}
.settings.webhooks .list > .item:not(:first-child),
.settings.githooks .list > .item:not(:first-child),
.settings.actions .list > .item:not(:first-child) {
padding: 0.25rem 1rem;
margin: 12px -1rem -1rem;
}
.settings .list > .item:not(:first-child) {
border-top: 1px solid var(--color-secondary);
padding: 1rem;
margin: 16px -1rem -1rem;
}
.settings .list > .item > .svg {
display: table-cell;
}
.settings .list > .item > .svg + .content {
display: table-cell;
padding: 0 0 0 0.5em;
vertical-align: top;
}
.settings .list > .item .info {
margin-top: 10px;
}
.settings .list > .item .info .tab.segment {
border: 0;
padding: 10px 0 0;
}
.comment:target .comment-container {
border-color: var(--color-primary) !important;
box-shadow: 0 0 0 3px var(--color-primary-alpha-30) !important;
@@ -1320,6 +1269,7 @@ td .commit-summary {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.comment-header-right {
@@ -1408,8 +1358,7 @@ tbody.commit-list {
vertical-align: baseline;
}
.message-wrapper,
.author-wrapper {
.message-wrapper {
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
@@ -1417,12 +1366,6 @@ tbody.commit-list {
vertical-align: middle;
}
.author-wrapper {
max-width: 180px;
align-self: center;
white-space: nowrap;
}
.latest-commit .message-wrapper {
max-width: calc(100% - 2.5rem);
}
@@ -1437,9 +1380,6 @@ tbody.commit-list {
tr.commit-list {
width: 100%;
}
.author-wrapper {
max-width: 80px;
}
}
@media (min-width: 768px) and (max-width: 991.98px) {
@@ -1600,6 +1540,7 @@ tbody.commit-list {
#diff-container {
display: flex;
gap: var(--page-spacing);
}
#diff-file-boxes {
@@ -1837,57 +1778,30 @@ tbody.commit-list {
box-shadow: 0 0.5rem 1rem var(--color-shadow) !important;
}
.commits-table .commits-table-right form {
display: flex;
align-items: center;
gap: 0.75em;
justify-content: center;
flex-wrap: wrap;
}
@media (max-width: 767.98px) {
.repository.view.issue .comment-list .timeline,
.repository.view.issue .comment-list,
.repository.view.issue .comment-list .timeline-item {
margin-left: 0;
}
.repository.view.issue .comment-list .timeline::before {
.repository.view.issue .comment-list::before {
left: 14px;
}
.repository.view.issue .comment-list .timeline .inline-timeline-avatar {
.repository.view.issue .comment-list .inline-timeline-avatar {
display: flex;
margin-bottom: auto;
margin-left: 6px;
margin-right: 2px;
}
.repository.view.issue .comment-list .timeline .comment-header {
padding-left: 4px;
}
/* Don't show the general avatar, we show the inline avatar on mobile.
* And don't show the role labels, there's no place for that. */
.repository.view.issue .comment-list .timeline .timeline-avatar,
.repository.view.issue .comment-list .timeline .comment-header-right .role-label {
.repository.view.issue .comment-list .timeline-avatar,
.repository.view.issue .comment-list .comment-header-right .role-label {
display: none;
}
.commit-header h3 {
flex-basis: auto !important;
margin-bottom: 0.5rem !important;
}
.commits-table {
flex-direction: column;
}
.commits-table .commits-table-left {
align-items: initial !important;
margin-bottom: 6px;
}
.commits-table .commits-table-right form > div:nth-child(1) {
order: 1; /* the "commit search" input */
}
.commits-table .commits-table-right form > div:nth-child(2) {
order: 3; /* the "search all" checkbox */
}
.commits-table .commits-table-right form > button:nth-child(3) {
order: 2; /* the "search" button */
}
.commit-table {
overflow-x: auto;
}
@@ -1910,58 +1824,34 @@ tbody.commit-list {
max-height: 240px; /* fit exactly 6 items, commit-status-item.height * 6 */
overflow-x: hidden;
transition: max-height .2s;
width: 100%;
}
.commit-status-item {
.commit-status-item { /* the item can be used at 2 places: PR's merge box (commit-status-list), commit's status popup (no commit-status-list) */
height: 40px;
padding: 0 10px;
padding-top: 0 !important; /* use "height" + "align items center", don't use padding-y (from the list container) to layout */
padding-bottom: 0 !important;
display: flex;
gap: 8px;
gap: var(--gap-block);
align-items: center;
}
.commit-status-item + .commit-status-item {
border-top: 1px solid var(--color-secondary);
}
.commit-status-item .commit-status {
flex-shrink: 0;
}
.commit-status-item .status-context {
color: var(--color-text);
flex: 1;
}
.commit-status-item .status-details {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
@media (max-width: 767.98px) {
.commit-status-item .status-details {
flex-direction: column;
align-items: flex-end;
justify-content: center;
}
}
.commit-status-item .status-details > span {
padding-right: 0.5em; /* To match the alignment with the "required" label */
justify-content: space-between;
flex-wrap: wrap;
}
.username-display {
max-width: 100%; /* the inner part might have "gt-ellipsis" */
min-width: 0; /* if it is the top flex container, "max-width" works; but if it is inside another flex container, the parent needs to handle the x-axis and here also needs "min-width" */
display: inline-flex;
gap: var(--gap-inline);
align-items: center;
max-width: 100%; /* min/max widths are for "gt-ellipsis", see the comment of other "flex-xxx" family classes */
min-width: 0;
overflow: hidden;
}
.username-display > .username-fullname {
color: var(--color-text-light-2);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
#issue-pins {
@@ -1,3 +1,7 @@
.file-view-container {
padding: 0 !important; /* the file-view itself provides padding */
}
.file-view tr.active .lines-num,
.file-view tr.active .lines-escape,
.file-view tr.active .lines-code {
@@ -1,29 +0,0 @@
.repository .secondary-nav {
padding-top: 12px;
}
.repository .secondary-nav .fork-flag {
margin-top: 0.5rem;
font-size: 12px;
}
.repo-header {
display: flex;
flex-flow: row wrap;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 4px;
}
.repo-header .flex-item {
padding: 0;
}
.repo-header .flex-item-main {
flex: 0;
flex-basis: unset;
}
.repo-header .flex-item-trailing {
flex-wrap: nowrap;
}
@@ -92,3 +92,8 @@
white-space: nowrap;
color: var(--color-text-light-1);
}
#repo-files-table .repo-file-cell.is-loading::after {
height: 40%;
border-width: 2px;
}
@@ -21,8 +21,10 @@
grid-row: 2;
}
.repo-home-sidebar-bottom .flex-list > :first-child {
border-top: 1px solid var(--color-secondary); /* same to .flex-list > .flex-item + .flex-item */
.repo-home-sidebar-header {
font-weight: var(--font-weight-semibold);
font-size: 16px;
margin-top: 0.5em;
}
@media (max-width: 767.98px) {
@@ -87,8 +89,7 @@
border-radius: 5px;
overflow: hidden;
width: 100%;
margin-top: 1rem;
margin-bottom: 5px;
margin: 5px 0;
}
.language-stats-details {
@@ -34,17 +34,17 @@
}
}
#issue-list .flex-item-body .branches {
#issue-list .branches {
display: inline-flex;
}
#issue-list .flex-item-body .branches .branch {
#issue-list .branches .branch {
background-color: var(--color-secondary-alpha-50);
border-radius: var(--border-radius);
padding: 0 4px;
}
#issue-list .flex-item-body .branches .truncated-name {
#issue-list .branches .truncated-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -53,18 +53,18 @@
vertical-align: top;
}
#issue-list .flex-item-body .checklist progress {
#issue-list .checklist progress {
margin-left: 2px;
width: 80px;
height: 6px;
display: inline-block;
}
#issue-list .flex-item-body .checklist progress::-webkit-progress-value {
#issue-list .checklist progress::-webkit-progress-value {
background-color: var(--color-secondary-dark-4);
}
#issue-list .flex-item-body .checklist progress::-moz-progress-bar {
#issue-list .checklist progress::-moz-progress-bar {
background-color: var(--color-secondary-dark-4);
}
@@ -84,3 +84,47 @@
margin-right: 8px;
text-align: left;
}
/* for "title (#123)":
<span "title-full-link-hover">
<a "title-full-link muted">title (</a>
<a "muted">#123</a>
<a "title-full-link muted">)</a>
</span>
* hover on "title": also highlight the right parentheses
* hover on "#123": don't highlight other parts
*/
.title-full-link-hover:not(:has(:not(.title-full-link):hover)):hover > a.title-full-link {
color: var(--color-primary);
text-decoration: underline;
}
.list-item-large-title {
font-size: 16px;
font-weight: var(--font-weight-semibold);
color: var(--color-text);
text-decoration: none;
overflow-wrap: anywhere;
}
.list-item-title-progress {
width: 200px;
height: 16px;
}
.list-item-secondary-bar {
display: flex;
flex-wrap: wrap;
gap: var(--gap-block);
justify-content: space-between;
color: var(--color-text-light-2);
}
.list-item-secondary-bar a {
color: var(--color-text-light-2);
text-decoration: none;
}
.list-item-secondary-bar a:hover {
color: var(--color-text);
}
@@ -5,12 +5,12 @@
}
.timeline-item .conversation-holder .bottom-reactions {
margin: 1em 0 0 36px;
margin: 1em 0 0 24px;
padding-bottom: 8px;
}
.bottom-reactions .ui.label {
padding: 5px 8px;
padding: 2px 6px;
font-weight: var(--font-weight-normal);
}
@@ -26,9 +26,7 @@
height: 14px;
background-color: var(--color-yellow); /* TODO: maybe it needs a new kind of color, there is no suitable "warning" color in the current palette */
mask-image: var(--octicon-alert-fill);
-webkit-mask-image: var(--octicon-alert-fill);
mask-size: contain;
-webkit-mask-size: contain;
}
.repository .diff-file-box .code-diff td.lines-escape {
@@ -1,54 +1,83 @@
.flex-list {
list-style: none;
}
.flex-item {
display: flex;
gap: 8px;
align-items: flex-start;
padding: 10px 0;
}
.flex-item .flex-item-leading {
display: flex;
align-items: flex-start;
}
.flex-item .flex-item-main {
/* can be used to replace "ui relaxed list" or "tw-flex tw-flex-col tw-gap-xxx" when we need more flexible layout */
.flex-relaxed-list {
display: flex;
flex-direction: column;
gap: 0.25em;
gap: var(--gap-block);
min-width: 0; /* keep the same style as "flex-text-block" etc, make the text content wrap/ellipse correctly */
max-width: 100%;
}
.flex-relaxed-list > .divider {
margin: 0;
}
/* items have dividers between them, the dividers align with items */
.flex-divided-list,
.flex-divided-list > .item.flex-divided-list {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0;
}
.flex-divided-list > .item {
padding: 10px 0;
flex-shrink: 0;
}
.flex-divided-list > .divider {
margin: 0;
flex-shrink: 0;
}
.flex-divided-list > .item + .item {
border-top: 1px solid var(--color-secondary);
}
.items-with-main > .item {
display: flex;
gap: var(--gap-block);
align-items: flex-start;
}
.items-with-main > .item .item-leading {
display: flex;
align-items: flex-start;
}
.items-with-main > .item .item-main {
display: flex;
flex-direction: column;
gap: var(--gap-inline);
flex-grow: 1;
flex-basis: 60%; /* avoid wrapping the "flex-item-trailing" too aggressively */
flex-basis: 60%; /* avoid wrapping the "item-trailing" too aggressively */
min-width: 0; /* make the "text truncate" work, otherwise the flex axis is not limited and the text just overflows */
}
.flex-item-header {
.items-with-main > .item .item-header {
display: flex;
gap: .25rem;
gap: var(--gap-inline);
justify-content: space-between;
flex-wrap: wrap;
}
.flex-item a:not(.label, .button):hover {
.items-with-main > .item a:not(.label, .button):hover {
color: var(--color-primary) !important;
}
.flex-item .flex-item-trailing {
.items-with-main > .item .item-trailing {
display: flex;
gap: 0.5rem;
gap: var(--gap-block);
align-items: center;
flex-grow: 0;
flex-wrap: wrap;
justify-content: end;
}
.flex-item .flex-item-title {
.items-with-main > .item .item-title {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
/* labels are under effect of this gap here because they are display:contents. Ideally we should make wrapping
of labels work without display: contents and set this to a static value again. */
gap: var(--gap-inline);
max-width: 100%;
color: var(--color-text);
@@ -58,46 +87,44 @@
min-width: 0;
}
.flex-item .flex-item-title a {
.items-with-main > .item .item-title a {
color: var(--color-text);
overflow-wrap: anywhere;
}
.flex-item .flex-item-body {
.items-with-main > .item .item-body {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: .25rem;
gap: var(--gap-inline);
color: var(--color-text-light-2);
overflow-wrap: anywhere;
}
.flex-item .flex-item-body a {
.items-with-main > .item .item-body a {
color: inherit;
overflow-wrap: anywhere;
}
.flex-list > .flex-item + .flex-item {
border-top: 1px solid var(--color-secondary);
/* special rules to make the list work with existing UI elements */
.flex-divided-list.items-px-default > .item {
padding-left: 1em; /* matches ".ui.segment" padding */
padding-right: 1em;
}
/* Fomantic UI segment has default "padding: 1em", so here it removes the padding-top and padding-bottom accordingly (there might also be some `tw-hidden` siblings).
Developers could also use "flex-space-fitted" class to remove the first item's padding-top and the last item's padding-bottom */
.flex-list.flex-space-fitted > .flex-item:first-child,
.ui.segment > .flex-list > .flex-item:first-child {
.ui.segment:not(.fitted) > .flex-divided-list > .item:first-child {
padding-top: 0;
}
.flex-list.flex-space-fitted > .flex-item:last-child,
.ui.segment > .flex-list > .flex-item:last-child {
.ui.segment:not(.fitted) > .flex-divided-list > .item:last-child {
padding-bottom: 0;
}
/* If there is a divider besides the flex-list, some padding/margin are not needs */
.divider + .flex-list > .flex-item:first-child {
.divider + .flex-divided-list > .item:first-child {
padding-top: 0;
}
.flex-list + .divider {
.flex-divided-list + .divider {
margin-top: 0;
}
@@ -1,62 +0,0 @@
.milestone-list {
list-style: none;
}
.milestone-card {
width: 100%;
padding-top: 10px;
padding-bottom: 10px;
}
.milestone-card + .milestone-card {
border-top: 1px solid var(--color-secondary);
}
.milestone-card .render-content {
padding-top: 10px;
}
.milestone-header progress {
width: 200px;
height: 16px;
}
.milestone-header {
display: flex;
align-items: center;
margin: 0;
flex-wrap: wrap;
justify-content: space-between;
}
.milestone-toolbar {
padding-top: 5px;
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: space-between;
}
.milestone-toolbar .group {
color: var(--color-text-light-2);
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.milestone-toolbar .group > a {
font-size: 15px;
color: var(--color-text-light-2);
}
.milestone-toolbar .group > a:hover {
color: var(--color-text);
}
@media (max-width: 767.98px) {
.milestone-card {
display: flex;
flex-direction: column;
gap: 8px;
}
}
@@ -1,18 +0,0 @@
.repository .head .ui.header .text,
.organization .head .ui.header .text {
vertical-align: middle;
font-size: 1.6rem;
margin-left: 15px;
}
.repository .ui.tabs.container,
.organization .ui.tabs.container {
margin-top: 14px;
margin-bottom: 0;
}
.repository .head .ui.header .org-visibility .label,
.organization .head .ui.header .org-visibility .label {
margin-left: 5px;
margin-top: 5px;
}
@@ -1,37 +1,53 @@
details.toggleable-item {
user-select: none !important;
padding: 0 !important;
.ui.vertical.menu > details.item {
user-select: none;
padding: 0;
}
details.toggleable-item .menu {
margin: 4px 0 10px !important;
}
details.toggleable-item summary {
.ui.vertical.menu > details.item summary {
display: flex;
justify-content: space-between;
justify-content: space-between; /* make the "::after" right-aligned */
align-items: center;
padding: 0.92857143em 1.14285714em;
padding: 16px 13px; /* match Fomantic menu item padding */
}
details.toggleable-item summary::marker, /* Chrome, Edge, Firefox */
details.toggleable-item summary::-webkit-details-marker /* Safari */ {
.ui.vertical.menu > details.item > summary::marker, /* Chrome, Edge, Firefox */
.ui.vertical.menu > details.item > summary::-webkit-details-marker /* Safari */ {
display: none;
}
details.toggleable-item summary::after {
.ui.vertical.menu > details.item > summary::after {
transition: transform 0.25s ease;
content: "";
width: 14px;
height: 14px;
mask-size: cover;
-webkit-mask-size: cover;
mask-image: var(--octicon-chevron-right);
-webkit-mask-image: var(--octicon-chevron-right);
background: currentcolor;
border: 1px solid var(--color-body); /* workaround https://bugzilla.mozilla.org/show_bug.cgi?id=1671784 */
}
details.toggleable-item[open] summary::after {
.ui.vertical.menu > details.item[open] > summary::after {
transform: rotate(90deg);
}
/* default toggleable details menu: items don't have menu item padding, don't change background color on hover or active */
.ui.vertical.menu > details.item > .menu {
margin: 0 0 10px; /* only need the space between the current details menu and next item */
}
/* full width toggleable details menu: items have menu item padding, change background color on hover and active */
.ui.vertical.menu > details.item > .menu.items-full-width {
margin: 0;
}
.ui.vertical.menu > details.item > .menu.items-full-width > .item {
padding: 16px 13px; /* match Fomantic menu item padding */
}
.ui.vertical.menu > details.item > .menu.items-full-width > .item.active {
background: var(--color-active);
}
.ui.vertical.menu > details.item > .menu.items-full-width > .item:hover {
background: var(--color-hover); /* ".ui.vertical.menu .menu .item" resets the hover background, so we need to add it again */
}
@@ -1,4 +1,4 @@
@import "../../node_modules/swagger-ui-dist/swagger-ui.css";
@import "swagger-ui-dist/swagger-ui.css";
html,
html body,
@@ -141,19 +141,36 @@ gitea-theme-meta-info {
--color-ansi-bright-magenta: #d74397;
--color-ansi-bright-cyan: #00b6ad;
--color-ansi-bright-white: var(--color-console-fg);
/* 16-color series */
--color-series-16-0: #7db233;
--color-series-16-1: #499a37;
--color-series-16-2: #ce4751;
--color-series-16-3: #8f9121;
--color-series-16-4: #ac32a6;
--color-series-16-5: #7445e9;
--color-series-16-6: #c67d28;
--color-series-16-7: #4db392;
--color-series-16-8: #aa4d30;
--color-series-16-9: #2a6f84;
--color-series-16-10: #c45327;
--color-series-16-11: #3d965c;
--color-series-16-12: #792a93;
--color-series-16-13: #439d73;
--color-series-16-14: #103aad;
--color-series-16-15: #982e85;
/* other colors */
--color-grey: #3d3f44;
--color-grey-light: #898d96;
--color-gold: #b1983b;
--color-white: #ffffff;
--color-diff-added-fg: #87ab63;
--color-diff-added-fg: #93b373;
--color-diff-added-linenum-bg: #274227;
--color-diff-added-row-bg: #203224;
--color-diff-added-row-border: #314a37;
--color-diff-added-word-bg: #3c653c;
--color-diff-moved-row-bg: #818044;
--color-diff-moved-row-border: #bcca6f;
--color-diff-removed-fg: #cc4848;
--color-diff-removed-fg: #fb5f5b;
--color-diff-removed-linenum-bg: #482121;
--color-diff-removed-row-bg: #301e1e;
--color-diff-removed-row-border: #634343;
@@ -253,7 +270,7 @@ gitea-theme-meta-info {
--color-syntax-keyword: #ff8854;
--color-syntax-bool: #25bbc9;
--color-syntax-control: #dd9e17;
--color-syntax-name: #c7a618;
--color-syntax-name: #fabd2f;
--color-syntax-type: #eb8cb3;
--color-syntax-number: #63b2dd;
--color-syntax-operator: #ff8854;
@@ -8,11 +8,11 @@ gitea-theme-meta-info {
/* red/green colorblind-friendly colors */
:root {
--color-diff-added-fg: #2185d0;
--color-diff-added-fg: #0860cc;
--color-diff-added-linenum-bg: #b6e3ff;
--color-diff-added-row-bg: #ddf4ff;
--color-diff-added-word-bg: #b6e3ff;
--color-diff-removed-fg: #fc6500;
--color-diff-removed-fg: #a84400;
--color-diff-removed-linenum-bg: #ffd8b5;
--color-diff-removed-row-bg: #fff1e5;
--color-diff-removed-word-bg: #ffd8b5;
@@ -8,7 +8,7 @@ gitea-theme-meta-info {
/* blue/yellow colorblind-friendly colors */
:root {
--color-diff-added-fg: #2185d0;
--color-diff-added-fg: #0860cc;
--color-diff-added-linenum-bg: #b6e3ff;
--color-diff-added-row-bg: #ddf4ff;
--color-diff-added-word-bg: #b6e3ff;
@@ -141,19 +141,36 @@ gitea-theme-meta-info {
--color-ansi-bright-magenta: #d74397;
--color-ansi-bright-cyan: #00b6ad;
--color-ansi-bright-white: var(--color-console-fg);
/* 16-color series */
--color-series-16-0: #7db233;
--color-series-16-1: #499a37;
--color-series-16-2: #ce4751;
--color-series-16-3: #8f9121;
--color-series-16-4: #ac32a6;
--color-series-16-5: #7445e9;
--color-series-16-6: #c67d28;
--color-series-16-7: #4db392;
--color-series-16-8: #aa4d30;
--color-series-16-9: #2a6f84;
--color-series-16-10: #c45327;
--color-series-16-11: #3d965c;
--color-series-16-12: #792a93;
--color-series-16-13: #439d73;
--color-series-16-14: #103aad;
--color-series-16-15: #982e85;
/* other colors */
--color-grey: #697077;
--color-grey-light: #7c838a;
--color-gold: #a1882b;
--color-white: #ffffff;
--color-diff-added-fg: #21ba45;
--color-diff-added-fg: #177231;
--color-diff-added-linenum-bg: #d1f8d9;
--color-diff-added-row-bg: #e6ffed;
--color-diff-added-row-border: #e6ffed;
--color-diff-added-word-bg: #acf2bd;
--color-diff-moved-row-bg: #f1f8d1;
--color-diff-moved-row-border: #d0e27f;
--color-diff-removed-fg: #db2828;
--color-diff-removed-fg: #c61f2b;
--color-diff-removed-linenum-bg: #ffcecb;
--color-diff-removed-row-bg: #ffeef0;
--color-diff-removed-row-border: #f1c0c0;
@@ -308,12 +308,12 @@ $.fn.dropdown = function(parameters) {
firstUnfiltered: function() {
module.verbose('Selecting first non-filtered element');
module.remove.selectedItem();
$item
const $selectable = $item
.not(selector.unselectable)
.not(selector.addition + selector.hidden)
.eq(0)
.addClass(className.selected)
;
.not(selector.addition + selector.hidden);
let $selectedItem = $selectable.filter(`[data-value="${CSS.escape($input.val())}"]`); // GITEA-PATCH: try to re-select the last selected item for single selection
if (!$selectedItem.length) $selectedItem = $item.eq(0);
$selectedItem.addClass(className.selected);
},
nextAvailable: function($selected) {
$selected = $selected.eq(0);
@@ -772,11 +772,13 @@ $.fn.dropdown = function(parameters) {
if(!Array.isArray(preSelected)) {
preSelected = preSelected && preSelected!=="" ? preSelected.split(settings.delimiter) : [];
}
$.each(preSelected,function(index,value){
$item.filter('[data-value="'+CSS.escape(value)+'"]') // GITEA-PATCH: use "CSS.escape" for query selector
if (module.is.multiple()) { // GITEA-PATCH: only hide selected items when the dropdown is "multiple selection"
$.each(preSelected, function (index, value) {
$item.filter('[data-value="' + CSS.escape(value) + '"]') // GITEA-PATCH: use "CSS.escape" for query selector
.addClass(className.filtered)
;
});
;
});
}
afterFiltered();
});
}
@@ -1951,8 +1953,8 @@ $.fn.dropdown = function(parameters) {
$choice.find(selector.menu).remove();
$choice.find(selector.menuIcon).remove();
}
return ($choice.data(metadata.text) !== undefined)
? $choice.data(metadata.text)
return ($choice.attr('data-' + metadata.text) !== undefined) // GITEA-PATCH: use "attr" but not "data", don't decode JSON like "false"
? $choice.attr('data-' + metadata.text)
: (preserveHTML)
? $choice.html().trim()
: $choice.text().trim()
@@ -2005,8 +2007,8 @@ $.fn.dropdown = function(parameters) {
value = ( $option.attr('value') !== undefined )
? $option.attr('value')
: name,
text = ( $option.data(metadata.text) !== undefined )
? $option.data(metadata.text)
text = ( $option.attr('data-' + metadata.text) !== undefined ) // GITEA-PATCH: use "attr" but not "data", don't decode JSON like "false"
? $option.attr('data-' + metadata.text)
: name,
group = $option.parent('optgroup')
;
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,5 @@
import './components/api.js';
import './components/dropdown.js';
import './components/modal.js';
import './components/search.js';
// Hard-forked from Fomantic UI 2.8.7, patches are commented with "GITEA-PATCH"
@@ -44,7 +44,6 @@
@progress : 'default';
@slider : 'default';
@rating : 'default';
@search : 'default';
@shape : 'default';
@sidebar : 'default';
@sticky : 'default';
@@ -0,0 +1,36 @@
import {buildArtifactTooltipHtml} from './ActionRunArtifacts.ts';
import {normalizeTestHtml} from '../utils/testhelper.ts';
describe('buildArtifactTooltipHtml', () => {
test('active artifact', () => {
const expiresUnix = Date.UTC(2026, 2, 20, 12, 0, 0) / 1000;
const expiresLocal = new Date(expiresUnix * 1000).toLocaleString();
const result = buildArtifactTooltipHtml({
name: 'artifact.zip',
size: 1024 * 1024,
status: 'completed',
expiresUnix,
}, 'Expires at %s (extra)');
expect(normalizeTestHtml(result)).toBe(normalizeTestHtml(`<span class="flex-text-inline">
<span>Expires at </span>
<relative-time datetime="${expiresUnix}" threshold="P0Y" prefix="" weekday="" year="numeric" month="short" hour="numeric" minute="2-digit">
${expiresLocal}
</relative-time>
<span> (extra)</span>
<span class="inline-divider">,</span>
<span>1.0 MiB</span>
</span>
`));
});
test('no expiry', () => {
const result = buildArtifactTooltipHtml({
name: 'artifact.zip',
size: 512,
status: 'completed',
expiresUnix: 0,
}, 'Expires at %s');
expect(normalizeTestHtml(result)).toBe(`<span class="flex-text-inline">512 B</span>`);
});
});
@@ -0,0 +1,24 @@
import {html} from '../utils/html.ts';
import {formatBytes} from '../utils.ts';
import type {ActionsArtifact} from '../modules/gitea-actions.ts';
export function buildArtifactTooltipHtml(artifact: ActionsArtifact, expiresAtLocale: string): string {
const sizeText = formatBytes(artifact.size);
if (artifact.expiresUnix <= 0) {
return html`<span class="flex-text-inline">${sizeText}</span>`; // use the same layout as below
}
const datetimeLocal = new Date(artifact.expiresUnix * 1000).toLocaleString();
// split so the <relative-time> element can be interleaved, e.g. "Expires at %s" -> ["Expires at ", ""]
const [prefix, suffix = ''] = expiresAtLocale.split('%s');
return html`
<span class="flex-text-inline">
<span>${prefix}</span>
<relative-time datetime="${artifact.expiresUnix}" threshold="P0Y" prefix="" weekday="" year="numeric" month="short" hour="numeric" minute="2-digit">
${datetimeLocal}
</relative-time>
<span>${suffix}</span>
<span class="inline-divider">,</span>
<span>${sizeText}</span>
</span>
`;
}
@@ -1,20 +1,21 @@
<script setup lang="ts">
import {nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch} from 'vue';
import {computed, nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch} from 'vue';
import {SvgIcon} from '../svg.ts';
import ActionRunStatus from './ActionRunStatus.vue';
import {addDelegatedEventListener, createElementFromAttrs, toggleElem} from '../utils/dom.ts';
import {formatDatetime} from '../utils/time.ts';
import ActionStatusIcon from './ActionStatusIcon.vue';
import {addDelegatedEventListener, createElementFromAttrs} from '../utils/dom.ts';
import {formatDatetime, formatDatetimeISO} from '../utils/time.ts';
import {POST} from '../modules/fetch.ts';
import {copyToClipboardWithFeedback} from '../modules/clipboard.ts';
import type {IntervalId} from '../types.ts';
import {toggleFullScreen} from '../utils.ts';
import {localUserSettings} from '../modules/user-settings.ts';
import type {ActionsArtifact, ActionsRun, ActionsRunStatus} from '../modules/gitea-actions.ts';
import type {ActionsArtifact, ActionsJob, ActionsRun, ActionsStatus} from '../modules/gitea-actions.ts';
import {
type ActionRunViewStore,
createLogLineMessage,
type LogLine,
type LogLineCommand,
parseLogLineCommand
parseLogLineCommand,
} from './ActionRunView.ts';
function isLogElementInViewport(el: Element, {extraViewPortHeight}={extraViewPortHeight: 0}): boolean {
@@ -26,7 +27,7 @@ function isLogElementInViewport(el: Element, {extraViewPortHeight}={extraViewPor
type Step = {
summary: string,
duration: string,
status: ActionsRunStatus,
status: ActionsStatus,
}
type JobStepState = {
@@ -77,9 +78,8 @@ defineOptions({
const props = defineProps<{
store: ActionRunViewStore,
runId: number;
jobId: number;
actionsUrl: string;
actionsViewUrl: string;
locale: Record<string, any>;
}>();
const store = props.store;
@@ -116,6 +116,12 @@ const currentJob = ref<CurrentJob>({
const stepsContainer = ref<HTMLElement | null>(null);
const jobStepLogs = ref<Array<StepContainerElement | undefined>>([]);
// Reusable workflow caller view: the right pane shows just the header (name + uses path +
// status). Callers don't run on a runner, and the dependency graph for their children lives
// in the run summary's WorkflowGraph, not here — matching GitHub Actions.
const selectedJob = computed<ActionsJob | undefined>(() => (run.value.jobs || []).find((it) => it.id === props.jobId));
const isCallerJob = computed(() => Boolean(selectedJob.value?.isReusableCaller));
watch(optionAlwaysAutoScroll, () => {
saveLocaleStorageOptions();
});
@@ -202,6 +208,22 @@ function endLogGroup(stepIndex: number) {
el._stepLogsActiveContainer = undefined;
}
async function copyStepOutput(event: MouseEvent, stepIndex: number) {
await copyToClipboardWithFeedback(event.currentTarget as HTMLElement, async () => {
const data = await fetchJobData([{step: stepIndex, cursor: null, expanded: true}]);
const stepLog = data.logs.stepsLog?.find((s) => s.step === stepIndex);
const lines: string[] = [];
for (const line of stepLog?.lines ?? []) {
const cmd = parseLogLineCommand(line);
if (cmd?.name === 'hidden' || cmd?.name === 'endgroup') continue;
const ts = formatDatetimeISO(line.timestamp);
const msg = createLogLineMessage(line, cmd).textContent ?? '';
lines.push(`${ts} ${msg}`);
}
return lines.join('\n');
});
}
// show/hide the step logs for a step
function toggleStepLogs(idx: number) {
currentJobStepsStates.value[idx].expanded = !currentJobStepsStates.value[idx].expanded;
@@ -217,7 +239,7 @@ function createLogLine(stepIndex: number, startTime: number, line: LogLine, cmd:
String(line.index),
);
const logTimeStamp = createElementFromAttrs('span', {class: 'log-time-stamp'},
formatDatetime(new Date(line.timestamp * 1000)), // for "Show timestamps"
formatDatetime(line.timestamp * 1000), // for "Show timestamps"
);
const logMsg = createLogLineMessage(line, cmd);
const seconds = Math.floor(line.timestamp - startTime);
@@ -225,9 +247,6 @@ function createLogLine(stepIndex: number, startTime: number, line: LogLine, cmd:
`${seconds}s`, // for "Show seconds"
);
toggleElem(logTimeStamp, timeVisible.value['log-time-stamp']);
toggleElem(logTimeSeconds, timeVisible.value['log-time-seconds']);
const lineClass = cmd?.name ? `job-log-line log-line-${cmd.name}` : 'job-log-line';
return createElementFromAttrs('div', {id: `jobstep-${stepIndex}-${line.index}`, class: lineClass},
lineNum, logTimeStamp, logMsg, logTimeSeconds,
@@ -262,18 +281,14 @@ function appendLogs(stepIndex: number, startTime: number, logLines: LogLine[]) {
}
}
async function fetchJobData(abortController: AbortController): Promise<JobData> {
const logCursors = currentJobStepsStates.value.map((it, idx) => {
// cursor is used to indicate the last position of the logs
// it's only used by backend, frontend just reads it and passes it back, it can be any type.
// for example: make cursor=null means the first time to fetch logs, cursor=eof means no more logs, etc
return {step: idx, cursor: it.cursor, expanded: it.expanded};
});
const url = `${props.actionsUrl}/runs/${props.runId}/jobs/${props.jobId}`;
const resp = await POST(url, {
signal: abortController.signal,
data: {logCursors},
});
// "cursor" is used to indicate the last position of the logs.
// It's only used by backend, frontend just reads it and passes it back, it can be any type.
// Frontend knows nothing about its type, never uses its value.
// For example: backend can make cursor=null means the first time to fetch logs, cursor=1234 for a position, cursor=eof for no more logs, etc.
type LogCursor = {step: number, cursor: any, expanded: boolean};
async function fetchJobData(logCursors: LogCursor[], signal?: AbortSignal): Promise<JobData> {
const resp = await POST(props.actionsViewUrl, {signal, data: {logCursors}});
return await resp.json();
}
@@ -288,7 +303,8 @@ async function loadJob() {
const abortController = new AbortController();
loadingAbortController = abortController;
try {
const runJobResp = await fetchJobData(abortController);
const logCursors = currentJobStepsStates.value.map((it, idx) => ({step: idx, cursor: it.cursor, expanded: it.expanded}));
const runJobResp = await fetchJobData(logCursors, abortController.signal);
if (loadingAbortController !== abortController) return;
// FIXME: this logic is quite hacky and dirty, it should be refactored in a better way in the future
@@ -354,11 +370,11 @@ async function loadJob() {
}
}
function isDone(status: ActionsRunStatus) {
function isDone(status: ActionsStatus) {
return ['success', 'skipped', 'failure', 'cancelled'].includes(status);
}
function isExpandable(status: ActionsRunStatus) {
function isExpandable(status: ActionsStatus) {
return ['success', 'running', 'failure', 'cancelled'].includes(status);
}
@@ -372,9 +388,6 @@ function elStepsContainer(): HTMLElement {
function toggleTimeDisplay(type: 'seconds' | 'stamp') {
timeVisible.value[`log-time-${type}`] = !timeVisible.value[`log-time-${type}`];
for (const el of elStepsContainer().querySelectorAll(`.log-time-${type}`)) {
toggleElem(el, timeVisible.value[`log-time-${type}`]);
}
saveLocaleStorageOptions();
}
@@ -404,16 +417,22 @@ async function hashChangeListener() {
<template>
<div class="job-info-header">
<div class="job-info-header-left gt-ellipsis">
<h3 class="job-info-header-title gt-ellipsis">
{{ currentJob.title }}
</h3>
<div class="job-info-header-title-row">
<h3 class="job-info-header-title gt-ellipsis">
{{ isCallerJob ? selectedJob?.name : currentJob.title }}
</h3>
<span v-if="isCallerJob && selectedJob?.callUses" class="ui label job-info-header-uses">
<span>uses:</span>
<span class="gt-ellipsis">{{ selectedJob.callUses }}</span>
</span>
</div>
<p class="job-info-header-detail">
{{ currentJob.detail }}
{{ isCallerJob && selectedJob ? locale.status[selectedJob.status] : currentJob.detail }}
</p>
</div>
<div class="job-info-header-right">
<div class="ui top right pointing dropdown custom jump item" @click.stop="menuVisible = !menuVisible" @keyup.enter="menuVisible = !menuVisible">
<button class="ui button tw-px-3">
<button class="btn interact-bg tw-p-2">
<SvgIcon name="octicon-gear" :size="18"/>
</button>
<div class="menu transition action-job-menu" :class="{visible: menuVisible}" v-if="menuVisible" v-cloak>
@@ -448,7 +467,15 @@ async function hashChangeListener() {
</div>
</div>
<!-- always create the node because we have our own event listeners on it, don't use "v-if" -->
<div class="job-step-container" ref="stepsContainer" v-show="currentJob.steps.length">
<div
class="job-step-container"
ref="stepsContainer"
v-show="!isCallerJob && currentJob.steps.length"
:class="{
'log-line-show-timestamps': timeVisible['log-time-stamp'],
'log-line-show-seconds': timeVisible['log-time-seconds']
}"
>
<div class="job-step-section" v-for="(jobStep, stepIdx) in currentJob.steps" :key="stepIdx">
<div
class="job-step-summary"
@@ -461,15 +488,25 @@ async function hashChangeListener() {
<SvgIcon
v-if="isDone(run.status) && currentJobStepsStates[stepIdx].expanded && currentJobStepsStates[stepIdx].cursor === null"
name="gitea-running"
class="tw-mr-2 rotate-clockwise"
class="rotate-clockwise"
/>
<SvgIcon
v-else
:name="currentJobStepsStates[stepIdx].expanded ? 'octicon-chevron-down' : 'octicon-chevron-right'"
:class="['tw-mr-2', !isExpandable(jobStep.status) && 'tw-invisible']"
name="octicon-chevron-right"
class="tw-mr-2 step-summary-chevron"
:class="{'tw-invisible': !isExpandable(jobStep.status)}"
/>
<ActionRunStatus :status="jobStep.status" class="tw-mr-2"/>
<ActionStatusIcon :status="jobStep.status" icon-variant="circle-fill"/>
<span class="step-summary-msg gt-ellipsis">{{ jobStep.summary }}</span>
<button
v-if="isExpandable(jobStep.status)"
class="btn interact-fg step-copy-btn"
:aria-label="locale.copyOutput"
:data-tooltip-content="locale.copyOutput"
@click.stop="copyStepOutput($event, stepIdx)"
>
<SvgIcon name="octicon-copy" :size="14"/>
</button>
<span class="step-summary-duration">{{ jobStep.duration }}</span>
</div>
<!-- the log elements could be a lot, do not use v-if to destroy/reconstruct the DOM,
@@ -541,6 +578,21 @@ async function hashChangeListener() {
.job-info-header-left {
flex: 1;
min-width: 0;
}
.job-info-header-title-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.job-info-header-uses {
display: inline-flex !important;
align-items: baseline;
gap: 4px;
min-width: 0;
}
.job-step-container {
@@ -554,6 +606,7 @@ async function hashChangeListener() {
padding: 5px 10px;
display: flex;
align-items: center;
gap: 8px;
border-radius: var(--border-radius);
}
@@ -566,12 +619,32 @@ async function hashChangeListener() {
background: var(--color-console-hover-bg);
}
.job-step-container .job-step-summary .step-summary-chevron {
transition: transform 0.1s ease;
}
.job-step-container .job-step-summary.selected .step-summary-chevron {
transform: rotate(90deg);
}
.job-step-container .job-step-summary .step-summary-msg {
flex: 1;
}
.job-step-container .job-step-summary .step-summary-duration {
margin-left: 16px;
.job-step-container .job-step-summary .step-copy-btn {
visibility: hidden;
margin: 0 4px;
}
.job-step-container .job-step-summary:hover .step-copy-btn,
.job-step-container .job-step-summary.selected .step-copy-btn {
visibility: visible;
}
@media (hover: none) {
.job-step-container .job-step-summary:focus-within .step-copy-btn {
visibility: visible;
}
}
.job-step-container .job-step-summary.selected {
@@ -610,8 +683,22 @@ async function hashChangeListener() {
scroll-margin-top: 95px;
}
.job-log-line .log-time-stamp,
.job-log-line .log-time-seconds {
display: none;
}
.log-line-show-timestamps .job-log-line .log-time-stamp {
display: inline;
}
.log-line-show-seconds .job-log-line .log-time-seconds {
display: inline;
}
/* class names 'log-time-seconds' and 'log-time-stamp' are used in the method toggleTimeDisplay */
.job-log-line .line-num, .log-time-seconds {
.job-log-line .line-num,
.job-log-line .log-time-seconds {
width: 48px;
color: var(--color-text-light-3);
text-align: right;
@@ -628,16 +715,16 @@ async function hashChangeListener() {
}
.job-log-line .log-time,
.log-time-stamp {
.job-log-line .log-time-stamp {
color: var(--color-text-light-3);
margin-left: 10px;
margin-left: 12px;
white-space: nowrap;
}
.job-step-logs .job-log-line .log-msg {
flex: 1;
white-space: break-spaces;
margin-left: 10px;
white-space: break-spaces; /* decoded commands like "::error::foo%0Abar" contain "\n" */
margin-left: 12px;
overflow-wrap: anywhere;
}
@@ -704,18 +791,28 @@ async function hashChangeListener() {
border-radius: 0;
}
.job-log-group .job-log-list .job-log-line .log-msg {
margin-left: 2em;
}
.job-log-group-summary {
position: relative;
cursor: pointer;
list-style: none; /* hide the standard disclosure marker (Chrome, Edge, Firefox) */
}
.job-log-group-summary > .job-log-line {
position: absolute;
inset: 0;
z-index: -1; /* to avoid hiding the triangle of the "details" element */
overflow: hidden;
.job-log-group-summary::-webkit-details-marker { /* hide the disclosure marker on Safari */
display: none;
}
.log-line-group .log-msg::before {
content: "";
display: inline-block;
vertical-align: middle;
margin-top: -2.5px;
margin-right: 8px;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 6px solid var(--color-text-light-3);
transition: transform 0.1s ease;
}
.job-log-group[open] .log-line-group .log-msg::before {
transform: rotate(90deg);
}
</style>
@@ -1,30 +0,0 @@
<!-- This vue should be kept the same as templates/repo/actions/status.tmpl
Please also update the template file above if this vue is modified.
action status accepted: success, skipped, waiting, blocked, running, failure, cancelled, unknown
-->
<script lang="ts" setup>
import {SvgIcon} from '../svg.ts';
withDefaults(defineProps<{
status: 'success' | 'skipped' | 'waiting' | 'blocked' | 'running' | 'failure' | 'cancelled' | 'unknown',
size?: number,
className?: string,
localeStatus?: string,
}>(), {
size: 16,
className: '',
localeStatus: undefined,
});
</script>
<template>
<span :data-tooltip-content="localeStatus ?? status" v-if="status">
<SvgIcon name="octicon-check-circle-fill" class="tw-text-green" :size="size" :class="className" v-if="status === 'success'"/>
<SvgIcon name="octicon-skip" class="tw-text-text-light" :size="size" :class="className" v-else-if="status === 'skipped'"/>
<SvgIcon name="octicon-stop" class="tw-text-text-light" :size="size" :class="className" v-else-if="status === 'cancelled'"/>
<SvgIcon name="octicon-circle" class="tw-text-text-light" :size="size" :class="className" v-else-if="status === 'waiting'"/>
<SvgIcon name="octicon-blocked" class="tw-text-yellow" :size="size" :class="className" v-else-if="status === 'blocked'"/>
<SvgIcon name="gitea-running" class="tw-text-yellow" :size="size" :class="'rotate-clockwise ' + className" v-else-if="status === 'running'"/>
<SvgIcon name="octicon-x-circle-fill" class="tw-text-red" :size="size" v-else/><!-- failure, unknown -->
</span>
</template>
@@ -1,5 +1,4 @@
<script setup lang="ts">
import ActionRunStatus from './ActionRunStatus.vue';
import WorkflowGraph from './WorkflowGraph.vue';
import type {ActionRunViewStore} from "./ActionRunView.ts";
import {computed, onBeforeUnmount, onMounted, toRefs} from "vue";
@@ -11,15 +10,42 @@ defineOptions({
const props = defineProps<{
store: ActionRunViewStore;
locale: Record<string, any>;
artifactCount: number;
}>();
const locale = props.locale;
const {currentRun: run} = toRefs(props.store.viewData);
const runTriggeredAtIso = computed(() => {
const t = props.store.viewData.currentRun.triggeredAt;
return t ? new Date(t * 1000).toISOString() : '';
const isRerun = computed(() => run.value.runAttempt > 1);
// The summary's dependency graph is the workflow's top-level shape: a reusable caller
// renders as a single node, its expanded children belong to the caller's own detail page.
const topLevelJobs = computed(() => (run.value.jobs || []).filter((j) => !j.parentJobID));
const triggerUser = computed(() => {
const currentAttempt = run.value.attempts.find((attempt) => attempt.current);
if (currentAttempt) {
return {
name: currentAttempt.triggerUserName,
link: currentAttempt.triggerUserLink,
avatar: currentAttempt.triggerUserAvatar,
};
}
const pusher = run.value.commit.pusher;
return pusher.displayName ? {
name: pusher.displayName,
link: pusher.link,
avatar: pusher.avatarLink,
} : null;
});
const triggerLabel = computed(() => {
if (isRerun.value) return locale.rerunTriggered;
return locale.triggeredVia.replace('%s', run.value.triggerEvent);
});
const artifactsDisplay = computed(() => props.artifactCount > 0 ? String(props.artifactCount) : '–');
onMounted(async () => {
await props.store.startPollingCurrentRun();
});
@@ -31,20 +57,71 @@ onBeforeUnmount(() => {
<template>
<div class="action-run-summary-view">
<div class="action-run-summary-block">
<div class="flex-text-block">
{{ locale.triggeredVia.replace('%s', run.triggerEvent) }} • <relative-time :datetime="runTriggeredAtIso" prefix=""/>
<div class="action-run-summary-trigger">
<span class="action-run-summary-label">
{{ triggerLabel }} <relative-time :datetime="run.triggeredAt || ''" prefix=""/>
</span>
<div class="flex-text-block tw-flex-wrap action-run-summary-trigger-content">
<component
:is="triggerUser.link ? 'a' : 'span'"
v-if="triggerUser"
class="flex-text-inline action-run-summary-user"
:class="{silenced: triggerUser.link}"
:href="triggerUser.link || undefined"
>
<img
v-if="triggerUser.avatar"
class="ui avatar tw-align-middle"
:src="triggerUser.avatar"
width="16"
height="16"
:alt="triggerUser.name"
>
<span>{{ triggerUser.name }}</span>
</component>
<a v-if="run.pullRequest" class="action-run-summary-pr silenced" :href="run.pullRequest.link">{{ run.pullRequest.index }}</a>
<span v-else-if="run.commit.branch.name" class="action-run-summary-branch-label tw-max-w-full">
<a
v-if="!run.commit.branch.isDeleted && run.commit.branch.link"
class="gt-ellipsis silenced"
:href="run.commit.branch.link"
:title="run.commit.branch.name"
>{{ run.commit.branch.name }}</a>
<span
v-else
class="gt-ellipsis tw-line-through"
:title="run.commit.branch.name"
>{{ run.commit.branch.name }}</span>
</span>
</div>
</div>
<div class="flex-text-block">
<ActionRunStatus :locale-status="locale.status[run.status]" :status="run.status" :size="16"/>
<span>{{ locale.status[run.status] }}</span> • <span>{{ locale.totalDuration }} {{ run.duration || '–' }}</span>
<div class="action-run-summary-stat-divider"/>
<div class="action-run-summary-stat">
<span class="action-run-summary-label">{{ locale.statusLabel }}</span>
<span class="action-run-summary-stat-value">{{ locale.status[run.status] }}</span>
</div>
<div class="action-run-summary-stat">
<span class="action-run-summary-label">{{ locale.totalDuration }}</span>
<span class="action-run-summary-stat-value">{{ run.duration || '–' }}</span>
</div>
<div class="action-run-summary-stat action-run-summary-stat-last">
<span class="action-run-summary-label">{{ locale.artifactsTitle }}</span>
<span class="action-run-summary-stat-value">{{ artifactsDisplay }}</span>
</div>
</div>
<WorkflowGraph
v-if="run.jobs.length > 0"
v-if="topLevelJobs.length > 0"
:store="store"
:jobs="run.jobs"
:jobs="topLevelJobs"
:run-link="run.link"
:workflow-id="run.workflowID"
:workflow-link="run.canViewWorkflowFile ? `${run.link}/workflow` : ''"
:trigger-event="run.triggerEvent"
:locale="locale"
/>
</div>
</template>
@@ -58,13 +135,119 @@ onBeforeUnmount(() => {
.action-run-summary-block {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 6px;
padding: 12px;
align-items: stretch; /* equal-height columns so labels align at top and values at bottom */
padding: 12px 16px;
border-bottom: 1px solid var(--color-secondary);
border-radius: var(--border-radius) var(--border-radius) 0 0;
background: var(--color-box-header);
background: var(--color-console-bg);
}
.action-run-summary-trigger {
display: flex;
flex-direction: column;
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
margin-right: 24px;
}
.action-run-summary-label {
display: block;
margin-bottom: 4px;
font-size: 12px;
line-height: 1.4;
color: var(--color-text-light-2);
}
.action-run-summary-trigger-content {
margin-top: auto; /* pin trigger content to the bottom, aligned with the stat values */
color: var(--color-text-light-2);
align-items: center;
}
.action-run-summary-user {
font-weight: var(--font-weight-semibold);
color: var(--color-text);
line-height: 16px;
}
.action-run-summary-user .ui.avatar {
margin: 0;
}
.action-run-summary-pr {
color: var(--color-text);
line-height: 16px;
}
.action-run-summary-branch-label {
display: inline-flex;
align-items: center;
max-width: 200px;
min-height: 20px;
padding: 0 6px;
border-radius: var(--border-radius);
background: var(--color-primary-light-6);
color: var(--color-primary);
font-size: 12px;
line-height: 20px;
font-family: var(--fonts-monospace);
}
.action-run-summary-branch-label a {
color: inherit;
}
.action-run-summary-branch-label a:hover {
text-decoration: underline;
}
.action-run-summary-user:hover span {
color: var(--color-primary);
}
.action-run-summary-stat {
display: flex;
flex-direction: column;
flex: 0 0 auto;
min-width: 72px;
margin-left: 24px;
margin-right: 24px;
}
.action-run-summary-stat-last {
margin-right: 0;
}
.action-run-summary-stat-divider {
display: none;
flex: 0 0 100%;
margin: 8px 0;
border-bottom: 1px solid var(--color-secondary);
}
.action-run-summary-stat-value {
display: block;
margin-top: auto; /* pin value to the bottom so all column values share a baseline */
font-size: 16px;
line-height: 1.25;
font-weight: var(--font-weight-semibold);
color: var(--color-text);
}
@media (max-width: 767.98px) {
.action-run-summary-trigger {
flex: 0 0 100%;
margin-right: 0;
}
.action-run-summary-stat {
margin-left: 0;
margin-right: 24px;
}
.action-run-summary-stat-divider {
display: block;
}
}
</style>
@@ -14,8 +14,16 @@ test('LogLineMessage', () => {
'##[debug] foo': '<span class="log-msg log-cmd-debug"><span class="log-msg-label">Debug:</span><span> foo</span></span>',
'::error::foo': '<span class="log-msg log-cmd-error"><span class="log-msg-label">Error:</span><span> foo</span></span>',
'::warning file=test.js,line=1::foo': '<span class="log-msg log-cmd-warning"><span class="log-msg-label">Warning:</span><span> foo</span></span>',
'::error::foo%0Abar': '<span class="log-msg log-cmd-error"><span class="log-msg-label">Error:</span><span> foo\nbar</span></span>',
'::error::foo%0D%0Abar': '<span class="log-msg log-cmd-error"><span class="log-msg-label">Error:</span><span> foo\nbar</span></span>',
'::error::100%25 done%250A': '<span class="log-msg log-cmd-error"><span class="log-msg-label">Error:</span><span> 100% done%0A</span></span>',
'::error::keep%5Dsemi%3B': '<span class="log-msg log-cmd-error"><span class="log-msg-label">Error:</span><span> keep%5Dsemi%3B</span></span>',
'::group::foo%0Abar': '<span class="log-msg log-cmd-group">foo\nbar</span>',
'##[error]foo%0Abar%3B%5D': '<span class="log-msg log-cmd-error"><span class="log-msg-label">Error:</span><span> foo\nbar;]</span></span>',
'##[command]foo%0Abar': '<span class="log-msg log-cmd-command">foo%0Abar</span>',
'::notice::foo': '<span class="log-msg log-cmd-notice"><span class="log-msg-label">Notice:</span><span> foo</span></span>',
'::debug::foo': '<span class="log-msg log-cmd-debug"><span class="log-msg-label">Debug:</span><span> foo</span></span>',
'##[command] foo': '<span class="log-msg log-cmd-command"> foo</span>',
'[command] foo': '<span class="log-msg log-cmd-command"> foo</span>',
// hidden is special, it is actually skipped before creating
@@ -1,7 +1,7 @@
import {createElementFromAttrs} from '../utils/dom.ts';
import {renderAnsi} from '../render/ansi.ts';
import {renderAnsiInto} from '../render/ansi.ts';
import {reactive} from 'vue';
import type {ActionsArtifact, ActionsJob, ActionsRun, ActionsRunStatus} from '../modules/gitea-actions.ts';
import type {ActionsArtifact, ActionsJob, ActionsRun, ActionsStatus} from '../modules/gitea-actions.ts';
import type {IntervalId} from '../types.ts';
import {POST} from '../modules/fetch.ts';
@@ -9,7 +9,8 @@ import {POST} from '../modules/fetch.ts';
// * Workflow command outputs log commands like "::group::the-title", "::add-matcher::...."
// * Workflow runner parses and processes the commands to "##[group]", apply "matchers", hide secrets, etc.
// * The reported logs are the processed logs.
// HOWEVER: Gitea runner does not completely process those commands. Many works are done by the frontend at the moment.
// HOWEVER: Gitea cannot, a decoded message may contain newlines and FormatLog drops them,
// so the commands arrive here still escaped and the frontend decodes them.
const LogLinePrefixCommandMap: Record<string, LogLineCommandName> = {
'::group::': 'group',
'##[group]': 'group',
@@ -20,6 +21,7 @@ const LogLinePrefixCommandMap: Record<string, LogLineCommandName> = {
'##[warning]': 'warning',
'##[notice]': 'notice',
'##[debug]': 'debug',
'##[command]': 'command',
'[command]': 'command',
// https://github.com/actions/toolkit/blob/master/docs/commands.md
@@ -46,9 +48,9 @@ export type LogLineCommand = {
export function parseLogLineCommand(line: LogLine): LogLineCommand | null {
// TODO: in the future it can be refactored to be a general parser that can parse arguments, drop the "prefix match"
for (const prefix of Object.keys(LogLinePrefixCommandMap)) {
for (const [prefix, commandName] of Object.entries(LogLinePrefixCommandMap)) {
if (line.message.startsWith(prefix)) {
return {name: LogLinePrefixCommandMap[prefix], prefix};
return {name: commandName, prefix};
}
}
// Handle ::cmd:: and ::cmd args:: format (runner may pass these through raw)
@@ -66,34 +68,61 @@ const LogLineLabelMap: Partial<Record<LogLineCommandName, string>> = {
'debug': 'Debug',
};
function decodeLineMessage(line: LogLine, cmd: LogLineCommand | null): string {
// TODO: for some commands (::group::), the "prefix removal" works well, for some commands with "arguments" (::remove-matcher ...::),
// it needs to do further processing in the future (fortunately, at the moment we don't need to handle these commands)
if (!cmd) return line.message;
let msg = line.message.substring(cmd.prefix.length);
if (cmd.name === 'command') return msg; // "command" is only an output tag, do not parse or escape it
// "##[cmd]" also escapes ";" and "]" which delimit its header, "::cmd::" does not
if (!cmd.prefix.startsWith('::')) msg = msg.replace(/%3B/g, ';').replace(/%5D/g, ']');
// renderAnsiInto breaks a line per "\r", so "%0D%0A" is one break. "%25" last keeps "%250A" literal
return msg.replace(/(?:%0D)?%0A/g, '\n').replace(/%0D/g, '\r').replace(/%25/g, '%');
}
export function createLogLineMessage(line: LogLine, cmd: LogLineCommand | null) {
const logMsgAttrs = {class: 'log-msg'};
if (cmd?.name) logMsgAttrs.class += ` log-cmd-${cmd.name}`; // make it easier to add styles to some commands like "error"
// TODO: for some commands (::group::), the "prefix removal" works well, for some commands with "arguments" (::remove-matcher ...::),
// it needs to do further processing in the future (fortunately, at the moment we don't need to handle these commands)
const msgContent = cmd ? line.message.substring(cmd.prefix.length) : line.message;
const msgContent = decodeLineMessage(line, cmd);
const logMsg = createElementFromAttrs('span', logMsgAttrs);
const label = cmd ? LogLineLabelMap[cmd.name] : null;
if (label) {
logMsg.append(createElementFromAttrs('span', {class: 'log-msg-label'}, `${label}:`));
const msgSpan = document.createElement('span');
msgSpan.innerHTML = ` ${renderAnsi(msgContent.trimStart())}`;
renderAnsiInto(msgSpan, ` ${msgContent.trimStart()}`);
logMsg.append(msgSpan);
} else {
logMsg.innerHTML = renderAnsi(msgContent);
renderAnsiInto(logMsg, msgContent);
}
return logMsg;
}
// buildJobsByParentJobID groups jobs by their parentJobID (0 = top level).
// Useful for rendering the reusable-workflow caller/child tree in the sidebar.
export function buildJobsByParentJobID(jobs: ActionsJob[]): Map<number, ActionsJob[]> {
const childrenByParent = new Map<number, ActionsJob[]>();
for (const job of jobs) {
const parentID = job.parentJobID || 0;
const existing = childrenByParent.get(parentID);
if (existing) {
existing.push(job);
} else {
childrenByParent.set(parentID, [job]);
}
}
return childrenByParent;
}
export function createEmptyActionsRun(): ActionsRun {
return {
repoId: 0,
index: 0,
link: '',
viewLink: '',
title: '',
titleHTML: '',
status: '' as ActionsRunStatus, // do not show the status before initialized, otherwise it would show an incorrect "error" icon
status: '' as ActionsStatus, // do not show the status before initialized, otherwise it would show an incorrect "error" icon
canCancel: false,
canApprove: false,
canRerun: false,
@@ -102,11 +131,16 @@ export function createEmptyActionsRun(): ActionsRun {
done: false,
workflowID: '',
workflowLink: '',
canViewWorkflowFile: true,
isSchedule: false,
runAttempt: 0,
attempts: [],
duration: '',
triggeredAt: 0,
triggerEvent: '',
pullRequest: null,
jobs: [] as Array<ActionsJob>,
jobSummaries: [],
commit: {
localeCommit: '',
localePushedBy: '',
@@ -115,6 +149,7 @@ export function createEmptyActionsRun(): ActionsRun {
pusher: {
displayName: '',
link: '',
avatarLink: '',
},
branch: {
name: '',
@@ -125,7 +160,7 @@ export function createEmptyActionsRun(): ActionsRun {
};
}
export function createActionRunViewStore(actionsUrl: string, runId: number) {
export function createActionRunViewStore(viewUrl: string) {
let loadingAbortController: AbortController | null = null;
let intervalID: IntervalId | null = null;
const viewData = reactive({
@@ -137,8 +172,7 @@ export function createActionRunViewStore(actionsUrl: string, runId: number) {
const abortController = new AbortController();
loadingAbortController = abortController;
try {
const url = `${actionsUrl}/runs/${runId}`;
const resp = await POST(url, {signal: abortController.signal, data: {}});
const resp = await POST(viewUrl, {signal: abortController.signal, data: {}});
const runResp = await resp.json();
if (loadingAbortController !== abortController) return;
@@ -158,7 +192,7 @@ export function createActionRunViewStore(actionsUrl: string, runId: number) {
}
};
return reactive({
return {
viewData,
async startPollingCurrentRun() {
@@ -175,7 +209,7 @@ export function createActionRunViewStore(actionsUrl: string, runId: number) {
clearInterval(intervalID);
intervalID = null;
},
});
};
}
export type ActionRunViewStore = ReturnType<typeof createActionRunViewStore>;
@@ -0,0 +1,32 @@
<!-- Keep in sync with templates/repo/icons/action_status.tmpl.
action status accepted: success, skipped, waiting, blocked, running, failure, cancelled, cancelling, unknown.
-->
<script lang="ts" setup>
import {computed} from 'vue';
import {SvgIcon} from '../svg.ts';
import {getActionStatusIcon, type ActionStatusIconVariant} from '../modules/action-status-icon.ts';
const props = withDefaults(defineProps<{
status: 'success' | 'skipped' | 'waiting' | 'blocked' | 'running' | 'failure' | 'cancelled' | 'cancelling' | 'unknown',
size?: number,
className?: string,
localeStatus?: string,
iconVariant?: ActionStatusIconVariant,
}>(), {
size: 16,
className: '',
localeStatus: undefined,
iconVariant: '',
});
const icon = computed(() => getActionStatusIcon(props.status, props.iconVariant));
const iconClass = computed(() => {
const classes = [icon.value.colorClass, props.className];
if (props.status === 'running') classes.push('rotate-clockwise');
return classes.filter(Boolean).join(' ');
});
</script>
<template>
<SvgIcon v-if="status" :name="icon.name" :class="iconClass" :size="size" :data-tooltip-content="localeStatus ?? status"/>
</template>
@@ -1,21 +1,24 @@
<script lang="ts" setup>
// TODO: Switch to upstream after https://github.com/razorness/vue3-calendar-heatmap/pull/34 is merged
import {CalendarHeatmap} from '@silverwind/vue3-calendar-heatmap';
import {onMounted, shallowRef} from 'vue';
import type {Value as HeatmapValue, Locale as HeatmapLocale} from '@silverwind/vue3-calendar-heatmap';
import {computed, onBeforeUnmount, onMounted} from 'vue';
import tippy, {createSingleton} from 'tippy.js';
import type {CreateSingletonInstance, Instance} from 'tippy.js';
import {getCurrentLocale} from '../utils.ts';
defineProps<{
type HeatmapValue = {date: Date; count: number};
type HeatmapCell = {date: Date; colorIndex: number; ariaLabel: string; tooltip: string};
type MonthLabel = {monthIdx: number; weekIdx: number};
const props = defineProps<{
values: HeatmapValue[];
locale: {
textTotalContributions: string;
heatMapLocale: Partial<HeatmapLocale>;
heatMapLocale: {months: string[]; days: string[]; on: string; more: string; less: string};
noDataText: string;
tooltipUnit: string;
};
}>();
const colorRange = [
'var(--color-secondary-alpha-60)',
'var(--color-secondary-alpha-60)',
'var(--color-primary-light-4)',
'var(--color-primary-light-2)',
@@ -24,21 +27,113 @@ const colorRange = [
'var(--color-primary-dark-4)',
];
const endDate = shallowRef(new Date());
const squareSize = 10;
const squareBorder = 2;
const cellSize = squareSize + squareBorder;
const daysInWeek = 7;
const trailingDays = 365;
const gridLeft = Math.ceil(squareSize * 2.5);
const gridTop = squareSize + squareSize / 2;
onMounted(() => {
// work around issue with first legend color being rendered twice and legend cut off
const legend = document.querySelector<HTMLElement>('.vch__external-legend-wrapper')!;
legend.setAttribute('viewBox', '12 0 80 10');
legend.style.marginRight = '-12px';
const now = new Date();
function dateKey(d: Date): string {
return `${d.getFullYear()}${String(d.getMonth()).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
}
function shiftDate(d: Date, days: number): Date {
const out = new Date(d);
out.setDate(out.getDate() + days);
return out;
}
const grid = computed(() => {
const start = shiftDate(now, -trailingDays);
const padStart = start.getDay();
const padEnd = daysInWeek - 1 - now.getDay();
const weekCount = (trailingDays + 1 + padStart + padEnd) / daysInWeek;
const maxCount = props.values.length ? Math.max(...props.values.map((v) => v.count)) : 0;
const max = maxCount > 0 ? Math.ceil(maxCount / 5 * 4) : 1;
const activities = new Map<string, {count: number; colorIndex: number}>();
for (const {date, count} of props.values) {
const colorIndex = count >= max ? 4 : Math.max(1, Math.ceil((count / max) * 3));
activities.set(dateKey(date), {count, colorIndex});
}
const {on} = props.locale.heatMapLocale;
const {noDataText, tooltipUnit} = props.locale;
const currentLocale = getCurrentLocale();
const cursorStart = shiftDate(start, -padStart);
const cursor = new Date(cursorStart.getFullYear(), cursorStart.getMonth(), cursorStart.getDate());
const calendar: HeatmapCell[][] = [];
for (let w = 0; w < weekCount; w++) {
const week: HeatmapCell[] = [];
for (let d = 0; d < daysInWeek; d++) {
const hit = activities.get(dateKey(cursor));
const dateStr = cursor.toLocaleDateString(currentLocale, {year: 'numeric', month: 'short', day: 'numeric'});
const head = hit ? `${hit.count} ${tooltipUnit}` : noDataText;
week.push({
date: new Date(cursor),
colorIndex: hit ? hit.colorIndex : 0,
ariaLabel: `${head} ${on} ${dateStr}`,
tooltip: `<b>${head}</b> ${on} ${dateStr}`,
});
cursor.setDate(cursor.getDate() + 1);
}
calendar.push(week);
}
const monthLabels: MonthLabel[] = [];
for (let w = 1; w < calendar.length; w++) {
const prev = calendar[w - 1][0].date;
const curr = calendar[w][0].date;
if (prev.getMonth() !== curr.getMonth()) {
monthLabels.push({monthIdx: curr.getMonth(), weekIdx: w});
}
}
const width = gridLeft + (cellSize * weekCount) + squareBorder;
const height = gridTop + (cellSize * daysInWeek);
return {calendar, monthLabels, width, height};
});
function handleDayClick(e: Event & {date: Date}) {
// Reset filter if same date is clicked
const legendViewBox = `${cellSize} 0 ${squareSize * (colorRange.length + 2)} ${squareSize}`;
const cellInstances = new Map<Element, Instance>();
let singleton: CreateSingletonInstance | null = null;
onMounted(() => {
singleton = createSingleton([], {
overrides: [],
moveTransition: 'transform 0.1s ease-out',
allowHTML: true,
theme: 'tooltip',
role: 'tooltip',
placement: 'top',
});
});
onBeforeUnmount(() => {
singleton?.destroy();
for (const instance of cellInstances.values()) instance.destroy();
cellInstances.clear();
});
function lazyInitTooltip(e: MouseEvent) {
const el = e.target as Element;
if (!singleton || cellInstances.has(el) || !el.classList.contains('heatmap-day')) return;
cellInstances.set(el, tippy(el, {content: el.getAttribute('data-tooltip')!}));
singleton.setInstances([...cellInstances.values()]);
}
function handleDayClick(date: Date) {
const params = new URLSearchParams(document.location.search);
const queryDate = params.get('date');
// Timezone has to be stripped because toISOString() converts to UTC
const clickedDate = new Date(e.date.getTime() - (e.date.getTimezoneOffset() * 60000)).toISOString().substring(0, 10);
const clickedDate = new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().substring(0, 10);
if (queryDate && queryDate === clickedDate) {
params.delete('date');
@@ -53,16 +148,63 @@ function handleDayClick(e: Event & {date: Date}) {
}
</script>
<template>
<calendar-heatmap
:locale="locale.heatMapLocale"
:no-data-text="locale.noDataText"
:tooltip-unit="locale.tooltipUnit"
:end-date="endDate"
:values="values"
:range-color="colorRange"
@day-click="handleDayClick($event)"
:tippy-props="{theme: 'tooltip'}"
>
<template #vch__legend-left>{{ locale.textTotalContributions }}</template>
</calendar-heatmap>
<div>
<svg class="heatmap-svg" :viewBox="`0 0 ${grid.width} ${grid.height}`">
<g class="heatmap-month-labels" :transform="`translate(${gridLeft}, 0)`">
<text
v-for="m in grid.monthLabels"
:key="m.weekIdx"
class="heatmap-month-label"
:x="cellSize * m.weekIdx"
:y="cellSize - squareBorder"
>
{{ locale.heatMapLocale.months[m.monthIdx] }}
</text>
</g>
<g class="heatmap-day-labels" :transform="`translate(0, ${gridTop})`">
<text class="heatmap-day-label" :x="0" :y="20">{{ locale.heatMapLocale.days[1] }}</text>
<text class="heatmap-day-label" :x="0" :y="44">{{ locale.heatMapLocale.days[3] }}</text>
<text class="heatmap-day-label" :x="0" :y="69">{{ locale.heatMapLocale.days[5] }}</text>
</g>
<g class="heatmap-grid" :transform="`translate(${gridLeft}, ${gridTop})`" @mouseover="lazyInitTooltip">
<g
v-for="(week, w) in grid.calendar"
:key="w"
class="heatmap-week"
:transform="`translate(${w * cellSize}, 0)`"
>
<template v-for="(day, d) in week" :key="d">
<rect
v-if="day.date < now"
class="heatmap-day"
:transform="`translate(0, ${d * cellSize})`"
:width="squareSize"
:height="squareSize"
:style="{fill: colorRange[day.colorIndex]}"
:aria-label="day.ariaLabel"
:data-tooltip="day.tooltip"
@click="handleDayClick(day.date)"
/>
</template>
</g>
</g>
</svg>
<div class="heatmap-footer">
<div>{{ locale.textTotalContributions }}</div>
<div class="heatmap-legend">
<div>{{ locale.heatMapLocale.less }}</div>
<svg class="heatmap-legend-svg" :viewBox="legendViewBox" :height="squareSize">
<rect
v-for="(color, i) in colorRange"
:key="i"
:width="squareSize"
:height="squareSize"
:x="(i + 1) * cellSize"
:style="{fill: color}"
/>
</svg>
<div>{{ locale.heatMapLocale.more }}</div>
</div>
</div>
</div>
</template>
@@ -1,63 +1,40 @@
<script lang="ts" setup>
import {SvgIcon} from '../svg.ts';
import {GET} from '../modules/fetch.ts';
import {getIssueColorClass, getIssueIcon} from '../features/issue.ts';
import {computed, onMounted, shallowRef} from 'vue';
import {computed} from 'vue';
import type {Issue} from '../types.ts';
const props = defineProps<{
repoLink: string,
loadIssueInfoUrl: string,
issue?: Issue | null,
renderedLabels?: string,
errorMessage?: string,
}>();
const loading = shallowRef(false);
const issue = shallowRef<Issue | null>(null);
const renderedLabels = shallowRef('');
const errorMessage = shallowRef('');
const createdAt = computed(() => {
if (!issue?.value) return '';
return new Date(issue.value.created_at).toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'});
if (!props.issue) return '';
return new Date(props.issue.created_at).toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'});
});
const body = computed(() => {
if (!issue?.value) return '';
const body = issue.value.body.replace(/\n+/g, ' ');
if (!props.issue) return '';
const body = props.issue.body.replace(/\n+/g, ' ');
return body.length > 85 ? `${body.substring(0, 85)}…` : body;
});
onMounted(async () => {
loading.value = true;
errorMessage.value = '';
try {
const resp = await GET(props.loadIssueInfoUrl);
if (!resp.ok) {
errorMessage.value = resp.status ? resp.statusText : 'Unknown network error';
return;
}
const respJson = await resp.json();
issue.value = respJson.convertedIssue;
renderedLabels.value = respJson.renderedLabels;
} finally {
loading.value = false;
}
});
</script>
<template>
<div class="tw-p-4">
<div v-if="loading" class="tw-h-12 tw-w-12 is-loading"/>
<div v-else-if="issue" class="tw-flex tw-flex-col tw-gap-2">
<div v-if="issue" class="tw-flex tw-flex-col tw-gap-2">
<div class="tw-text-12">
<a :href="repoLink" class="muted">{{ issue.repository.full_name }}</a>
<a :href="issue.repository.html_url" class="muted">{{ issue.repository.full_name }}</a>
on {{ createdAt }}
</div>
<div class="flex-text-block">
<svg-icon :name="getIssueIcon(issue)" :class="getIssueColorClass(issue)"/>
<span class="issue-title tw-font-semibold tw-break-anywhere">
<a :href="issue.html_url" class="issue-title tw-font-semibold tw-break-anywhere muted">
{{ issue.title }}
<span class="index">#{{ issue.number }}</span>
</span>
</a>
</div>
<div v-if="body">{{ body }}</div>
<!-- eslint-disable-next-line vue/no-v-html -->
@@ -3,9 +3,13 @@ import {computed, onMounted, onUnmounted, shallowRef, watch} from 'vue';
import {SvgIcon} from '../svg.ts';
import {toggleElem} from '../utils/dom.ts';
const {pageData} = window.config;
const props = defineProps<{
mergeFormProps: any, // TODO: this is a huge object, need to be refactored in the future
}>();
const mergeForm = pageData.pullRequestMergeForm!;
const mergeStyleManuallyMerged = 'manually-merged';
const mergeForm = props.mergeFormProps;
const mergeTitleFieldValue = shallowRef('');
const mergeMessageFieldValue = shallowRef('');
@@ -27,10 +31,18 @@ const showMergeStyleMenu = shallowRef(false);
const showActionForm = shallowRef(false);
const mergeButtonStyleClass = computed(() => {
if (mergeStyle.value === mergeStyleManuallyMerged) return 'red';
if (mergeForm.allOverridableChecksOk) return 'primary';
return autoMergeWhenSucceed.value ? 'primary' : 'red';
});
const mergeSelectStyleClass = computed(() => {
if (mergeForm.emptyCommit) return '';
if (mergeStyle.value === mergeStyleManuallyMerged) return 'red';
if (!mergeForm.allOverridableChecksOk) return 'red';
return 'primary';
});
const forceMerge = computed(() => {
return mergeForm.canMergeNow && !mergeForm.allOverridableChecksOk;
});
@@ -113,30 +125,32 @@ function clearMergeMessage() {
</div>
</template>
<div class="field" v-if="mergeStyle === 'manually-merged'">
<div class="field" v-if="mergeStyle === mergeStyleManuallyMerged">
<input type="text" name="merge_commit_id" :placeholder="mergeForm.textMergeCommitId">
</div>
<button class="ui button" :class="mergeButtonStyleClass" type="submit" name="do" :value="mergeStyle">
{{ mergeStyleDetail.textDoMerge }}
<template v-if="autoMergeWhenSucceed">
{{ mergeForm.textAutoMergeButtonWhenSucceed }}
</template>
</button>
<div class="flex-text-block tw-gap-3">
<button class="ui button" :class="mergeButtonStyleClass" type="submit" name="do" :value="mergeStyle">
{{ mergeStyleDetail.textDoMerge }}
<template v-if="autoMergeWhenSucceed">
{{ mergeForm.textAutoMergeButtonWhenSucceed }}
</template>
</button>
<button class="ui button merge-cancel" @click="toggleActionForm(false)">
{{ mergeForm.textCancel }}
</button>
<button class="ui button merge-cancel" type="button" @click="toggleActionForm(false)">
{{ mergeForm.textCancel }}
</button>
<div class="ui checkbox tw-ml-1" v-if="mergeForm.isPullBranchDeletable">
<input name="delete_branch_after_merge" type="checkbox" v-model="deleteBranchAfterMerge" id="delete-branch-after-merge">
<label for="delete-branch-after-merge">{{ mergeForm.textDeleteBranch }}</label>
<div class="ui checkbox" v-if="mergeForm.isPullBranchDeletable">
<input name="delete_branch_after_merge" type="checkbox" v-model="deleteBranchAfterMerge" id="delete-branch-after-merge">
<label for="delete-branch-after-merge">{{ mergeForm.textDeleteBranch }}</label>
</div>
</div>
</form>
<div v-if="!showActionForm" class="tw-flex">
<!-- the merge button -->
<div class="ui buttons merge-button" :class="[mergeForm.emptyCommit ? '' : mergeForm.allOverridableChecksOk ? 'primary' : 'red']" @click="toggleActionForm(true)">
<div class="ui buttons merge-button" :class="mergeSelectStyleClass" @click="toggleActionForm(true)">
<button class="ui button">
<svg-icon name="octicon-git-merge"/>
<span class="button-text">
@@ -1,26 +1,110 @@
<script setup lang="ts">
import {SvgIcon} from '../svg.ts';
import ActionRunStatus from './ActionRunStatus.vue';
import {toRefs} from 'vue';
import ActionStatusIcon from './ActionStatusIcon.vue';
import {computed, onBeforeUnmount, ref, toRefs, watch} from 'vue';
import {resetActionFavicon, syncActionRunFavicon} from '../modules/favicon-status.ts';
import {POST, DELETE} from '../modules/fetch.ts';
import ActionRunSummaryView from './ActionRunSummaryView.vue';
import ActionRunJobView from './ActionRunJobView.vue';
import {createActionRunViewStore} from "./ActionRunView.ts";
import type {ActionsJob, ActionsRunAttempt} from '../modules/gitea-actions.ts';
import {buildJobsByParentJobID, createActionRunViewStore} from './ActionRunView.ts';
import {buildArtifactTooltipHtml} from './ActionRunArtifacts.ts';
defineOptions({
name: 'RepoActionView',
});
const props = defineProps<{
runId: number;
jobId: number;
actionsUrl: string;
actionsViewUrl: string;
locale: Record<string, any>;
}>();
const locale = props.locale;
const store = createActionRunViewStore(props.actionsUrl, props.runId);
const {currentRun: run , runArtifacts: artifacts} = toRefs(store.viewData);
const store = createActionRunViewStore(props.actionsViewUrl);
const {currentRun: run, runArtifacts: artifacts} = toRefs(store.viewData);
const visibleJobSummaries = computed(() => {
const summaries = run.value.jobSummaries || [];
if (!props.jobId) return summaries;
return summaries.filter((summary) => summary.jobId === props.jobId);
});
type JobListItem = {
job: ActionsJob;
depth: number;
};
// Caller jobs default to collapsed. Membership in this set means "user has manually expanded this caller"
const expandedJobIDs = ref(new Set<number>());
function toggleExpandedJob(jobID: number) {
const next = new Set(expandedJobIDs.value);
if (next.has(jobID)) {
next.delete(jobID);
} else {
next.add(jobID);
}
expandedJobIDs.value = next;
}
// When a child job is currently selected, force-expand the chain of caller ancestors
const forcedExpandedJobIDs = computed(() => {
const expanded = new Set<number>();
if (!props.jobId) return expanded;
const jobsByID = new Map((run.value.jobs || []).map((job) => [job.id, job]));
let cur = jobsByID.get(props.jobId);
while (cur?.parentJobID) {
expanded.add(cur.parentJobID);
cur = jobsByID.get(cur.parentJobID);
}
return expanded;
});
function isJobCollapsed(jobID: number) {
return !expandedJobIDs.value.has(jobID) && !forcedExpandedJobIDs.value.has(jobID);
}
const visibleJobListItems = computed<JobListItem[]>(() => {
const jobs = [...(run.value.jobs || [])].sort((a, b) => a.id - b.id);
const childrenByParent = buildJobsByParentJobID(jobs);
const result: JobListItem[] = [];
const stack: Array<{job: ActionsJob; depth: number}> = [];
const top = childrenByParent.get(0) || [];
for (let i = top.length - 1; i >= 0; i--) stack.push({job: top[i], depth: 0});
while (stack.length > 0) {
const {job, depth} = stack.pop()!;
const children = childrenByParent.get(job.id) || [];
result.push({job, depth});
if (children.length > 0 && isJobCollapsed(job.id)) continue;
for (let i = children.length - 1; i >= 0; i--) stack.push({job: children[i], depth: depth + 1});
}
return result;
});
function formatAttemptTitle(attempt: ActionsRunAttempt) {
return attempt.latest ? `${locale.latestAttempt} #${attempt.attempt}` : `${locale.attempt} #${attempt.attempt}`;
}
function formatCurrentAttemptTitle(attempt: ActionsRunAttempt) {
return attempt.latest ? `${locale.latest} #${attempt.attempt}` : formatAttemptTitle(attempt);
}
const backLink = computed(() => {
if (run.value.pullRequest) {
return {href: run.value.pullRequest.link, prefix: locale.backToPullRequest, name: run.value.pullRequest.index};
}
if (run.value.workflowLink) {
return {href: run.value.workflowLink, prefix: locale.backToWorkflow, name: run.value.workflowID.replace(/\.(yml|yaml)$/i, '')};
}
return null;
});
function buildArtifactLink(name: string) {
const searchString = run.value.runAttempt > 0 ? `?attempt=${run.value.runAttempt}` : '';
return `${run.value.link}/artifacts/${encodeURIComponent(name)}${searchString}`;
}
function cancelRun() {
POST(`${run.value.link}/cancel`);
@@ -32,28 +116,41 @@ function approveRun() {
async function deleteArtifact(name: string) {
if (!window.confirm(locale.confirmDeleteArtifact.replace('%s', name))) return;
await DELETE(`${run.value.link}/artifacts/${encodeURIComponent(name)}`);
await DELETE(buildArtifactLink(name));
await store.forceReloadCurrentRun();
}
watch(() => run.value.status, (status) => {
syncActionRunFavicon(status);
});
onBeforeUnmount(() => {
resetActionFavicon();
});
</script>
<template>
<!-- make the view container full width to make users easier to read logs -->
<div class="ui fluid container">
<div class="action-view-header">
<a v-if="backLink" class="action-view-back silenced" :href="backLink.href">
<SvgIcon name="octicon-arrow-left" :size="14"/>
<span>{{ backLink.prefix }} <span class="action-view-back-name">{{ backLink.name }}</span></span>
</a>
<div class="action-info-summary">
<div class="action-info-summary-title">
<ActionRunStatus :locale-status="locale.status[run.status]" :status="run.status" :size="20"/>
<ActionStatusIcon :locale-status="locale.status[run.status]" :status="run.status" :size="22" icon-variant="circle-fill"/>
<!-- eslint-disable-next-line vue/no-v-html -->
<h2 class="action-info-summary-title-text" v-html="run.titleHTML"/>
<span class="action-info-summary-title-index">#{{ run.index }}</span>
</div>
<div class="flex-text-block tw-shrink-0 tw-flex-wrap">
<button class="ui basic small compact button primary" @click="approveRun()" v-if="run.canApprove">
{{ locale.approve }}
</button>
<button class="ui basic small compact button red" @click="cancelRun()" v-else-if="run.canCancel">
<button class="ui small compact button tw-text-red" @click="cancelRun()" v-else-if="run.canCancel">
{{ locale.cancel }}
</button>
<template v-else-if="run.canRerun">
<template v-if="run.canRerun">
<div v-if="run.canRerunFailed" class="ui small compact buttons">
<button class="ui basic small compact button link-action" :data-url="`${run.link}/rerun-failed`">
{{ locale.rerun_failed }}
@@ -71,99 +168,173 @@ async function deleteArtifact(name: string) {
{{ locale.rerun_all }}
</button>
</template>
<div v-if="run.attempts.length > 1" class="ui dropdown basic small compact button">
<div class="flex-text-inline">
<SvgIcon name="octicon-history" :size="14"/>
<span>{{ formatCurrentAttemptTitle(run.attempts.find((attempt) => attempt.current)!) }}</span>
</div>
<SvgIcon name="octicon-triangle-down" :size="14" class="dropdown icon"/>
<div class="menu">
<a
v-for="attempt in run.attempts"
:key="attempt.attempt"
class="item tw-flex tw-flex-col tw-gap-2"
:class="attempt.current ? 'selected' : ''"
:href="attempt.link"
>
<div class="flex-text-block">
<SvgIcon name="octicon-check" :size="14" :class="{'tw-invisible': !Boolean(attempt.current)}"/>
<strong class="tw-text-sm gt-ellipsis">{{ formatAttemptTitle(attempt) }}</strong>
</div>
<div class="flex-text-block tw-pl-[20px]">
<span class="flex-text-inline tw-flex-shrink-0">
<ActionStatusIcon :locale-status="locale.status[attempt.status]" :status="attempt.status" :size="14" class="flex-text-block" icon-variant="circle-fill"/>
<span>{{ locale.status[attempt.status] }}</span>
</span>
<span>•</span>
<relative-time :datetime="attempt.triggeredAt" prefix=""/>
<span>•</span>
<span class="gt-ellipsis">{{ attempt.triggerUserName }}</span>
</div>
</a>
</div>
</div>
</div>
</div>
<div class="action-commit-summary">
<span><a class="muted" :href="run.workflowLink"><b>{{ run.workflowID }}</b></a>:</span>
<template v-if="run.isSchedule">
{{ locale.scheduled }}
</template>
<template v-else>
{{ locale.commit }}
<a class="muted" :href="run.commit.link">{{ run.commit.shortSHA }}</a>
{{ locale.pushedBy }}
<a class="muted" :href="run.commit.pusher.link">{{ run.commit.pusher.displayName }}</a>
</template>
<span class="ui label tw-max-w-full" v-if="run.commit.shortSHA">
<span v-if="run.commit.branch.isDeleted" class="gt-ellipsis tw-line-through" :data-tooltip-content="run.commit.branch.name">{{ run.commit.branch.name }}</span>
<a v-else class="gt-ellipsis" :href="run.commit.branch.link" :data-tooltip-content="run.commit.branch.name">{{ run.commit.branch.name }}</a>
</span>
</div>
</div>
<div class="action-view-body">
<div class="action-view-left">
<!-- summary -->
<a class="job-brief-item silenced" :href="run.link" :class="!props.jobId ? 'selected' : ''">
<SvgIcon name="octicon-list-unordered"/>
<span class="gt-ellipsis">{{ locale.summary }}</span>
</a>
<div class="flex-items-block action-view-sidebar-list">
<a class="item silenced" :href="run.viewLink" :class="!props.jobId ? 'selected' : ''">
<SvgIcon name="octicon-home"/>
<span class="gt-ellipsis">{{ locale.summary }}</span>
</a>
</div>
<!-- jobs list -->
<div class="ui divider"/>
<div class="left-list-header">{{ locale.allJobs }}</div>
<!-- unlike other lists, the items have paddings already -->
<ul class="ui relaxed list flex-items-block tw-p-0">
<li class="item job-brief-item" v-for="job in run.jobs" :key="job.id" :class="props.jobId === job.id ? 'selected' : ''">
<a class="tw-contents silenced" :href="run.link+'/jobs/'+job.id">
<ActionRunStatus :locale-status="locale.status[job.status]" :status="job.status"/>
<span class="tw-flex-1 gt-ellipsis">{{ job.name }}</span>
<SvgIcon name="octicon-sync" role="button" :data-tooltip-content="locale.rerun" class="tw-cursor-pointer link-action interact-fg" :data-url="`${run.link}/jobs/${job.id}/rerun`" v-if="job.canRerun"/>
<span>{{ job.duration }}</span>
<div class="flex-items-block action-view-sidebar-list">
<template
v-for="item in visibleJobListItems"
:key="item.job.id"
>
<!-- Callers have no log page of their own; the whole row toggles expansion
(matches GitHub Actions, where caller rows are not navigation targets). -->
<button
v-if="item.job.isReusableCaller"
type="button"
class="item caller-row-toggle"
:class="{'selected': props.jobId === item.job.id}"
:style="{paddingLeft: `${10 + item.depth * 16}px`}"
@click="toggleExpandedJob(item.job.id)"
:title="isJobCollapsed(item.job.id) ? locale.expandCallerJobs : locale.collapseCallerJobs"
:aria-label="isJobCollapsed(item.job.id) ? locale.expandCallerJobs : locale.collapseCallerJobs"
:aria-expanded="!isJobCollapsed(item.job.id)"
>
<ActionStatusIcon :locale-status="locale.status[item.job.status]" :status="item.job.status" icon-variant="circle-fill"/>
<span class="tw-min-w-0 gt-ellipsis">{{ item.job.name }}</span>
<span class="job-duration">{{ item.job.duration }}</span>
<SvgIcon name="octicon-chevron-down" :size="14" class="job-brief-toggle-icon" :class="{'collapsed': isJobCollapsed(item.job.id)}"/>
</button>
<a
v-else
class="item silenced"
:class="{'selected': props.jobId === item.job.id}"
:style="{paddingLeft: `${10 + item.depth * 16}px`}"
:href="item.job.link"
>
<ActionStatusIcon :locale-status="locale.status[item.job.status]" :status="item.job.status" icon-variant="circle-fill"/>
<span class="tw-min-w-0 gt-ellipsis">{{ item.job.name }}</span>
<SvgIcon name="octicon-sync" role="button" :data-tooltip-content="locale.rerun" class="job-rerun-button tw-cursor-pointer link-action interact-fg" :data-url="`${run.link}/jobs/${item.job.id}/rerun`" v-if="item.job.canRerun"/>
<span class="job-duration">{{ item.job.duration }}</span>
</a>
</li>
</ul>
</template>
</div>
<!-- artifacts list -->
<template v-if="artifacts.length > 0">
<div class="ui divider"/>
<div class="left-list-header">{{ locale.artifactsTitle }} ({{ artifacts.length }})</div>
<ul class="ui relaxed list flex-items-block">
<li class="item" v-for="artifact in artifacts" :key="artifact.name">
<div class="flex-items-block action-view-sidebar-list">
<div class="item" v-for="artifact in artifacts" :key="artifact.name">
<template v-if="artifact.status !== 'expired'">
<a class="tw-flex-1 flex-text-block" target="_blank" :href="run.link+'/artifacts/'+artifact.name">
<SvgIcon name="octicon-file" class="tw-text-text"/>
<a
class="tw-flex-1 tw-min-w-0 flex-text-block silenced" target="_blank"
:href="buildArtifactLink(artifact.name)"
:data-tooltip-content="buildArtifactTooltipHtml(artifact, locale.artifactExpiresAt)"
data-tooltip-render="html"
data-tooltip-placement="top-end"
>
<SvgIcon name="octicon-file" class="tw-text-text-light"/>
<span class="tw-flex-1 gt-ellipsis">{{ artifact.name }}</span>
</a>
<a v-if="run.canDeleteArtifact" @click="deleteArtifact(artifact.name)">
<SvgIcon name="octicon-trash" class="tw-text-text"/>
<a v-if="run.canDeleteArtifact" class="silenced" @click="deleteArtifact(artifact.name)">
<SvgIcon name="octicon-trash"/>
</a>
</template>
<span v-else class="flex-text-block tw-flex-1 tw-text-grey-light">
<SvgIcon name="octicon-file"/>
<span
v-else class="flex-text-block tw-flex-1 tw-min-w-0 tw-text-text-light-2"
:data-tooltip-content="buildArtifactTooltipHtml(artifact, locale.artifactExpiredAt)"
data-tooltip-render="html"
data-tooltip-placement="top-end"
>
<SvgIcon name="octicon-file-removed"/>
<span class="tw-flex-1 gt-ellipsis">{{ artifact.name }}</span>
<span class="ui label tw-text-grey-light tw-flex-shrink-0">{{ locale.artifactExpired }}</span>
<span class="ui label tw-flex-shrink-0">{{ locale.artifactExpired }}</span>
</span>
</li>
</ul>
</div>
</div>
</template>
<!-- run details -->
<div class="ui divider"/>
<div class="left-list-header">{{ locale.runDetails }}</div>
<ul class="ui relaxed list">
<li class="item">
<a class="flex-text-block" :href="`${run.link}/workflow`">
<div class="flex-items-block action-view-sidebar-list">
<div class="item">
<a v-if="run.canViewWorkflowFile" class="flex-text-block silenced" :href="`${run.link}/workflow`">
<SvgIcon name="octicon-file-code" class="tw-text-text"/>
<span class="gt-ellipsis">{{ locale.workflowFile }}</span>
</a>
</li>
</ul>
<span v-else class="flex-text-block silenced" :data-tooltip-content="locale.workflowFileNoPermission">
<SvgIcon name="octicon-lock" class="tw-text-text"/>
<span class="gt-ellipsis">{{ locale.workflowFileNoPermission }}</span>
</span>
</div>
</div>
</div>
<div class="action-view-right">
<ActionRunSummaryView
v-if="!props.jobId"
:store="store"
:locale="locale"
/>
<ActionRunJobView
v-else
:store="store"
:locale="locale"
:run-id="props.runId"
:job-id="props.jobId"
:actions-url="props.actionsUrl"
/>
<div class="action-view-right-panel">
<ActionRunSummaryView
v-if="!props.jobId"
:store="store"
:locale="locale"
:artifact-count="artifacts.length"
/>
<ActionRunJobView
v-else
:store="store"
:locale="locale"
:actions-view-url="props.actionsViewUrl"
:job-id="props.jobId"
/>
</div>
<div v-if="visibleJobSummaries.length" class="action-view-right-panel job-summary-section">
<div class="job-summary-section-header">
{{ locale.jobSummaries }}
</div>
<div class="job-summary-list">
<div v-for="s in visibleJobSummaries" :key="s.jobId" class="job-summary-item">
<div class="job-summary-header">
<strong class="gt-ellipsis">{{ s.jobName || `Job ${s.jobId}` }}</strong>
</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="markup job-summary-body" v-html="s.summaryHTML"/>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -180,9 +351,30 @@ async function deleteArtifact(name: string) {
/* action view header */
.action-view-header {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 8px;
}
.action-view-back {
display: inline-flex;
align-items: center;
align-self: flex-start;
gap: 4px;
font-size: 13px;
color: var(--color-text-light-1);
}
.action-view-back:hover {
color: var(--color-primary);
}
.action-view-back-name {
font-weight: var(--font-weight-bold);
color: var(--color-text);
}
.action-info-summary {
display: flex;
flex-wrap: wrap;
@@ -200,30 +392,20 @@ async function deleteArtifact(name: string) {
.action-info-summary-title-text {
font-size: 20px;
margin: 0;
flex: 1;
overflow-wrap: anywhere;
}
.action-info-summary-title-index {
font-size: 20px;
color: var(--color-text-light-2);
flex: 1;
}
.action-info-summary .ui.button {
margin: 0;
white-space: nowrap;
}
.action-commit-summary {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 5px;
margin-left: 28px;
}
@media (max-width: 767.98px) {
.action-commit-summary {
margin-left: 0;
margin-top: 8px;
}
}
/* ================ */
/* action view left */
@@ -251,32 +433,77 @@ async function deleteArtifact(name: string) {
.left-list-header {
font-size: 13px;
font-weight: var(--font-weight-semibold);
color: var(--color-text-light-2);
}
.action-view-left .ui.relaxed.list {
.action-view-sidebar-list {
margin: var(--gap-block) 0;
padding-left: 10px;
}
.job-brief-item {
.action-view-sidebar-list:first-child {
margin-top: 0;
}
.action-view-sidebar-list > .item {
padding: 6px 10px;
border-radius: var(--border-radius);
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: var(--gap-block);
}
.job-brief-item:hover {
.action-view-sidebar-list > .item:hover {
background-color: var(--color-hover);
}
.job-brief-item.selected {
.action-view-sidebar-list > .item.selected {
font-weight: var(--font-weight-bold);
background-color: var(--color-active);
}
.caller-row-toggle {
width: 100%;
border: none;
background: transparent;
color: inherit;
line-height: inherit; /* buttons don't inherit line-height; match the <a> rows' row height */
cursor: pointer;
text-align: inherit;
}
.job-brief-toggle-icon {
flex-shrink: 0;
transition: transform 0.15s ease;
/* sit between name and duration; duration uses order:2 with margin-left:auto to float right */
order: 1;
}
.job-brief-toggle-icon:not(.collapsed) {
transform: rotate(180deg);
}
/* push rerun/duration to the right edge; only one is visible at a time (hover swap),
the visible one absorbs the free space via auto-margin */
.action-view-sidebar-list > .item .job-rerun-button,
.action-view-sidebar-list > .item .job-duration {
order: 2;
margin-left: auto;
}
/* the re-run button replaces the duration on hover or job-link focus */
.action-view-sidebar-list > .item .job-rerun-button {
display: none;
}
.action-view-sidebar-list > .item:hover .job-rerun-button,
.action-view-sidebar-list > .item:focus .job-rerun-button {
display: inline-flex;
}
/* only swap out the duration when a re-run button exists to take its place */
.action-view-sidebar-list > .item:hover .job-rerun-button ~ .job-duration,
.action-view-sidebar-list > .item:focus .job-rerun-button ~ .job-duration {
display: none;
}
/* ================ */
/* action view right */
@@ -287,25 +514,33 @@ async function deleteArtifact(name: string) {
width: 70%;
display: flex;
flex-direction: column;
gap: 12px;
}
.action-view-right-panel {
flex: 1; /* fill the right column so the summary graph stretches even without a job-summary section */
border: 1px solid var(--color-console-border);
border-radius: var(--border-radius);
background: var(--color-console-bg);
display: flex;
flex-direction: column;
min-height: 0;
}
/* begin fomantic button overrides */
.action-view-right .ui.button,
.action-view-right .ui.button:focus {
.action-view-right-panel .ui.button,
.action-view-right-panel .ui.button:focus {
background: transparent;
color: var(--color-console-fg-subtle);
}
.action-view-right .ui.button:hover {
.action-view-right-panel .ui.button:hover {
background: var(--color-console-hover-bg);
color: var(--color-console-fg);
}
.action-view-right .ui.button:active {
.action-view-right-panel .ui.button:active {
background: var(--color-console-active-bg);
color: var(--color-console-fg);
}
@@ -323,4 +558,40 @@ async function deleteArtifact(name: string) {
max-width: none;
}
}
.job-summary-section {
flex: 0 0 auto; /* size to its content; let the summary panel keep the remaining height */
overflow: hidden;
}
.job-summary-section-header {
padding: 12px;
border-bottom: 1px solid var(--color-console-border);
background: var(--color-console-bg);
color: var(--color-console-fg);
font-weight: var(--font-weight-semibold);
}
.job-summary-list {
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.job-summary-item {
padding: 12px;
border-radius: var(--border-radius);
background: var(--color-console-hover-bg);
border: 1px solid var(--color-console-border);
}
.job-summary-header {
color: var(--color-console-fg);
margin-bottom: 8px;
}
.job-summary-body {
color: var(--color-console-fg);
}
</style>
@@ -21,6 +21,7 @@ import {
type DayDataObject,
} from '../utils/time.ts';
import {chartJsColors} from '../utils/color.ts';
import {errorMessage} from '../modules/errors.ts';
import {sleep} from '../utils.ts';
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
import {onMounted, shallowRef} from 'vue';
@@ -78,7 +79,7 @@ async function fetchGraphData() {
errorText.value = response.statusText;
}
} catch (err) {
errorText.value = err.message;
errorText.value = errorMessage(err);
} finally {
isLoading.value = false;
}
@@ -144,7 +145,7 @@ const options: ChartOptions<'line'> = {
<template>
<div>
<div class="ui header tw-flex tw-items-center tw-justify-between">
<div class="ui header">
{{ isLoading ? locale.loadingTitle : errorText ? locale.loadingTitleFailed: `Code frequency over the history of ${repoLink.slice(1)}` }}
</div>
<div class="tw-flex ui segment main-graph">
@@ -24,6 +24,7 @@ import {
fillEmptyStartDaysWithZeroes,
} from '../utils/time.ts';
import {chartJsColors} from '../utils/color.ts';
import {errorMessage} from '../modules/errors.ts';
import {sleep} from '../utils.ts';
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
import {fomanticQuery} from '../modules/fomantic/base.ts';
@@ -166,7 +167,7 @@ export default defineComponent({
this.errorText = response.statusText;
}
} catch (err) {
this.errorText = err.message;
this.errorText = errorMessage(err);
} finally {
this.isLoading = false;
}
@@ -269,7 +270,7 @@ export default defineComponent({
plugins: {
title: {
display: type === 'main',
text: 'drag: zoom, shift+drag: pan, double click: reset zoom',
text: this.locale.chartZoomHint,
position: 'top',
align: 'center',
},
@@ -339,7 +340,7 @@ export default defineComponent({
</script>
<template>
<div>
<div class="ui header tw-flex tw-items-center tw-justify-between">
<div class="ui header flex-left-right">
<div>
<relative-time
v-if="xAxisMin && xAxisMin > 0"
@@ -20,6 +20,7 @@ import {
type DayDataObject,
} from '../utils/time.ts';
import {chartJsColors} from '../utils/color.ts';
import {errorMessage} from '../modules/errors.ts';
import {sleep} from '../utils.ts';
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
import {onMounted, ref, shallowRef} from 'vue';
@@ -74,7 +75,7 @@ async function fetchGraphData() {
errorText.value = response.statusText;
}
} catch (err) {
errorText.value = err.message;
errorText.value = errorMessage(err);
} finally {
isLoading.value = false;
}
@@ -122,7 +123,7 @@ const options: ChartOptions<'bar'> = {
<template>
<div>
<div class="ui header tw-flex tw-items-center tw-justify-between">
<div class="ui header">
{{ isLoading ? locale.loadingTitle : errorText ? locale.loadingTitleFailed: "Number of commits in the past year" }}
</div>
<div class="tw-flex ui segment main-graph">
@@ -32,6 +32,8 @@ const onItemClick = (e: MouseEvent) => {
// - the user didn't press any special key like "Ctrl+Click" (which may have custom browser behavior)
// - the editor/commit form isn't dirty (a full page reload shows a confirmation dialog if the form contains unsaved changes)
if (!isPlainClick(e) || shouldTriggerAreYouSure()) return;
// submodules (commit entry mode) point to external repos, let the browser handle navigation normally
if (props.item.entryMode === 'commit') return;
e.preventDefault();
if (props.item.entryMode === 'tree') doLoadChildren();
store.navigateTreeView(props.item.fullPath);
@@ -57,9 +57,7 @@ export function createViewFileTreeStore(props: {repoLink: string, treePath: stri
await store.loadViewContent(url);
},
buildTreePathWebUrl(treePath: string) {
return `${props.repoLink}/src/${props.currentRefNameSubURL}/${pathEscapeSegments(treePath)}`;
},
buildTreePathWebUrl: (treePath: string) => `${props.repoLink}/src/${props.currentRefNameSubURL}/${pathEscapeSegments(treePath)}`,
});
return store;
}
@@ -0,0 +1,210 @@
import {computeGraphHighlightState, computeJobLevels, createWorkflowGraphModel} from './WorkflowGraph.utils.ts';
import type {ActionsJob} from '../modules/gitea-actions.ts';
const mockJobs: ActionsJob[] = [
{id: 1, link: '', jobId: 'job-100', name: 'job-100', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s'},
{id: 2, link: '', jobId: 'job-101', name: 'job-101', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['job-100']},
{id: 3, link: '', jobId: 'job-102', name: 'job-102', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['job-101']},
{id: 4, link: '', jobId: 'job-103', name: 'job-103', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100']},
{id: 5, link: '', jobId: 'prep-jdk', name: 'prep-jdk', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s'},
{id: 6, link: '', jobId: 'code-analysis', name: 'code-analysis', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s'},
{id: 7, link: '', jobId: 'matrix-e2e', name: 'matrix-e2e (1, chromium)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
{id: 8, link: '', jobId: 'matrix-e2e', name: 'matrix-e2e (1, firefox)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
{id: 9, link: '', jobId: 'matrix-e2e', name: 'matrix-e2e (2, chromium)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
{id: 10, link: '', jobId: 'matrix-e2e', name: 'matrix-e2e (3, chromium)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
{id: 11, link: '', jobId: 'matrix-e2e', name: 'matrix-e2e (3, firefox)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
{id: 12, link: '', jobId: 'matrix-e2e', name: 'matrix-e2e (99, webkit)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
{id: 13, link: '', jobId: 'unit-test', name: 'unit-test', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['prep-jdk', 'code-analysis']},
{id: 14, link: '', jobId: 'arch-test', name: 'arch-test', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['prep-jdk', 'code-analysis']},
{id: 15, link: '', jobId: 'integration-test', name: 'integration-test', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['prep-jdk', 'code-analysis']},
{id: 16, link: '', jobId: 'build-image', name: 'build-image', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: [
'unit-test',
'arch-test',
'integration-test',
'matrix-e2e',
]},
];
const verifyDeployJobs: ActionsJob[] = [
{id: 101, link: '', jobId: 'seed-dev', name: 'seed-dev', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s'},
{id: 102, link: '', jobId: 'seed-qa', name: 'seed-qa', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s'},
{id: 103, link: '', jobId: 'verify-dev', name: 'Verify Dev', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['seed-dev']},
{id: 104, link: '', jobId: 'verify-qa', name: 'Verify QA', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['seed-qa']},
{id: 105, link: '', jobId: 'deploy', name: 'Deploy', status: 'blocked', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '', needs: ['verify-dev', 'verify-qa']},
];
// Multi-level pipeline with two matrices and a leaf with two parents.
const wfTest1Jobs: ActionsJob[] = [
{id: 1, link: '', jobId: 'init', name: 'Initialize Pipeline', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '1s'},
{id: 2, link: '', jobId: 'lint-frontend', name: 'Lint Frontend', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['init']},
{id: 3, link: '', jobId: 'lint-backend', name: 'Lint Backend', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['init']},
{id: 4, link: '', jobId: 'build-frontend', name: 'Build Frontend', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['lint-frontend']},
{id: 5, link: '', jobId: 'build-backend', name: 'Build Backend', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '5s', needs: ['lint-backend']},
{id: 6, link: '', jobId: 'unit-tests', name: 'Unit Tests (api, true)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['build-frontend', 'build-backend']},
{id: 7, link: '', jobId: 'unit-tests', name: 'Unit Tests (api, false)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['build-frontend', 'build-backend']},
{id: 8, link: '', jobId: 'unit-tests', name: 'Unit Tests (service, true)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['build-frontend', 'build-backend']},
{id: 9, link: '', jobId: 'test-integration', name: 'Integration Tests', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '6s', needs: ['build-backend']},
{id: 10, link: '', jobId: 'e2e-tests', name: 'E2E Tests (chrome, desktop)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['build-frontend', 'unit-tests']},
{id: 11, link: '', jobId: 'e2e-tests', name: 'E2E Tests (chrome, mobile)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['build-frontend', 'unit-tests']},
{id: 12, link: '', jobId: 'e2e-tests', name: 'E2E Tests (firefox, desktop)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['build-frontend', 'unit-tests']},
{id: 13, link: '', jobId: 'bundle-app', name: 'Bundle Application', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['unit-tests', 'test-integration', 'e2e-tests']},
{id: 14, link: '', jobId: 'deploy-dev', name: 'Deploy to Dev', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['bundle-app']},
{id: 15, link: '', jobId: 'deploy-qa', name: 'Deploy to QA', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['bundle-app']},
{id: 16, link: '', jobId: 'verify-dev', name: 'Verify Dev', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['deploy-dev']},
{id: 17, link: '', jobId: 'verify-qa', name: 'Verify QA', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['deploy-qa']},
{id: 18, link: '', jobId: 'deploy-prod', name: 'Deploy to Production', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '5s', needs: ['verify-dev', 'verify-qa']},
{id: 19, link: '', jobId: 'post-deploy-checks', name: 'Post-Deploy Checks', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['deploy-prod']},
];
const mockJob = (id: number, jobId: string, name: string, needs?: string[]): ActionsJob =>
({id, link: '', jobId, name, status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '1s', needs});
test('matrix nodes key on job id, not on the display name', () => {
const legs = createWorkflowGraphModel([mockJob(1, 'explicit', 'leg one'), mockJob(2, 'explicit', 'leg two')]);
expect(legs.nodes).toHaveLength(1);
expect(legs.nodes[0].type).toBe('matrix');
expect(legs.nodes[0].name).toBe('explicit');
expect(legs.nodes[0].jobs.map((j) => j.id)).toEqual([1, 2]);
const lookalikes = createWorkflowGraphModel([
mockJob(1, 'setup', 'setup'),
mockJob(2, 'build-fast', 'build (fast)', ['setup']),
mockJob(3, 'build-slow', 'build (slow)', ['setup']),
]);
expect(lookalikes.nodes.map((n) => n.type)).toEqual(['job', 'group']);
const noJobId = createWorkflowGraphModel([mockJob(1, '', 'first'), mockJob(2, '', 'second')]);
expect(noJobId.nodes.map((n) => n.id)).toEqual(['job:1', 'job:2']);
});
test('computeJobLevels keeps stable topological levels', () => {
const levels = computeJobLevels(mockJobs);
expect(levels.get('job-100')).toBe(0);
expect(levels.get('job-101')).toBe(1);
expect(levels.get('job-102')).toBe(2);
expect(levels.get('build-image')).toBe(2);
});
test('graph model collapses matrix and groups jobs that share parents and children', () => {
const graph = createWorkflowGraphModel(mockJobs);
expect(graph.nodes.find((n) => n.type === 'matrix')?.jobs).toHaveLength(6);
const groupJobIds = graph.nodes.filter((n) => n.type === 'group').map((g) => g.jobs.map((j) => j.jobId));
expect(groupJobIds).toEqual(expect.arrayContaining([
['prep-jdk', 'code-analysis'],
['unit-test', 'arch-test', 'integration-test'],
]));
});
test('expanded matrix height includes summary and toggle rows', () => {
const collapsed = createWorkflowGraphModel(mockJobs);
const expanded = createWorkflowGraphModel(mockJobs, new Set(['matrix:matrix-e2e']));
const collapsedMatrix = collapsed.nodes.find((n) => n.id === 'matrix:matrix-e2e');
const expandedMatrix = expanded.nodes.find((n) => n.id === 'matrix:matrix-e2e');
expect(collapsedMatrix?.displayHeight).toBeLessThan(expandedMatrix?.displayHeight ?? 0);
// 6 jobs * 26 row height + 24 header + 6 pad * 2 = 192
expect(expandedMatrix?.displayHeight).toBe(192);
});
test('every dependency is rendered as one routed edge', () => {
const graph = createWorkflowGraphModel(mockJobs);
const rootGroup = graph.nodes.find((n) => n.type === 'group' && n.jobs.some((j) => j.jobId === 'prep-jdk'))!;
const testGroup = graph.nodes.find((n) => n.type === 'group' && n.jobs.some((j) => j.jobId === 'unit-test'))!;
const expectedKeys = [
`${rootGroup.id}->matrix:matrix-e2e`,
`${rootGroup.id}->${testGroup.id}`,
];
const keys = new Set(graph.routedEdges.map((e) => e.key));
for (const k of expectedKeys) expect(keys.has(k)).toBe(true);
});
test('same-row edge collapses to a single horizontal line', () => {
const graph = createWorkflowGraphModel(verifyDeployJobs);
const verifyDevEdge = graph.routedEdges.find((e) => e.fromId === 'job:101' && e.toId === 'job:103');
const verifyQaEdge = graph.routedEdges.find((e) => e.fromId === 'job:102' && e.toId === 'job:104');
expect(verifyDevEdge?.path).toMatch(/^M [\d.]+ [\d.]+ H [\d.]+$/);
expect(verifyQaEdge?.path).toMatch(/^M [\d.]+ [\d.]+ H [\d.]+$/);
});
test('different-row edge uses cubic bezier curve', () => {
const graph = createWorkflowGraphModel(verifyDeployJobs);
const deployLowerEdge = graph.routedEdges.find((e) => e.fromId === 'job:104' && e.toId === 'job:105');
expect(deployLowerEdge?.path).toContain(' C ');
});
test('multi-level pipeline with two matrices and a converging leaf renders without errors', () => {
const graph = createWorkflowGraphModel(wfTest1Jobs);
const matrices = graph.nodes.filter((n) => n.type === 'matrix');
expect(matrices.map((n) => n.name).sort()).toEqual(['E2E Tests', 'Unit Tests']);
const deployProd = graph.nodes.find((n) => n.id === 'job:18');
const verifyDev = graph.nodes.find((n) => n.id === 'job:16');
const verifyQa = graph.nodes.find((n) => n.id === 'job:17');
expect(verifyDev?.level).toBe(verifyQa?.level);
expect(deployProd?.level).toBe((verifyDev?.level ?? 0) + 1);
for (const node of graph.nodes) {
expect(Number.isFinite(node.x)).toBe(true);
expect(Number.isFinite(node.y)).toBe(true);
expect(node.x).toBeGreaterThanOrEqual(0);
expect(node.y).toBeGreaterThanOrEqual(0);
}
for (const edge of graph.routedEdges) {
expect(edge.path).not.toMatch(/NaN|undefined|Infinity/);
}
});
test('reusable callers with identical dependency signature are kept as separate nodes', () => {
const jobs: ActionsJob[] = [
{id: 1, link: '', jobId: 'prepare', name: 'prepare', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '30s'},
{id: 2, link: '', jobId: 'local_caller', name: 'local caller', status: 'running', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '5m', needs: ['prepare'], callUses: './.gitea/workflows/lib.yml'},
{id: 3, link: '', jobId: 'cross_caller', name: 'cross-repo caller', status: 'waiting', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '0s', needs: ['prepare'], callUses: 'user2/lib/.gitea/workflows/ext.yml@main'},
{id: 4, link: '', jobId: 'final', name: 'final', status: 'blocked', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '0s', needs: ['local_caller', 'cross_caller']},
];
const graph = createWorkflowGraphModel(jobs);
expect(graph.nodes.find((n) => n.type === 'group')).toBeUndefined();
expect(graph.nodes.find((n) => n.id === 'job:2')?.name).toBe('local caller');
expect(graph.nodes.find((n) => n.id === 'job:3')?.name).toBe('cross-repo caller');
});
test('matrix legs that call a reusable workflow are folded into a single matrix node', () => {
const jobs: ActionsJob[] = [
{id: 1, link: '', jobId: 'prepare', name: 'prepare', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '30s'},
{id: 2, link: '', jobId: 'build-call', name: 'build-call (linux)', status: 'success', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '1m', needs: ['prepare'], callUses: './.gitea/workflows/build.yml'},
{id: 3, link: '', jobId: 'build-call', name: 'build-call (windows)', status: 'success', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '2m', needs: ['prepare'], callUses: './.gitea/workflows/build.yml'},
{id: 4, link: '', jobId: 'build-call', name: 'build-call (macos)', status: 'success', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '90s', needs: ['prepare'], callUses: './.gitea/workflows/build.yml'},
];
const graph = createWorkflowGraphModel(jobs);
const matrixNodes = graph.nodes.filter((n) => n.type === 'matrix');
expect(matrixNodes).toHaveLength(1);
expect(matrixNodes[0].name).toBe('build-call');
expect(matrixNodes[0].jobs.map((j) => j.id).sort()).toEqual([2, 3, 4]);
});
test('directed highlight state covers ancestors and descendants of the hovered node', () => {
const graph = createWorkflowGraphModel(mockJobs);
const rootGroup = graph.nodes.find((n) => n.type === 'group' && n.jobs.some((j) => j.jobId === 'prep-jdk'))!;
const highlight = computeGraphHighlightState(rootGroup.id, graph.adjacency);
expect(highlight.nodeIds.has('matrix:matrix-e2e')).toBe(true);
expect(highlight.nodeIds.has('job:16')).toBe(true);
expect(highlight.edgeKeys.has(`${rootGroup.id}->matrix:matrix-e2e`)).toBe(true);
});
test('directed highlight state for converging graph excludes sibling branch when hovering parent', () => {
const graph = createWorkflowGraphModel(verifyDeployJobs);
const parentHighlight = computeGraphHighlightState('job:103', graph.adjacency);
expect(parentHighlight.nodeIds.has('job:101')).toBe(true);
expect(parentHighlight.nodeIds.has('job:105')).toBe(true);
expect(parentHighlight.nodeIds.has('job:104')).toBe(false);
expect(parentHighlight.edgeKeys.has('job:103->job:105')).toBe(true);
expect(parentHighlight.edgeKeys.has('job:104->job:105')).toBe(false);
const sinkHighlight = computeGraphHighlightState('job:105', graph.adjacency);
expect(sinkHighlight.nodeIds.has('job:103')).toBe(true);
expect(sinkHighlight.nodeIds.has('job:104')).toBe(true);
expect(sinkHighlight.edgeKeys.has('job:103->job:105')).toBe(true);
expect(sinkHighlight.edgeKeys.has('job:104->job:105')).toBe(true);
});
@@ -0,0 +1,538 @@
import type {ActionsJob, ActionsStatus} from '../modules/gitea-actions.ts';
export type GraphNodeType = 'job' | 'matrix' | 'group';
export type GraphNode = {
id: string;
type: GraphNodeType;
name: string;
status: ActionsStatus;
duration: string;
x: number;
y: number;
level: number;
displayHeight: number;
jobs: ActionsJob[];
};
export type Edge = {
fromId: string;
toId: string;
key: string;
};
export type RoutedEdge = Edge & {
path: string;
fromNode: GraphNode;
toNode: GraphNode;
};
export type SharedSegment = {
key: string;
edgeKeys: string[];
path: string;
};
export type GraphHighlightState = {
nodeIds: Set<string>;
edgeKeys: Set<string>;
};
export type WorkflowGraphLayoutOptions = {
margin: number;
nodeWidth: number;
nodeHeight: number;
columnGap: number;
laneGap: number;
groupRowHeight: number;
groupPadY: number;
matrixCollapsedHeight: number;
matrixHeaderHeight: number;
matrixRowHeight: number;
matrixPadY: number;
};
export type WorkflowGraphModel = {
nodes: GraphNode[];
edges: Edge[];
routedEdges: RoutedEdge[];
sharedSegments: SharedSegment[];
adjacency: NodeAdjacency;
};
export type NodeAdjacency = {
incomingByNodeId: Map<string, string[]>;
outgoingByNodeId: Map<string, string[]>;
};
const defaultLayoutOptions: WorkflowGraphLayoutOptions = {
margin: 24,
nodeWidth: 220,
nodeHeight: 40,
columnGap: 96,
laneGap: 32,
groupRowHeight: 28,
groupPadY: 8,
matrixCollapsedHeight: 78,
matrixHeaderHeight: 24,
matrixRowHeight: 26,
matrixPadY: 6,
};
function canonicalKey(ids: Iterable<string>): string {
return Array.from(ids).sort().join('');
}
function graphIdForJob(job: ActionsJob): string {
return `job:${job.id}`;
}
// matrix legs are named `<job name> (<combination>)`; a workflow-provided `name:` may not be
function matrixLabel(matrixJobs: ActionsJob[], jobId: string): string {
const prefixes = new Set(matrixJobs.map((job) => {
const idx = job.name.indexOf(' (');
return idx === -1 ? '' : job.name.slice(0, idx).trim();
}));
const [prefix] = prefixes;
return prefixes.size === 1 && prefix ? prefix : jobId;
}
export function boxBottom(node: GraphNode): number {
return node.y + node.displayHeight;
}
export function boxCenterY(node: GraphNode): number {
return node.y + node.displayHeight / 2;
}
function matrixPanelHeight(rowCount: number, expanded: boolean, options: WorkflowGraphLayoutOptions): number {
if (rowCount <= 0) return options.nodeHeight;
if (!expanded) return options.matrixCollapsedHeight;
return options.matrixHeaderHeight + rowCount * options.matrixRowHeight + options.matrixPadY * 2;
}
function groupPanelHeight(rowCount: number, options: WorkflowGraphLayoutOptions): number {
return rowCount * options.groupRowHeight + options.groupPadY * 2;
}
function compareStatusWorstFirst(a: ActionsStatus, b: ActionsStatus): number {
const rank = (s: ActionsStatus) => {
if (s === 'failure') return 0;
if (s === 'cancelled') return 1;
if (s === 'running') return 2;
if (s === 'waiting') return 3;
if (s === 'blocked') return 4;
if (s === 'success') return 5;
if (s === 'skipped') return 6;
return 7;
};
return rank(a) - rank(b);
}
function aggregateStatus(children: ActionsJob[]): ActionsStatus {
return children.map((c) => c.status).slice().sort(compareStatusWorstFirst)[0] ?? 'unknown';
}
function buildDirectNeedsMap(jobs: ActionsJob[]): Map<string, string[]> {
const directNeedsByJobId = new Map<string, string[]>();
const dependentsByJobId = new Map<string, Set<string>>();
for (const job of jobs) {
const needs = job.needs || [];
directNeedsByJobId.set(job.jobId, needs);
for (const need of needs) {
if (!dependentsByJobId.has(need)) dependentsByJobId.set(need, new Set());
dependentsByJobId.get(need)!.add(job.jobId);
}
}
const reachabilityCache = new Map<string, boolean>();
function canReach(fromJobId: string, toJobId: string): boolean {
const cacheKey = `${fromJobId}->${toJobId}`;
if (reachabilityCache.has(cacheKey)) return reachabilityCache.get(cacheKey)!;
const visited = new Set<string>();
const stack = Array.from(dependentsByJobId.get(fromJobId) || []);
while (stack.length > 0) {
const current = stack.pop()!;
if (current === toJobId) {
reachabilityCache.set(cacheKey, true);
return true;
}
if (visited.has(current)) continue;
visited.add(current);
stack.push(...(dependentsByJobId.get(current) || []));
}
reachabilityCache.set(cacheKey, false);
return false;
}
const reducedNeedsByJobId = new Map<string, string[]>();
for (const [jobId, needs] of directNeedsByJobId) {
reducedNeedsByJobId.set(jobId, needs.filter((need) => {
return needs.every((other) => other === need || !canReach(need, other));
}));
}
return reducedNeedsByJobId;
}
export function computeJobLevels(jobs: ActionsJob[]): Map<string, number> {
const jobMap = new Map<string, ActionsJob>();
for (const job of jobs) {
jobMap.set(job.name, job);
if (job.jobId) jobMap.set(job.jobId, job);
}
const levels = new Map<string, number>();
const visited = new Set<string>();
const recursionStack = new Set<string>();
function dfs(jobNameOrId: string): number {
if (recursionStack.has(jobNameOrId)) return 0;
if (visited.has(jobNameOrId)) return levels.get(jobNameOrId) ?? 0;
recursionStack.add(jobNameOrId);
visited.add(jobNameOrId);
const job = jobMap.get(jobNameOrId);
if (!job) {
recursionStack.delete(jobNameOrId);
return 0;
}
if (!job.needs?.length) {
levels.set(job.jobId, 0);
if (job.jobId !== job.name) levels.set(job.name, 0);
recursionStack.delete(jobNameOrId);
return 0;
}
let maxLevel = -1;
for (const need of job.needs) {
if (!jobMap.has(need)) continue;
maxLevel = Math.max(maxLevel, dfs(need));
}
const level = maxLevel + 1;
levels.set(job.name, level);
levels.set(job.jobId, level);
recursionStack.delete(jobNameOrId);
return level;
}
for (const job of jobs) {
if (!visited.has(job.jobId)) dfs(job.jobId);
}
return levels;
}
export function computeGraphHighlightState(hoveredId: string | null, adjacency: NodeAdjacency): GraphHighlightState {
if (!hoveredId) return {nodeIds: new Set(), edgeKeys: new Set()};
const {incomingByNodeId, outgoingByNodeId} = adjacency;
const edgeKeys = new Set<string>();
const collect = (startId: string, adj: Map<string, string[]>, edgeKeyForward: boolean): Set<string> => {
const seen = new Set<string>();
const queue = [startId];
while (queue.length > 0) {
const current = queue.shift()!;
if (seen.has(current)) continue;
seen.add(current);
for (const next of adj.get(current) || []) {
edgeKeys.add(edgeKeyForward ? `${current}->${next}` : `${next}->${current}`);
if (!seen.has(next)) queue.push(next);
}
}
return seen;
};
const ancestors = collect(hoveredId, incomingByNodeId, false);
const descendants = collect(hoveredId, outgoingByNodeId, true);
return {nodeIds: new Set([...ancestors, ...descendants]), edgeKeys};
}
type VisualGraphBuild = {
nodes: GraphNode[];
edges: Edge[];
};
function buildVisualGraph(
jobs: ActionsJob[],
expandedMatrixNodeIds: ReadonlySet<string>,
options: WorkflowGraphLayoutOptions,
): VisualGraphBuild {
const jobsByJobId = new Map<string, ActionsJob[]>();
const jobIndexById = new Map<number, number>();
for (const [index, job] of jobs.entries()) {
jobIndexById.set(job.id, index);
if (!jobsByJobId.has(job.jobId)) jobsByJobId.set(job.jobId, []);
jobsByJobId.get(job.jobId)!.push(job);
}
// legs of one matrix job share its `jobId`; their display names are free-form so cannot key them
const isMatrixLeg = (job: ActionsJob): boolean => Boolean(job.jobId) && jobsByJobId.get(job.jobId)!.length > 1;
const directNeedsByJobId = buildDirectNeedsMap(jobs);
const rawLevels = computeJobLevels(jobs);
const dependentsByJobId = new Map<string, string[]>();
const rawEdges: Array<{from: ActionsJob; to: ActionsJob}> = [];
for (const job of jobs) {
for (const need of directNeedsByJobId.get(job.jobId) || []) {
for (const upstream of jobsByJobId.get(need) || []) {
rawEdges.push({from: upstream, to: job});
if (!dependentsByJobId.has(upstream.jobId)) dependentsByJobId.set(upstream.jobId, []);
dependentsByJobId.get(upstream.jobId)!.push(job.jobId);
}
}
}
for (const list of dependentsByJobId.values()) list.sort();
// Group sibling jobs that share an identical (parents, children) signature into a single
// collapsed "group" node. This is a visual aggregation only - the underlying jobs are
// preserved on the node so the panel can list them.
const groupedJobIds = new Map<number, string>();
const groupsById = new Map<string, ActionsJob[]>();
const groupCandidateBuckets = new Map<string, ActionsJob[]>();
for (const job of jobs) {
if (isMatrixLeg(job)) continue;
// Reusable callers represent distinct workflow files — keep each as its own node so the
// graph mirrors GitHub Actions, where every caller shows up as its own box even when
// siblings share an identical (parents, children) dependency signature.
if (job.isReusableCaller) continue;
const needsKey = canonicalKey(directNeedsByJobId.get(job.jobId) || []);
const childrenKey = (dependentsByJobId.get(job.jobId) || []).join('');
if (!needsKey && !childrenKey) continue;
const level = rawLevels.get(job.jobId) ?? 0;
const key = `group:${level}:${needsKey}:${childrenKey}`;
if (!groupCandidateBuckets.has(key)) groupCandidateBuckets.set(key, []);
groupCandidateBuckets.get(key)!.push(job);
}
for (const [groupId, groupJobs] of groupCandidateBuckets) {
if (groupJobs.length < 2) continue;
groupJobs.sort((a, b) => (jobIndexById.get(a.id) ?? 0) - (jobIndexById.get(b.id) ?? 0));
groupsById.set(groupId, groupJobs);
for (const job of groupJobs) groupedJobIds.set(job.id, groupId);
}
const visualIdByJobId = new Map<number, string>();
const emittedNodeIds = new Set<string>();
const nodes: GraphNode[] = [];
for (const job of jobs) {
const matrixJobs = isMatrixLeg(job) ? jobsByJobId.get(job.jobId)! : null;
const visualId = matrixJobs ? `matrix:${job.jobId}` : (groupedJobIds.get(job.id) || graphIdForJob(job));
visualIdByJobId.set(job.id, visualId);
if (emittedNodeIds.has(visualId)) continue;
emittedNodeIds.add(visualId);
if (matrixJobs) {
nodes.push({
id: visualId,
type: 'matrix',
name: matrixLabel(matrixJobs, job.jobId),
status: aggregateStatus(matrixJobs),
duration: '',
x: 0, y: 0, level: 0,
displayHeight: matrixPanelHeight(matrixJobs.length, expandedMatrixNodeIds.has(visualId), options),
jobs: matrixJobs,
});
continue;
}
const groupJobs = groupsById.get(visualId);
if (groupJobs) {
nodes.push({
id: visualId,
type: 'group',
name: groupJobs.map((g) => g.name).join(', '),
status: aggregateStatus(groupJobs),
duration: '',
x: 0, y: 0, level: 0,
displayHeight: groupPanelHeight(groupJobs.length, options),
jobs: groupJobs,
});
continue;
}
nodes.push({
id: visualId,
type: 'job',
name: job.name,
status: job.status,
duration: job.duration,
x: 0, y: 0, level: 0,
displayHeight: options.nodeHeight,
jobs: [job],
});
}
const seenEdges = new Set<string>();
const edges: Edge[] = [];
for (const {from, to} of rawEdges) {
const fromId = visualIdByJobId.get(from.id);
const toId = visualIdByJobId.get(to.id);
if (!fromId || !toId || fromId === toId) continue;
const key = `${fromId}->${toId}`;
if (seenEdges.has(key)) continue;
seenEdges.add(key);
edges.push({fromId, toId, key});
}
return {nodes, edges};
}
function buildNodeAdjacency(edges: Edge[]): NodeAdjacency {
const incomingByNodeId = new Map<string, string[]>();
const outgoingByNodeId = new Map<string, string[]>();
for (const edge of edges) {
if (!incomingByNodeId.has(edge.toId)) incomingByNodeId.set(edge.toId, []);
incomingByNodeId.get(edge.toId)!.push(edge.fromId);
if (!outgoingByNodeId.has(edge.fromId)) outgoingByNodeId.set(edge.fromId, []);
outgoingByNodeId.get(edge.fromId)!.push(edge.toId);
}
return {incomingByNodeId, outgoingByNodeId};
}
function assignNodeLevels(nodes: GraphNode[], {incomingByNodeId}: NodeAdjacency): void {
const cache = new Map<string, number>();
function levelFor(id: string, visiting = new Set<string>()): number {
if (cache.has(id)) return cache.get(id)!;
if (visiting.has(id)) return 0;
visiting.add(id);
const incoming = incomingByNodeId.get(id) || [];
const level = incoming.length > 0 ?
Math.max(...incoming.map((fromId) => levelFor(fromId, visiting))) + 1 :
0;
visiting.delete(id);
cache.set(id, level);
return level;
}
for (const node of nodes) node.level = levelFor(node.id);
}
// Roots stay in input order; later levels are sorted by the mean parent Y so that simple
// chains stay on a straight horizontal line.
function assignNodeCoordinates(nodesById: Map<string, GraphNode>, nodes: GraphNode[], adjacency: NodeAdjacency, options: WorkflowGraphLayoutOptions): void {
const {incomingByNodeId} = adjacency;
const inputRank = (node: GraphNode): number => Math.min(...node.jobs.map((j) => j.id));
const nodesByLevel = new Map<number, GraphNode[]>();
for (const node of nodes) {
if (!nodesByLevel.has(node.level)) nodesByLevel.set(node.level, []);
nodesByLevel.get(node.level)!.push(node);
}
const orderedLevels = Array.from(nodesByLevel.keys()).sort((a, b) => a - b);
// Initial X assignment and a default Y so barycenters can use a finite value.
for (const level of orderedLevels) {
const list = nodesByLevel.get(level)!;
list.sort((a, b) => inputRank(a) - inputRank(b));
let yCursor = options.margin;
for (const node of list) {
node.x = options.margin + level * (options.nodeWidth + options.columnGap);
node.y = yCursor;
yCursor += node.displayHeight + options.laneGap;
}
}
function packLevel(level: number, anchorOf: (n: GraphNode) => number): void {
const list = nodesByLevel.get(level)!;
const sorted = Array.from(list).sort((a, b) => anchorOf(a) - anchorOf(b) || inputRank(a) - inputRank(b));
// Pack tight to top after sorting. Using barycenter only for order (not Y) keeps terminal
// nodes like build-image close to the top of their column instead of being pulled down to
// the mean Y of their parents — matching GitHub Actions' compact layout.
let prevBottom = options.margin - options.laneGap;
for (const node of sorted) {
node.y = prevBottom + options.laneGap;
prevBottom = boxBottom(node);
}
nodesByLevel.set(level, sorted);
}
function meanCenterOf(ids: string[]): number | null {
if (ids.length === 0) return null;
let sum = 0;
for (const id of ids) sum += boxCenterY(nodesById.get(id)!);
return sum / ids.length;
}
// Down-only barycenter pass: each child is anchored to the mean Y of its parents. Roots
// keep their initial yaml-declaration order (via inputRank), matching how GitHub Actions
// arranges root jobs. This produces a "main chain on top" layout where job-100 → job-101 →
// job-102 stays on a straight horizontal line.
for (const level of orderedLevels) {
if (level === 0) continue;
packLevel(level, (node) => meanCenterOf(incomingByNodeId.get(node.id) || []) ?? boxCenterY(node));
}
}
// Per-edge connector: source stub → cubic-bezier corner down/up to column midpoint →
// vertical run → cubic-bezier corner back to horizontal → target stub. The corner radius is
// fixed (not clamped to the row delta) so any two edges sharing the same source produce the
// same source-side path and overlap into a single visual line until they diverge at the V.
const cornerRadius = 12;
function connectorPath(sx: number, sy: number, ex: number, ey: number, options: WorkflowGraphLayoutOptions): string {
if (Math.abs(sy - ey) < 0.5) return `M ${sx} ${sy} H ${ex}`;
// Anchor the V segment in the column gap immediately before the target instead of the
// horizontal midpoint. The long H stays at the source's Y, matching GitHub Actions' style
// — a multi-column edge runs along the source row across intermediate columns, then turns
// up/down only when it reaches the target column.
const midX = Math.max(ex - options.columnGap / 2, (sx + ex) / 2);
const dy = ey > sy ? 1 : -1;
// Keep the same H prefix to `midX - cornerRadius` for every edge so that edges sharing a
// source overlap visually until they fork. When there isn't 2*cornerRadius of vertical
// room for the V segment, emit a single S-curve between (midX - r, sy) and (midX + r, ey)
// instead of a backward V kink.
if (Math.abs(ey - sy) < cornerRadius * 2) {
return [
`M ${sx} ${sy}`,
`H ${midX - cornerRadius}`,
`C ${midX} ${sy} ${midX} ${ey} ${midX + cornerRadius} ${ey}`,
`H ${ex}`,
].join(' ');
}
const half = cornerRadius / 2;
return [
`M ${sx} ${sy}`,
`H ${midX - cornerRadius}`,
`C ${midX - half} ${sy} ${midX} ${sy + half * dy} ${midX} ${sy + cornerRadius * dy}`,
`V ${ey - cornerRadius * dy}`,
`C ${midX} ${ey - half * dy} ${midX + half} ${ey} ${midX + cornerRadius} ${ey}`,
`H ${ex}`,
].join(' ');
}
function buildRoutedEdges(
nodesById: Map<string, GraphNode>,
edges: Edge[],
options: WorkflowGraphLayoutOptions,
): Pick<WorkflowGraphModel, 'routedEdges' | 'sharedSegments'> {
const routedEdges: RoutedEdge[] = [];
for (const edge of edges) {
const fromNode = nodesById.get(edge.fromId);
const toNode = nodesById.get(edge.toId);
if (!fromNode || !toNode) continue;
const startX = fromNode.x + options.nodeWidth;
const endX = toNode.x;
const startY = boxCenterY(fromNode);
const endY = boxCenterY(toNode);
routedEdges.push({...edge, fromNode, toNode, path: connectorPath(startX, startY, endX, endY, options)});
}
return {routedEdges, sharedSegments: []};
}
export function createWorkflowGraphModel(
jobs: ActionsJob[],
expandedMatrixNodeIds: ReadonlySet<string> = new Set(),
partialOptions: Partial<WorkflowGraphLayoutOptions> = {},
): WorkflowGraphModel {
const options = {...defaultLayoutOptions, ...partialOptions};
const {nodes, edges} = buildVisualGraph(jobs, expandedMatrixNodeIds, options);
const nodesById = new Map(nodes.map((n) => [n.id, n]));
const adjacency = buildNodeAdjacency(edges);
assignNodeLevels(nodes, adjacency);
assignNodeCoordinates(nodesById, nodes, adjacency, options);
return {nodes, edges, ...buildRoutedEdges(nodesById, edges, options), adjacency};
}
export function getWorkflowGraphLayoutOptions(partialOptions: Partial<WorkflowGraphLayoutOptions> = {}): WorkflowGraphLayoutOptions {
return {...defaultLayoutOptions, ...partialOptions};
}
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,7 @@ type LazyLoadFunc = () => Promise<{frontendRender: FrontendRenderFunc}>;
const frontendPlugins: Record<string, LazyLoadFunc> = {
'viewer-3d': () => import('./render/plugins/frontend-viewer-3d.ts'),
'openapi-swagger': () => import('./render/plugins/frontend-openapi-swagger.ts'),
'asciicast': () => import('./render/plugins/frontend-asciicast.ts'),
};
class Options implements FrontendRenderOptions {
@@ -44,23 +45,28 @@ async function initFrontendExternalRender() {
const viewerContainer = document.querySelector<HTMLElement>('#frontend-render-viewer')!;
const renderNames = viewerContainer.getAttribute('data-frontend-renders')!.split(' ');
const fileTreePath = viewerContainer.getAttribute('data-file-tree-path')!;
viewerContainer.setAttribute('data-window-origin', window.origin); // mainly for testing purpose
const fileDataElem = document.querySelector<HTMLTextAreaElement>('#frontend-render-data')!;
fileDataElem.remove();
const fileDataContent = fileDataElem.value;
const fileDataEncoding = fileDataElem.getAttribute('data-content-encoding')!;
const opts = new Options(viewerContainer, fileTreePath, fileDataEncoding, fileDataContent);
let found = false;
let renderName = '', rendered = false;
for (const name of renderNames) {
if (!(name in frontendPlugins)) continue;
const plugin = await frontendPlugins[name]();
found = true;
if (await plugin.frontendRender(opts)) break;
renderName = name;
rendered = await plugin.frontendRender(opts);
if (rendered) break;
}
if (!found) {
if (!renderName) {
viewerContainer.textContent = 'No frontend render plugin found for this file, but backend declares that there must be one, there must be a bug';
} else if (!rendered) {
viewerContainer.textContent = `Failed to render by ${renderName}`;
} else {
viewerContainer.setAttribute('data-frontend-render-name', renderName); // succeeded render, mainly for testing purpose
}
}
@@ -1,7 +1,7 @@
import './external-render-helper.ts';
test('isValidCssColor', async () => {
const isValidCssColor = window.testModules.externalRenderHelper!.isValidCssColor;
const isValidCssColor = window.giteaExternalRenderHelper!.isValidCssColor;
expect(isValidCssColor(null)).toBe(false);
expect(isValidCssColor('')).toBe(false);
@@ -50,12 +50,12 @@ body { background: ${backgroundColor}; }
}
const iframeId = queryParams.get('gitea-iframe-id');
if (iframeId) {
// iframe is in different origin, so we need to use postMessage to communicate
const postIframeMsg = (cmd: string, data: Record<string, any> = {}) => {
window.parent.postMessage({giteaIframeCmd: cmd, giteaIframeId: iframeId, ...data}, '*');
};
// iframe is in different origin, so we need to use postMessage to communicate
const postIframeMsg = (cmd: string, data: Record<string, any> = {}) => {
window.parent.postMessage({giteaIframeCmd: cmd, giteaIframeId: iframeId, ...data}, '*');
};
if (iframeId) {
const updateIframeHeight = () => {
if (!document.body) return; // the body might not be available when this function is called
// Use scrollHeight to get the full content height, even when CSS sets html/body to height:100%
@@ -90,6 +90,4 @@ if (iframeId) {
});
}
if (window.testModules) {
window.testModules.externalRenderHelper = {isValidCssColor};
}
window.giteaExternalRenderHelper = {isValidCssColor, queryParams, postIframeMsg};
@@ -1,8 +1,9 @@
import {checkAppUrl} from '../common-page.ts';
import {hideElem, queryElems, showElem, toggleElem} from '../../utils/dom.ts';
import {POST} from '../../modules/fetch.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
import {showFomanticModal} from '../../modules/fomantic/modal.ts';
import {pathEscape} from '../../utils/url.ts';
import {registerGlobalInitFunc} from '../../modules/observer.ts';
const {appSubUrl} = window.config;
@@ -23,6 +24,33 @@ export function initAdminCommon(): void {
initAdminUser();
initAdminAuthentication();
initAdminNotice();
registerGlobalInitFunc('initRunnerBulkToolbar', initAdminRunnerBulk);
}
function initAdminRunnerBulk(toolbar: HTMLElement) {
const actionButtons = toolbar.querySelectorAll<HTMLButtonElement>('.runner-bulk-action');
const formRunnerIds = toolbar.querySelector<HTMLInputElement>('form input[name="ids"]')!;
const rowCheckboxes = document.querySelectorAll<HTMLInputElement>('.runner-bulk-select');
const selectAll = document.querySelector<HTMLInputElement>('.runner-bulk-select-all');
if (!selectAll) return;
const refresh = () => {
const checked = Array.from(rowCheckboxes).filter((c) => c.checked);
formRunnerIds.value = checked.map((c) => c.getAttribute('data-runner-id')!).join(',');
toggleElem(toolbar, checked.length > 0);
for (const btn of actionButtons) {
btn.querySelector<HTMLElement>('.runner-bulk-count')!.textContent = `(${checked.length})`;
}
selectAll.checked = checked.length > 0 && checked.length === rowCheckboxes.length;
selectAll.indeterminate = checked.length > 0 && checked.length < rowCheckboxes.length;
};
selectAll.addEventListener('change', () => {
for (const cb of rowCheckboxes) cb.checked = selectAll.checked;
refresh();
});
for (const cb of rowCheckboxes) cb.addEventListener('change', refresh);
refresh();
}
function initAdminUser() {
@@ -78,7 +106,7 @@ function initAdminAuthentication() {
}
function onOAuth2Change(applyDefaultValues: boolean) {
hideElem('.open_id_connect_auto_discovery_url, .oauth2_use_custom_url');
hideElem('.open_id_connect_auto_discovery_url, .open_id_connect_external_id_claim, .oauth2_use_custom_url');
for (const input of document.querySelectorAll<HTMLInputElement>('.open_id_connect_auto_discovery_url input[required]')) {
input.removeAttribute('required');
}
@@ -86,8 +114,10 @@ function initAdminAuthentication() {
const provider = document.querySelector<HTMLInputElement>('#oauth2_provider')!.value;
switch (provider) {
case 'openidConnect':
case 'aws-cognito':
document.querySelector<HTMLInputElement>('.open_id_connect_auto_discovery_url input')!.setAttribute('required', 'required');
showElem('.open_id_connect_auto_discovery_url');
showElem('.open_id_connect_external_id_claim');
break;
default: {
const elProviderCustomUrlSettings = document.querySelector<HTMLInputElement>(`#${provider}_customURLSettings`);
@@ -249,7 +279,7 @@ function initAdminNotice() {
const elNoticeDesc = el.closest('tr')!.querySelector('.notice-description')!;
const elModalDesc = detailModal.querySelector('.content pre')!;
elModalDesc.textContent = elNoticeDesc.textContent;
fomanticQuery(detailModal).modal('show');
showFomanticModal(detailModal);
}));
// Select actions
@@ -285,6 +315,6 @@ function initAdminNotice() {
}
}
await POST(this.getAttribute('data-link')!, {data});
window.location.href = this.getAttribute('data-redirect')!;
window.location.reload();
});
}
@@ -29,10 +29,10 @@ test('ConfigFormValueMapper', () => {
mapper.fillFromSystemConfig();
const formData = mapper.collectToFormData();
const result: Record<string, string> = {};
const keys = [], values = [];
for (const [key, value] of formData.entries()) {
const keys: string[] = [], values: string[] = [];
for (const [key, value] of formData) {
if (key === 'key') keys.push(value as string);
if (key === 'value') values.push(value as string);
else if (key === 'value') values.push(value as string);
}
for (let i = 0; i < keys.length; i++) {
result[keys[i]] = values[i];
@@ -2,7 +2,9 @@ import {showTemporaryTooltip} from '../../modules/tippy.ts';
import {POST} from '../../modules/fetch.ts';
import {registerGlobalInitFunc} from '../../modules/observer.ts';
import {queryElems} from '../../utils/dom.ts';
import {errorMessage} from '../../modules/errors.ts';
import {submitFormFetchAction} from '../common-fetch-action.ts';
import {cutString} from '../../utils/string.ts';
const {appSubUrl} = window.config;
@@ -26,7 +28,7 @@ function initSystemConfigAutoCheckbox(el: HTMLInputElement) {
const json: Record<string, any> = await resp.json();
if (json.errorMessage) throw new Error(json.errorMessage);
} catch (ex) {
showTemporaryTooltip(el, ex.toString());
showTemporaryTooltip(el, errorMessage(ex));
el.checked = !el.checked;
}
});
@@ -101,9 +103,7 @@ export class ConfigFormValueMapper {
} else if (el.matches('[type="datetime-local"]')) {
if (valType !== 'timestamp') requireExplicitValueType(el);
if (val) el.value = toDatetimeLocalValue(val);
} else if (el.matches('textarea')) {
el.value = String(val ?? el.value);
} else if (el.matches('input') && (el.getAttribute('type') ?? 'text') === 'text') {
} else if (el.matches('textarea') || (el.matches('input') && (el.getAttribute('type') ?? 'text') === 'text')) {
el.value = String(val ?? el.value);
} else {
unsupportedElement(el);
@@ -122,9 +122,7 @@ export class ConfigFormValueMapper {
} else if (el.matches('[type="datetime-local"]')) {
if (valType !== 'timestamp') requireExplicitValueType(el);
val = Math.floor(new Date(el.value).getTime() / 1000) ?? 0; // NaN is fine to JSON.stringify, it becomes null.
} else if (el.matches('textarea')) {
val = el.value;
} else if (el.matches('input') && (el.getAttribute('type') ?? 'text') === 'text') {
} else if (el.matches('textarea') || (el.matches('input') && (el.getAttribute('type') ?? 'text') === 'text')) {
val = el.value;
} else {
unsupportedElement(el);
@@ -161,7 +159,7 @@ export class ConfigFormValueMapper {
const apps: Array<{DisplayName: string, OpenURL: string}> = [];
const lines = cfgVal.split('\n');
for (const line of lines) {
let [displayName, openUrl] = line.split('=', 2);
let [displayName, openUrl] = cutString(line, '=');
displayName = displayName.trim();
openUrl = openUrl?.trim() ?? '';
if (!displayName || !openUrl) continue;
@@ -177,7 +175,7 @@ export class ConfigFormValueMapper {
collectToFormData(): FormData {
const namedElems: Array<GeneralFormFieldElement | null> = [];
queryElems(this.form, '[name]', (el) => namedElems.push(el as GeneralFormFieldElement));
queryElems(this.form, '[name]', (el) => { namedElems.push(el as GeneralFormFieldElement) });
// first, process the config options with sub values, for example:
// merge "foo.bar.Enabled", "foo.bar.Message" to "foo.bar"
@@ -5,14 +5,14 @@ export function initAdminUserListSearchForm(): void {
const form = document.querySelector<HTMLFormElement>('#user-list-search-form');
if (!form) return;
for (const button of form.querySelectorAll(`button[name=sort][value="${searchForm.SortType}"]`)) {
for (const button of form.querySelectorAll(`button[name=sort][value="${CSS.escape(searchForm.SortType)}"]`)) {
button.classList.add('active');
}
if (searchForm.StatusFilterMap) {
for (const [k, v] of Object.entries(searchForm.StatusFilterMap)) {
if (!v) continue;
for (const input of form.querySelectorAll<HTMLInputElement>(`input[name="status_filter[${k}]"][value="${v}"]`)) {
for (const input of form.querySelectorAll<HTMLInputElement>(`input[name="status_filter[${CSS.escape(k)}]"][value="${CSS.escape(v)}"]`)) {
input.checked = true;
}
}
@@ -1,5 +1,6 @@
import {getCurrentLocale} from '../utils.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {errorMessage} from '../modules/errors.ts';
import {showFomanticModal} from '../modules/fomantic/modal.ts';
import {localUserSettings} from '../modules/user-settings.ts';
const {pageData} = window.config;
@@ -46,7 +47,7 @@ export async function initCitationFileCopyContent() {
try {
await initInputCitationValue(citationCopyApa, citationCopyBibtex);
} catch (e) {
console.error(`initCitationFileCopyContent error: ${e}`, e);
console.error(`initCitationFileCopyContent error: ${errorMessage(e)}`, e);
return;
}
updateUi();
@@ -65,6 +66,6 @@ export async function initCitationFileCopyContent() {
inputContent.select();
});
fomanticQuery('#cite-repo-modal').modal('show');
showFomanticModal(document.querySelector('#cite-repo-modal'));
});
}
@@ -1,40 +0,0 @@
import {showTemporaryTooltip} from '../modules/tippy.ts';
import {toAbsoluteUrl} from '../utils.ts';
import {clippie} from 'clippie';
const {copy_success, copy_error} = window.config.i18n;
// Enable clipboard copy from HTML attributes. These properties are supported:
// - data-clipboard-text: Direct text to copy
// - data-clipboard-target: Holds a selector for an element. "value" of <input> or <textarea>, or "textContent" of <div> will be copied
// - data-clipboard-text-type: When set to 'url' will convert relative to absolute urls
export function initGlobalCopyToClipboardListener() {
document.addEventListener('click', async (e) => {
const target = (e.target as HTMLElement).closest('[data-clipboard-text], [data-clipboard-target]');
if (!target) return;
e.preventDefault();
let text = target.getAttribute('data-clipboard-text');
if (text === null) {
const textSelector = target.getAttribute('data-clipboard-target')!;
const textTarget = document.querySelector(textSelector)!;
if (textTarget.nodeName === 'INPUT' || textTarget.nodeName === 'TEXTAREA') {
text = (textTarget as HTMLInputElement | HTMLTextAreaElement).value;
} else if (textTarget.nodeName === 'DIV') {
text = textTarget.textContent;
} else {
throw new Error(`Unsupported element for clipboard target: ${textSelector}`);
}
}
if (text && target.getAttribute('data-clipboard-text-type') === 'url') {
text = toAbsoluteUrl(text);
}
if (text) {
const success = await clippie(text);
showTemporaryTooltip(target, success ? copy_success : copy_error);
}
});
}
@@ -2,9 +2,10 @@ import {assignElementProperty, type ElementWithAssignableProperties} from './com
test('assignElementProperty', () => {
const elForm = document.createElement('form');
assignElementProperty(elForm, 'action', '/test-link');
expect(elForm.action).contains('/test-link'); // the DOM always returns absolute URL
expect(elForm.getAttribute('action')).eq('/test-link');
expect(() => assignElementProperty(elForm, 'action', '/test-link')).toThrow();
assignElementProperty(elForm, 'url', '/test-url');
expect(elForm.action).contains('/test-url'); // the DOM always returns absolute URL
expect(elForm.getAttribute('action')).eq('/test-url');
assignElementProperty(elForm, 'text-content', 'dummy');
expect(elForm.textContent).toBe('dummy');
@@ -14,8 +15,9 @@ test('assignElementProperty', () => {
_attrs: Record<string, string> = {};
setAttribute(name: string, value: string) { this._attrs[name] = value }
getAttribute(name: string): string | null { return this._attrs[name] }
nodeName = 'FORM';
}();
assignElementProperty(elFormWithAction, 'action', '/bar');
assignElementProperty(elFormWithAction, 'url', '/bar');
expect(elFormWithAction.getAttribute('action')).eq('/bar');
const elInput = document.createElement('input');
@@ -1,6 +1,5 @@
import {POST} from '../modules/fetch.ts';
import {addDelegatedEventListener, hideElem, isElemVisible, showElem, toggleElem} from '../utils/dom.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {showFomanticModal} from '../modules/fomantic/modal.ts';
import {camelize} from 'vue';
import {applyAutoFocus} from './common-page.ts';
@@ -13,74 +12,6 @@ export function initGlobalButtonClickOnEnter(): void {
});
}
export function initGlobalDeleteButton(): void {
// ".delete-button" shows a confirmation modal defined by `data-modal-id` attribute.
// Some model/form elements will be filled by `data-id` / `data-name` / `data-data-xxx` attributes.
// If there is a form defined by `data-form`, then the form will be submitted as-is (without any modification).
// If there is no form, then the data will be posted to `data-url`.
// TODO: do not use this method in new code. `show-modal` / `link-action(data-modal-confirm)` does far better than this.
// FIXME: all legacy `delete-button` should be refactored to use `show-modal` or `link-action`
for (const btn of document.querySelectorAll<HTMLElement>('.delete-button')) {
btn.addEventListener('click', (e) => {
e.preventDefault();
// eslint-disable-next-line github/no-dataset -- code depends on the camel-casing
const dataObj = btn.dataset;
const modalId = btn.getAttribute('data-modal-id');
const modal = document.querySelector(`.delete.modal${modalId ? `#${modalId}` : ''}`)!;
// set the modal "display name" by `data-name`
const modalNameEl = modal.querySelector('.name');
if (modalNameEl) modalNameEl.textContent = btn.getAttribute('data-name');
// fill the modal elements with data-xxx attributes: `data-data-organization-name="..."` => `<span class="dataOrganizationName">...</span>`
for (const [key, value] of Object.entries(dataObj)) {
if (key.startsWith('data')) {
const textEl = modal.querySelector(`.${key}`);
if (textEl) textEl.textContent = value ?? null;
}
}
fomanticQuery(modal).modal({
closable: false,
onApprove: () => {
// if `data-type="form"` exists, then submit the form by the selector provided by `data-form="..."`
if (btn.getAttribute('data-type') === 'form') {
const formSelector = btn.getAttribute('data-form')!;
const form = document.querySelector<HTMLFormElement>(formSelector);
if (!form) throw new Error(`no form named ${formSelector} found`);
modal.classList.add('is-loading'); // the form is not in the modal, so also add loading indicator to the modal
form.classList.add('is-loading');
form.submit();
return false; // prevent modal from closing automatically
}
// prepare an AJAX form by data attributes
const postData = new FormData();
for (const [key, value] of Object.entries(dataObj)) {
if (key.startsWith('data')) { // for data-data-xxx (HTML) -> dataXxx (form)
postData.append(key.slice(4), String(value));
}
if (key === 'id') { // for data-id="..."
postData.append('id', String(value));
}
}
(async () => {
const response = await POST(btn.getAttribute('data-url')!, {data: postData});
if (response.ok) {
const data = await response.json();
window.location.href = data.redirect;
}
})();
modal.classList.add('is-loading'); // the request is in progress, so also add loading indicator to the modal
return false; // prevent modal from closing automatically
},
}).modal('show');
});
}
}
function onShowPanelClick(el: HTMLElement, e: MouseEvent) {
// a '.show-panel' element can show a panel, by `data-panel="selector"`
// if it has "toggle" class, it toggles the panel
@@ -111,11 +42,20 @@ function onHidePanelClick(el: HTMLElement, e: MouseEvent) {
}
export type ElementWithAssignableProperties = {
nodeName: string;
getAttribute: (name: string) => string | null;
setAttribute: (name: string, value: string) => void;
} & Record<string, any>;
export function assignElementProperty(el: ElementWithAssignableProperties, kebabName: string, val: string) {
if (el.nodeName === 'FORM') {
// HINT: GOLANG-HTML-TEMPLATE-URL-ESCAPING: a special case for Golang HTML template escaping.
// Golang HTML template only handles some "known" attribute names as URL (e.g.: when the name is "action" or contains "url")
// To prevent template developers from making mistakes like `data-modal-form.action="?k={{ValueWithSpecialChars}}" (no escaping),
// here we use `data-modal-form.url="?k={{ValueWithSpecialChars}}", then the value gets correctly escaped by Golang HTML template.
if (kebabName === 'action') throw new Error(`don't assign element property "action" by value, use "data-modal-form.url" instead`);
if (kebabName === 'url') kebabName = 'action';
}
const camelizedName = camelize(kebabName);
const old = el[camelizedName];
if (typeof old === 'boolean') {
@@ -128,7 +68,7 @@ export function assignElementProperty(el: ElementWithAssignableProperties, kebab
// "form" has an edge case: its "<input name=action>" element overwrites the "action" property, we can only set attribute
el.setAttribute(kebabName, val);
} else {
// in the future, we could introduce a better typing system like `data-modal-form.action:string="..."`
// in the future, maybe we could introduce a better typing system if it is really needed
throw new Error(`cannot assign element property "${camelizedName}" by value "${val}"`);
}
}
@@ -140,7 +80,11 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) {
// * Then, try to query '[name=target]'
// * Then, try to query '.target'
// * Then, try to query 'target' as HTML tag
// If there is a ".{prop-name}" part like "data-modal-form.action", the "form" element's "action" property will be set, the "prop-name" will be camel-cased to "propName".
// If there's a ".{prop-name}" part like "data-modal-input.value", the "input" element's "value" property will be set,
// the "prop-name" will be camel-cased to "propName" (e.g.: "data-modal-input.read-only" for "readOnly" property).
//
// HINT: GOLANG-HTML-TEMPLATE-URL-ESCAPING: Form element's "action" property must be set by "data-modal-form.url"
// to make the template variables get correctly escaped in the URL.
e.preventDefault();
const modalSelector = el.getAttribute('data-modal')!;
const elModal = document.querySelector(modalSelector);
@@ -156,10 +100,10 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) {
const [attrTargetName, attrTargetProp] = attrTargetCombo.split('.');
// try to find target by: "#target" -> "[name=target]" -> ".target" -> "<target> tag", and then try the modal itself
const attrTarget = elModal.querySelector(`#${attrTargetName}`) ||
elModal.querySelector(`[name=${attrTargetName}]`) ||
elModal.querySelector(`[name=${CSS.escape(attrTargetName)}]`) ||
elModal.querySelector(`.${attrTargetName}`) ||
elModal.querySelector(`${attrTargetName}`) ||
(elModal.matches(`${attrTargetName}`) || elModal.matches(`#${attrTargetName}`) || elModal.matches(`.${attrTargetName}`) ? elModal : null);
elModal.querySelector(attrTargetName) ||
(elModal.matches(attrTargetName) || elModal.matches(`#${attrTargetName}`) || elModal.matches(`.${attrTargetName}`) ? elModal : null);
if (!attrTarget) {
if (!window.config.runModeIsProd) throw new Error(`attr target "${attrTargetCombo}" not found for modal`);
continue;
@@ -174,7 +118,7 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) {
}
}
fomanticQuery(elModal).modal('show');
showFomanticModal(elModal);
}
export function initGlobalButtons(): void {
@@ -0,0 +1,59 @@
import {execPseudoSelectorCommands, handleFetchActionSuccessJson} from './common-fetch-action.ts';
test('execPseudoSelectorCommands', () => {
window.document.body.innerHTML = `
<div id="d1">
<ul id="u1">
<li class="x"></li>
</ul>
<ul id="u2">
<li class="x"></li>
</ul>
</div>
<div id="d2">
<ul id="u3">
<li class="x"></li>
</ul>
</div>`;
let ret = execPseudoSelectorCommands(document.querySelector('#u1')!, '');
expect(ret.targets).toEqual([document.querySelector('#u1')]);
ret = execPseudoSelectorCommands(document.querySelector('#u1')!, '$this');
expect(ret.targets).toEqual([document.querySelector('#u1')]);
expect(ret.cmdInnerHTML).toBeFalsy();
expect(ret.cmdMorph).toBeFalsy();
ret = execPseudoSelectorCommands(document.querySelector('#u1')!, '$body $morph $innerHTML');
expect(ret.targets).toEqual([document.body]);
expect(ret.cmdInnerHTML).toBeTruthy();
expect(ret.cmdMorph).toBeTruthy();
ret = execPseudoSelectorCommands(document.querySelector('#u1')!, '$body .x');
expect(ret.targets.length).toEqual(3);
expect(ret.targets).toEqual(Array.from(document.querySelectorAll('.x')));
ret = execPseudoSelectorCommands(document.querySelector('#u1 .x')!, '$closest(div) .x');
expect(ret.targets.length).toEqual(2);
expect(ret.targets).toEqual(Array.from(document.querySelectorAll('#d1 .x')));
});
test('handleFetchActionSuccessJson', async () => {
const spyAssign = vi.spyOn(window.location, 'assign').mockImplementation(() => {});
const spyReload = vi.spyOn(window.location, 'reload').mockImplementation(() => {});
await handleFetchActionSuccessJson(document.body, {redirect: '/'});
expect(spyAssign).toHaveBeenCalledTimes(1);
expect(spyReload).toHaveBeenCalledTimes(0);
vi.resetAllMocks();
await handleFetchActionSuccessJson(document.body, {redirect: ''});
expect(spyAssign).toHaveBeenCalledTimes(0);
expect(spyReload).toHaveBeenCalledTimes(1);
vi.resetAllMocks();
await handleFetchActionSuccessJson(document.body, {});
expect(spyAssign).toHaveBeenCalledTimes(0);
expect(spyReload).toHaveBeenCalledTimes(1);
vi.resetAllMocks();
});
@@ -1,89 +1,161 @@
import {request} from '../modules/fetch.ts';
import {GET, request} from '../modules/fetch.ts';
import {hideToastsAll, showErrorToast} from '../modules/toast.ts';
import {addDelegatedEventListener, createElementFromHTML, submitEventSubmitter} from '../utils/dom.ts';
import {addDelegatedEventListener, createElementFromHTML} from '../utils/dom.ts';
import {errorMessage, errorName} from '../modules/errors.ts';
import {confirmModal, createConfirmModal} from './comp/ConfirmModal.ts';
import type {RequestOpts} from '../types.ts';
import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
import {Idiomorph} from 'idiomorph';
import {parseDom} from '../utils.ts';
import {html} from '../utils/html.ts';
const {appSubUrl} = window.config;
const {appSubUrl, runModeIsProd} = window.config;
type FetchActionOpts = {
method: string;
url: string;
headers?: HeadersInit;
body?: FormData;
formSubmitter?: HTMLElement | null;
// pseudo selectors/commands to update the current page with the response text when the response is text (html)
// e.g.: "$this", "$innerHTML", "$closest(tr) td .the-class", "$body #the-id"
successSync: string;
// the loading indicator element selector, it uses the same syntax as "data-fetch-sync" to find the element(s)
// empty means no loading indicator, "$this" means the element itself
loadingIndicator: string;
};
// fetchActionDoRedirect does real redirection to bypass the browser's limitations of "location"
// more details are in the backend's fetch-redirect handler
function fetchActionDoRedirect(redirect: string) {
const form = document.createElement('form');
const input = document.createElement('input');
form.method = 'post';
form.action = `${appSubUrl}/-/fetch-redirect`;
input.type = 'hidden';
input.name = 'redirect';
input.value = redirect;
form.append(input);
// In production, if the link can be directly navigated by browser, we just do normal redirection, which is faster.
// Otherwise, need to use backend to do redirection:
// * Also do so in development, to make sure the redirection logic is always tested by real users
const needBackendHelp = redirect.includes('#');
if (runModeIsProd && !needBackendHelp) {
window.location.assign(redirect);
return;
}
// use backend to do redirection, which can bypass the browser's limitations of "location"
const form = createElementFromHTML<HTMLFormElement>(html`<form method="post"></form>`);
form.action = `${appSubUrl}/-/fetch-redirect?redirect=${encodeURIComponent(redirect)}`;
document.body.append(form);
form.submit();
}
async function fetchActionDoRequest(actionElem: HTMLElement, url: string, opt: RequestOpts) {
const showErrorForResponse = (code: number, message: string) => {
showErrorToast(`Error ${code || 'request'}: ${message}`);
};
let respStatus = 0;
let respText = '';
try {
hideToastsAll();
const resp = await request(url, opt);
respStatus = resp.status;
respText = await resp.text();
const respJson = JSON.parse(respText);
if (respStatus === 200) {
let {redirect} = respJson;
redirect = redirect || actionElem.getAttribute('data-redirect');
ignoreAreYouSure(actionElem); // ignore the areYouSure check before reloading
if (redirect) {
fetchActionDoRedirect(redirect);
function toggleLoadingIndicator(el: HTMLElement, opt: FetchActionOpts, isLoading: boolean) {
const loadingIndicatorElems = opt.loadingIndicator ? execPseudoSelectorCommands(el, opt.loadingIndicator).targets : [];
for (const indicatorEl of loadingIndicatorElems) {
if (isLoading) {
// for button or input element, we can directly disable it, it looks better than adding a loading spinner
if ('disabled' in indicatorEl) {
indicatorEl.disabled = true;
} else {
window.location.reload();
indicatorEl.classList.add('is-loading');
if (indicatorEl.clientHeight < 50) indicatorEl.classList.add('loading-icon-2px');
}
return;
}
if (respStatus >= 400 && respStatus < 500 && respJson?.errorMessage) {
// the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error"
// but at the moment, as a new approach, we only use "errorMessage" here, backend can use JSONError() to respond.
showErrorToast(respJson.errorMessage, {useHtmlBody: respJson.renderFormat === 'html'});
} else {
showErrorForResponse(respStatus, respText);
}
} catch (e) {
if (e.name === 'SyntaxError') {
showErrorForResponse(respStatus, (respText || '').substring(0, 100));
} else if (e.name !== 'AbortError') {
console.error('fetchActionDoRequest error', e);
showErrorForResponse(respStatus, `${e}`);
if ('disabled' in indicatorEl) {
indicatorEl.disabled = false;
} else {
indicatorEl.classList.remove('is-loading', 'loading-icon-2px');
}
}
}
actionElem.classList.remove('is-loading', 'loading-icon-2px');
}
async function onFormFetchActionSubmit(formEl: HTMLFormElement, e: SubmitEvent) {
e.preventDefault();
await submitFormFetchAction(formEl, {formSubmitter: submitEventSubmitter(e)});
export async function handleFetchActionSuccessJson(el: HTMLElement, respJson: any) {
ignoreAreYouSure(el); // ignore the areYouSure check before reloading
const redirect = respJson?.redirect;
if (typeof redirect === 'string' && redirect) {
fetchActionDoRedirect(redirect);
} else {
// reserved behavior, in the future, there can be more fields to introduce more behaviors
window.location.reload();
}
}
async function handleFetchActionSuccess(el: HTMLElement, opt: FetchActionOpts, resp: Response) {
const isRespJson = resp.headers.get('content-type')?.includes('application/json');
const respText = await resp.text();
const respJson = isRespJson ? JSON.parse(respText) : null;
if (isRespJson) {
await handleFetchActionSuccessJson(el, respJson);
} else if (opt.successSync) {
await handleFetchActionSuccessSync(el, opt.successSync, respText);
} else {
showErrorToast(`Unsupported fetch action response, expected JSON but got: ${respText.substring(0, 200)}`);
}
}
async function handleFetchActionError(resp: Response) {
const isRespJson = resp.headers.get('content-type')?.includes('application/json');
const respText = await resp.text();
const respJson = isRespJson ? JSON.parse(respText) : null;
if (respJson?.errorMessage) {
// the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error"
// but at the moment, as a new approach, we only use "errorMessage" here, backend can use JSONError() to respond.
showErrorToast(respJson.errorMessage, {useHtmlBody: respJson.renderFormat === 'html'});
} else {
showErrorToast(`Error ${resp.status} ${resp.statusText}. Response: ${respText.substring(0, 200)}`);
}
}
function buildFetchActionUrl(el: HTMLElement, opt: FetchActionOpts) {
let url = opt.url;
if ('name' in el && 'value' in el) {
// ref: https://htmx.org/attributes/hx-get/
// If the element with the hx-get attribute also has a value, this will be included as a parameter
const name = (el as HTMLInputElement).name;
const val = (el as HTMLInputElement).value;
const u = new URL(url, window.location.href);
if (name && !u.searchParams.has(name)) {
u.searchParams.set(name, val);
url = u.href;
}
}
return url;
}
async function performActionRequest(el: HTMLElement, opt: FetchActionOpts) {
const attrIsLoading = 'data-fetch-is-loading';
if (el.getAttribute(attrIsLoading)) return;
if (!await confirmFetchAction(opt.formSubmitter ?? el)) return;
el.setAttribute(attrIsLoading, 'true');
toggleLoadingIndicator(el, opt, true);
try {
const url = buildFetchActionUrl(el, opt);
const headers = new Headers(opt.headers);
headers.set('X-Gitea-Fetch-Action', '1');
const resp = await request(url, {method: opt.method, body: opt.body, headers});
if (resp.ok) {
await handleFetchActionSuccess(el, opt, resp);
return;
}
await handleFetchActionError(resp);
} catch (err) {
if (errorName(err) !== 'AbortError') {
console.error(`Fetch action request error:`, err);
showErrorToast(`Error: ${errorMessage(err)}`);
}
} finally {
toggleLoadingIndicator(el, opt, false);
el.removeAttribute(attrIsLoading);
}
}
type SubmitFormFetchActionOpts = {
formSubmitter?: HTMLElement;
formSubmitter?: HTMLElement | null;
formData?: FormData;
};
export async function submitFormFetchAction(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}) {
if (formEl.classList.contains('is-loading')) return;
formEl.classList.add('is-loading');
if (formEl.clientHeight < 50) {
formEl.classList.add('loading-icon-2px');
}
const formMethod = formEl.getAttribute('method') || 'get';
function prepareFormFetchActionOpts(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}): FetchActionOpts {
const formMethodUpper = formEl.getAttribute('method')?.toUpperCase() || 'GET';
const formActionUrl = formEl.getAttribute('action') || window.location.href;
const formData = opts.formData ?? new FormData(formEl);
const [submitterName, submitterValue] = [opts.formSubmitter?.getAttribute('name'), opts.formSubmitter?.getAttribute('value')];
@@ -92,11 +164,8 @@ export async function submitFormFetchAction(formEl: HTMLFormElement, opts: Submi
}
let reqUrl = formActionUrl;
const reqOpt = {
method: formMethod.toUpperCase(),
body: null as FormData | null,
};
if (formMethod.toLowerCase() === 'get') {
let reqBody: FormData | undefined;
if (formMethodUpper === 'GET') {
const params = new URLSearchParams();
for (const [key, value] of formData) {
params.append(key, value as string);
@@ -107,25 +176,24 @@ export async function submitFormFetchAction(formEl: HTMLFormElement, opts: Submi
}
reqUrl += `?${params.toString()}`;
} else {
reqOpt.body = formData;
reqBody = formData;
}
await fetchActionDoRequest(formEl, reqUrl, reqOpt);
return {
method: formMethodUpper,
url: reqUrl,
body: reqBody,
formSubmitter: opts.formSubmitter,
loadingIndicator: '$this', // for form submit, by default, the loading indicator is the whole form
successSync: formEl.getAttribute('data-fetch-sync') ?? '', // by default, no fetch sync for form submit
};
}
async function onLinkActionClick(el: HTMLElement, e: Event) {
// A "link-action" can post AJAX request to its "data-url"
// Then the browser is redirected to: the "redirect" in response, or "data-redirect" attribute, or current URL by reloading.
// If the "link-action" has "data-modal-confirm" attribute, a "confirm modal dialog" will be shown before taking action.
// Attribute "data-modal-confirm" can be a modal element by "#the-modal-id", or a string content for the modal dialog.
e.preventDefault();
const url = el.getAttribute('data-url')!;
const doRequest = async () => {
if ('disabled' in el) el.disabled = true; // el could be A or BUTTON, but "A" doesn't have the "disabled" attribute
await fetchActionDoRequest(el, url, {method: el.getAttribute('data-link-action-method') || 'POST'});
if ('disabled' in el) el.disabled = false;
};
export async function submitFormFetchAction(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}) {
hideToastsAll();
await performActionRequest(formEl, prepareFormFetchActionOpts(formEl, opts));
}
async function confirmFetchAction(el: HTMLElement) {
let elModal: HTMLElement | null = null;
const dataModalConfirm = el.getAttribute('data-modal-confirm') || '';
if (dataModalConfirm.startsWith('#')) {
@@ -147,18 +215,204 @@ async function onLinkActionClick(el: HTMLElement, e: Event) {
});
}
}
if (!elModal) return true;
return await confirmModal(elModal);
}
if (!elModal) {
await doRequest();
async function performLinkFetchAction(el: HTMLElement) {
hideToastsAll();
await performActionRequest(el, {
method: el.getAttribute('data-fetch-method') || 'POST', // by default, the method is POST for link-action
url: el.getAttribute('data-url')!,
loadingIndicator: el.getAttribute('data-fetch-indicator') ?? '$this', // by default, the link-action itself is the loading indicator
successSync: el.getAttribute('data-fetch-sync') ?? '', // by default, no fetch sync for link-action
});
}
type FetchActionTriggerType = 'click' | 'change' | 'every' | 'load' | 'fetch-reload';
export async function performFetchActionTrigger(el: HTMLElement, triggerType: FetchActionTriggerType) {
const isUserInitiated = triggerType === 'click' || triggerType === 'change';
// for user initiated action, by default, the loading indicator is the element itself, otherwise no loading indicator
const defaultLoadingIndicator = isUserInitiated ? '$this' : '';
if (isUserInitiated) hideToastsAll();
await performActionRequest(el, {
method: el.getAttribute('data-fetch-method') || 'GET', // by default, the method is GET for fetch trigger action
url: el.getAttribute('data-fetch-url')!,
loadingIndicator: el.getAttribute('data-fetch-indicator') ?? defaultLoadingIndicator,
successSync: el.getAttribute('data-fetch-sync') ?? '$this', // by default, the response will replace the current element
});
}
type PseudoSelectorCommandResult = {
targets: Element[];
cmdInnerHTML: boolean;
cmdMorph: boolean;
};
export function execPseudoSelectorCommands(el: Element, fullCommand: string): PseudoSelectorCommandResult {
const cmds = fullCommand.split(' ').map((s) => s.trim()).filter(Boolean) || [];
let targets = [el], cmdInnerHTML = false, cmdMorph = false;
for (const cmd of cmds) {
if (cmd === '$this') {
targets = [el];
} else if (cmd === '$body') {
targets = [document.body];
} else if (cmd === '$innerHTML') {
cmdInnerHTML = true;
} else if (cmd === '$morph') {
cmdMorph = true;
} else if (cmd.startsWith('$closest(') && cmd.endsWith(')')) {
const selector = cmd.substring('$closest('.length, cmd.length - 1);
const newTargets: Element[] = [];
for (const target of targets) {
const closest = target.closest(selector);
if (closest) newTargets.push(closest);
}
targets = newTargets;
} else {
const newTargets: Element[] = [];
for (const target of targets) {
newTargets.push(...target.querySelectorAll(cmd));
}
targets = newTargets;
}
}
return {targets, cmdInnerHTML, cmdMorph};
}
async function handleFetchActionSuccessSync(el: Element, successSync: string, respText: string) {
const res = execPseudoSelectorCommands(el, successSync);
if (!res.targets.length) throw new Error(`Fetch-sync command "${successSync}" did not find any target element to update`);
if (res.targets.length > 1) throw new Error(`Fetch-sync command "${successSync}" found multiple target elements, which is not supported`);
const target = res.targets[0];
if (res.cmdMorph) {
Idiomorph.morph(target, respText, {morphStyle: res.cmdInnerHTML ? 'innerHTML' : 'outerHTML'});
} else if (res.cmdInnerHTML) {
target.innerHTML = respText;
} else {
target.outerHTML = respText;
}
await fetchActionReloadOutdatedElements();
}
async function fetchActionReloadOutdatedElements() {
const outdatedElems: HTMLElement[] = [];
for (const outdated of document.querySelectorAll<HTMLElement>('[data-fetch-trigger~="fetch-reload"]')) {
if (!outdated.id) throw new Error(`Elements with "fetch-reload" trigger must have an id to be reloaded after fetch sync: ${outdated.outerHTML.substring(0, 100)}`);
outdatedElems.push(outdated);
}
if (!outdatedElems.length) return;
const resp = await GET(window.location.href);
if (!resp.ok) {
showErrorToast(`Failed to reload page content after fetch action: ${resp.status} ${resp.statusText}`);
return;
}
const newPageHtml = await resp.text();
const newPageDom = parseDom(newPageHtml, 'text/html');
for (const oldEl of outdatedElems) {
// eslint-disable-next-line unicorn/prefer-query-selector
const newEl = newPageDom.getElementById(oldEl.id);
if (newEl) {
oldEl.replaceWith(newEl);
} else {
oldEl.remove();
}
}
}
if (await confirmModal(elModal)) {
await doRequest();
function initFetchActionTriggerEvery(el: HTMLElement, trigger: string) {
const interval = trigger.substring('every '.length);
const match = /^(\d+)(ms|s)$/.exec(interval);
if (!match) throw new Error(`Invalid interval format: ${interval}`);
const num = parseInt(match[1], 10), unit = match[2];
const intervalMs = unit === 's' ? num * 1000 : num;
const fn = async () => {
try {
await performFetchActionTrigger(el, 'every');
} finally {
// only continue if the element is still in the document
if (document.contains(el)) {
setTimeout(fn, intervalMs);
}
}
};
setTimeout(fn, intervalMs);
}
function initFetchActionTrigger(el: HTMLElement) {
const trigger = el.getAttribute('data-fetch-trigger');
// this trigger is managed internally, only triggered after fetch sync success, not triggered by event or timer
if (trigger === 'fetch-reload') return;
if (trigger === 'load') {
performFetchActionTrigger(el, trigger);
} else if (trigger === 'change') {
el.addEventListener('change', () => performFetchActionTrigger(el, trigger));
} else if (trigger?.startsWith('every ')) {
initFetchActionTriggerEvery(el, trigger);
} else if (!trigger || trigger === 'click') {
el.addEventListener('click', (e) => {
e.preventDefault();
performFetchActionTrigger(el, 'click');
});
} else {
throw new Error(`Unsupported fetch trigger: ${trigger}`);
}
}
export function initGlobalFetchAction() {
addDelegatedEventListener(document, 'submit', '.form-fetch-action', onFormFetchActionSubmit);
addDelegatedEventListener(document, 'click', '.link-action', onLinkActionClick);
// The "fetch-action" framework is a general approach for elements to trigger fetch requests:
// show confirm dialog (if any), show loading indicators, send fetch request, and redirect or update UI after success.
//
// If you need more fine-grained control more details, sometimes it's clearer to write the logic in JavaScript, instead of using this generic framework.
//
// Attributes:
//
// * data-fetch-method: the HTTP method to use
// * default to "GET" for "data-fetch-url" actions, "POST" for "link-action" elements
// * this attribute is ignored, the method will be determined by the form's "method" attribute, and default to "GET"
//
// * data-fetch-url: the URL for the request
//
// * data-fetch-trigger: the event to trigger the fetch action, can be:
// * "click", "change" (user-initiated events)
// * "load" (triggered on page load)
// * "every 5s" (also support "ms" unit)
// * "fetch-reload" (only triggered by fetch sync success to reload outdated content)
//
// * data-fetch-indicator: the loading indicator element selector, it uses the same syntax as "data-fetch-sync" to find the element(s)
//
// * data-fetch-sync: when the response is text (html), the pseudo selectors/commands defined in "data-fetch-sync"
// will be used to update the content in the current page. It only supports some simple syntaxes that we need.
// "$" prefix means it is our private command (for special logic), the selectors are run one by one from current element.
// * "$this": replace the current element with the response
// * "$innerHTML": replace innerHTML of the current element with the response, instead of replacing the whole element (outerHTML)
// * "$morph": use morph algorithm to update the target element
// * "$body #the-id .the-class": query the selector one by one from body
// * "$closest(tr) td": pseudo command can help to find the target element in a more flexible way
//
// * data-modal-confirm: a "confirm modal dialog" will be shown before taking action.
// * it can be a string for the content of the modal dialog
// * it has "-header" and "-content" variants to set the header and content of the "confirm modal"
// * it can refer an existing modal element by "#the-modal-id"
addDelegatedEventListener<HTMLFormElement, SubmitEvent>(document, 'submit', '.form-fetch-action', async (el, e) => {
// "fetch-action" will use the form's data to send the request
e.preventDefault();
await submitFormFetchAction(el, {formSubmitter: e.submitter});
});
addDelegatedEventListener(document, 'click', '.link-action', async (el, e) => {
// `<a class="link-action" data-url="...">` is a shorthand for
// `<a data-fetch-trigger="click" data-fetch-method="post" data-fetch-url="..." data-fetch-indicator="$this">`
e.preventDefault();
await performLinkFetchAction(el);
});
registerGlobalSelectorFunc('[data-fetch-url]', initFetchActionTrigger);
}
@@ -16,17 +16,13 @@ export function initGlobalEnterQuickSubmit() {
document.addEventListener('keydown', (e) => {
if (e.isComposing) return;
if (e.key !== 'Enter') return;
const el = e.target as HTMLElement;
const hasCtrlOrMeta = ((e.ctrlKey || e.metaKey) && !e.altKey);
if (hasCtrlOrMeta && (e.target as HTMLElement).matches('textarea')) {
if (handleGlobalEnterQuickSubmit(e.target as HTMLElement)) {
e.preventDefault();
}
} else if ((e.target as HTMLElement).matches('input') && !(e.target as HTMLElement).closest('form')) {
// input in a normal form could handle Enter key by default, so we only handle the input outside a form
// eslint-disable-next-line unicorn/no-lonely-if
if (handleGlobalEnterQuickSubmit(e.target as HTMLElement)) {
e.preventDefault();
}
const isCtrlEnterInTextarea = hasCtrlOrMeta && el.matches('textarea');
// an input in a normal form could handle Enter key by default, so we only handle the input outside a form
const isEnterInBareInput = el.matches('input') && !el.closest('form');
if ((isCtrlEnterInTextarea || isEnterInBareInput) && handleGlobalEnterQuickSubmit(el)) {
e.preventDefault();
}
});
}
@@ -35,7 +35,7 @@ export function initCommonIssueListQuickGoto() {
const repoLink = elGotoButton.getAttribute('data-repo-link') || '';
elGotoButton.addEventListener('click', () => {
window.location.href = elGotoButton.getAttribute('data-issue-goto-link')!;
window.location.assign(elGotoButton.getAttribute('data-issue-goto-link')!);
});
const onInput = async () => {
@@ -1,10 +1,12 @@
import {GET, POST} from '../modules/fetch.ts';
import {showGlobalErrorMessage} from '../modules/errors.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {initTabSwitcher} from '../modules/fomantic/tab.ts';
import {addDelegatedEventListener, queryElems} from '../utils/dom.ts';
import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts';
import {initAvatarUploaderWithCropper} from './comp/Cropper.ts';
import {initCompSearchRepoBox} from './comp/SearchRepoBox.ts';
import {initScopedWorkflowRequired} from './comp/ScopedWorkflows.ts';
const {appUrl, appSubUrl} = window.config;
@@ -100,9 +102,10 @@ export function initGlobalDropdown() {
}
export function initGlobalComponent() {
fomanticQuery('.ui.menu.tabular:not(.custom) .item').tab();
registerGlobalInitFunc('initTabSwitcher', initTabSwitcher);
registerGlobalInitFunc('initAvatarUploader', initAvatarUploaderWithCropper);
registerGlobalInitFunc('initSearchRepoBox', initCompSearchRepoBox);
registerGlobalInitFunc('initScopedWorkflowRequired', initScopedWorkflowRequired);
}
// for performance considerations, it only uses performant syntax
@@ -23,7 +23,7 @@ import {
} from './EditorMarkdown.ts';
import {DropzoneCustomEventReloadFiles, initDropzone} from '../dropzone.ts';
import {createTippy} from '../../modules/tippy.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
import {initTabSwitcher} from '../../modules/fomantic/tab.ts';
import type EasyMDE from 'easymde';
import {localUserSettings} from '../../modules/user-settings.ts';
@@ -71,26 +71,26 @@ export class ComboMarkdownEditor {
options: ComboMarkdownEditorOptions;
tabEditor: HTMLElement;
tabPreviewer: HTMLElement;
tabEditor?: HTMLElement;
tabPreviewer?: HTMLElement;
supportEasyMDE: boolean;
supportEasyMDE!: boolean;
easyMDE: any;
easyMDEToolbarActions: any;
easyMDEToolbarDefault: any;
textarea: ComboMarkdownEditorTextarea;
textareaMarkdownToolbar: HTMLElement;
textarea!: ComboMarkdownEditorTextarea;
textareaMarkdownToolbar!: HTMLElement;
textareaAutosize: any;
buttonMonospace: HTMLButtonElement;
buttonMonospace!: HTMLButtonElement;
dropzone: HTMLElement | null;
dropzone: HTMLElement | null = null;
attachedDropzoneInst: any;
previewMode: string;
previewUrl: string;
previewContext: string;
previewMode!: string;
previewUrl!: string;
previewContext!: string;
constructor(container: ComboMarkdownEditorContainer, options:ComboMarkdownEditorOptions = {}) {
if (container._giteaComboMarkdownEditor) throw new Error('ComboMarkdownEditor already initialized');
@@ -204,22 +204,21 @@ export class ComboMarkdownEditor {
}
setupTab() {
const tabs = this.container.querySelectorAll<HTMLElement>('.tabular.menu > .item');
if (!tabs.length) return;
const elTabular = this.container.querySelector('.ui.tabular');
if (!elTabular) return;
this.tabEditor = this.container.querySelector('[data-tab-for="markdown-writer"]')!;
this.tabPreviewer = this.container.querySelector('[data-tab-for="markdown-previewer"]')!;
const panelEditor = this.container.querySelector('.ui.tab[data-tab-panel="markdown-writer"]')!;
const panelPreviewer = this.container.querySelector('.ui.tab[data-tab-panel="markdown-previewer"]')!;
// Fomantic Tab requires the "data-tab" to be globally unique.
// So here it uses our defined "data-tab-for" and "data-tab-panel" to generate the "data-tab" attribute for Fomantic.
const tabIdSuffix = generateElemId();
const tabsArr = Array.from(tabs);
this.tabEditor = tabsArr.find((tab) => tab.getAttribute('data-tab-for') === 'markdown-writer')!;
this.tabPreviewer = tabsArr.find((tab) => tab.getAttribute('data-tab-for') === 'markdown-previewer')!;
this.tabEditor.setAttribute('data-tab', `markdown-writer-${tabIdSuffix}`);
this.tabPreviewer.setAttribute('data-tab', `markdown-previewer-${tabIdSuffix}`);
const panelEditor = this.container.querySelector('.ui.tab[data-tab-panel="markdown-writer"]')!;
const panelPreviewer = this.container.querySelector('.ui.tab[data-tab-panel="markdown-previewer"]')!;
panelEditor.setAttribute('data-tab', `markdown-writer-${tabIdSuffix}`);
panelPreviewer.setAttribute('data-tab', `markdown-previewer-${tabIdSuffix}`);
initTabSwitcher(elTabular);
this.tabEditor.addEventListener('click', () => {
requestAnimationFrame(() => {
@@ -227,8 +226,6 @@ export class ComboMarkdownEditor {
});
});
fomanticQuery(tabs).tab();
this.tabPreviewer.addEventListener('click', async () => {
const formData = new FormData();
formData.append('mode', this.previewMode);
@@ -291,7 +288,7 @@ export class ComboMarkdownEditor {
}
switchTabToEditor() {
this.tabEditor.click();
this.tabEditor!.click(); // when this function is called, the tab must exist
}
prepareEasyMDEToolbarActions() {
@@ -1,7 +1,7 @@
import {svg} from '../../svg.ts';
import {html, htmlRaw} from '../../utils/html.ts';
import {createElementFromHTML} from '../../utils/dom.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
import {showFomanticModal} from '../../modules/fomantic/modal.ts';
import {hideToastsAll} from '../../modules/toast.ts';
const {i18n} = window.config;
@@ -32,15 +32,14 @@ export function confirmModal(modal: HTMLElement | ConfirmModalOptions): Promise<
// it's fine to do so because the modal is triggered by user's explicit action, so the user should already have read the toast messages
hideToastsAll();
return new Promise((resolve) => {
const $modal = fomanticQuery(modal);
$modal.modal({
showFomanticModal(modal, {
onApprove() {
resolve(true);
},
onHidden() {
$modal.remove();
modal.remove();
resolve(false);
},
}).modal('show');
});
});
}
@@ -8,7 +8,7 @@ import {
import {subscribe} from '@github/paste-markdown';
import type CodeMirror from 'codemirror';
import type EasyMDE from 'easymde';
import type {DropzoneFile} from 'dropzone';
import type Dropzone from '@deltablot/dropzone';
let uploadIdCounter = 0;
@@ -31,7 +31,7 @@ function uploadFile(dropzoneEl: HTMLElement, file: File) {
};
dropzoneInst.on(DropzoneCustomEventUploadDone, onUploadDone);
// FIXME: this is not entirely correct because `file` does not satisfy DropzoneFile (we have abused the Dropzone for long time)
dropzoneInst.addFile(file as DropzoneFile);
dropzoneInst.addFile(file as Dropzone.DropzoneFile);
});
}
@@ -1,5 +1,5 @@
import {toggleElem} from '../../utils/dom.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
import {showFomanticModal} from '../../modules/fomantic/modal.ts';
import {submitFormFetchAction} from '../common-fetch-action.ts';
function nameHasScope(name: string): boolean {
@@ -65,7 +65,7 @@ export function initCompLabelEdit(pageSelector: string) {
form.action = isEdit ? `${curPageLink}/edit` : `${curPageLink}/new`;
toggleElem(elIsArchivedField, isEdit);
syncModalUi();
fomanticQuery(elModal).modal({
showFomanticModal(elModal, {
onApprove() {
if (!form.checkValidity()) {
form.reportValidity();
@@ -74,7 +74,7 @@ export function initCompLabelEdit(pageSelector: string) {
submitFormFetchAction(form);
return false;
},
}).modal('show');
});
};
elModal.addEventListener('input', () => syncModalUi());
@@ -0,0 +1,104 @@
import {initScopedWorkflowRequired} from './ScopedWorkflows.ts';
function setupForm(required = false) {
window.document.body.innerHTML = `
<form>
<table><tbody>
<tr>
<td>ci.yaml<input type="hidden" name="workflow_ids" value="ci.yaml"></td>
<td><div class="ui checkbox"><input type="checkbox" class="js-scoped-required-toggle" ${required ? 'checked' : ''}><label></label></div></td>
<td>
<textarea class="js-scoped-required-patterns${required ? '' : ' tw-hidden'}" data-default-pattern="org/src: CI / *">${required ? 'org/src: CI / *' : ''}</textarea>
<span class="js-scoped-required-hint${required ? ' tw-hidden' : ''}">hint</span>
</td>
</tr>
</tbody></table>
</form>`;
const form = document.querySelector('form')!;
const checkbox = form.querySelector<HTMLInputElement>('.js-scoped-required-toggle')!;
const textarea = form.querySelector<HTMLTextAreaElement>('.js-scoped-required-patterns')!;
const hint = form.querySelector<HTMLElement>('.js-scoped-required-hint')!;
return {form, checkbox, textarea, hint};
}
test('required toggle shows/prefills the patterns textarea (and hides the hint) and reverses otherwise, keeping the value', () => {
const {form, checkbox, textarea, hint} = setupForm();
initScopedWorkflowRequired(form);
expect(textarea.classList.contains('tw-hidden')).toBe(true); // initial: not required -> textarea hidden
expect(hint.classList.contains('tw-hidden')).toBe(false); // ... and the hint shown in its place
// check -> textarea shown and prefilled; hint hidden
checkbox.checked = true;
checkbox.dispatchEvent(new Event('change', {bubbles: true}));
expect(textarea.classList.contains('tw-hidden')).toBe(false);
expect(hint.classList.contains('tw-hidden')).toBe(true);
expect(textarea.value).toBe('org/src: CI / *');
// admin edits the pattern
textarea.value = 'org/src: CI / build (pull_request)';
// uncheck -> textarea hidden (value kept, still submits as history), hint shown again
checkbox.checked = false;
checkbox.dispatchEvent(new Event('change', {bubbles: true}));
expect(textarea.classList.contains('tw-hidden')).toBe(true);
expect(hint.classList.contains('tw-hidden')).toBe(false);
expect(textarea.value).toBe('org/src: CI / build (pull_request)');
// re-check -> shown again with the same value (not re-prefilled to the default)
checkbox.checked = true;
checkbox.dispatchEvent(new Event('change', {bubbles: true}));
expect(textarea.classList.contains('tw-hidden')).toBe(false);
expect(textarea.value).toBe('org/src: CI / build (pull_request)');
});
test('an already-required row stays shown with its stored patterns (not re-prefilled)', () => {
const {form, textarea} = setupForm(true);
textarea.value = 'org/src: custom / build (push)'; // a stored, admin-edited pattern
initScopedWorkflowRequired(form);
expect(textarea.classList.contains('tw-hidden')).toBe(false);
expect(textarea.value).toBe('org/src: custom / build (push)');
});
function setupFormWithContexts(patterns: string) {
window.document.body.innerHTML = `
<form>
<table><tbody>
<tr>
<td>ci.yaml<input type="hidden" name="workflow_ids" value="ci.yaml"></td>
<td><div class="ui checkbox"><input type="checkbox" class="js-scoped-required-toggle" checked><label></label></div></td>
<td>
<textarea class="js-scoped-required-patterns" data-default-pattern="org/src: CI / *">${patterns}</textarea>
<span class="js-scoped-required-hint tw-hidden">hint</span>
<table class="js-scoped-required-contexts"><tbody>
<tr><td><span class="js-scoped-context" data-context="org/src: CI / lint (push)"></span><span class="js-scoped-context-matched tw-hidden">Matched</span></td></tr>
<tr><td><span class="js-scoped-context" data-context="org/src: CI / build (push)"></span><span class="js-scoped-context-matched tw-hidden">Matched</span></td></tr>
</tbody></table>
</td>
</tr>
</tbody></table>
</form>`;
const form = document.querySelector('form')!;
const [lintMark, buildMark] = Array.from(form.querySelectorAll<HTMLElement>('.js-scoped-context-matched'));
return {form, lintMark, buildMark};
}
test('an exact pattern marks only the context it matches', () => {
const {form, lintMark, buildMark} = setupFormWithContexts('org/src: CI / lint (push)');
initScopedWorkflowRequired(form);
expect(lintMark.classList.contains('tw-hidden')).toBe(false); // matched
expect(buildMark.classList.contains('tw-hidden')).toBe(true); // not matched
});
test('a wildcard pattern marks every matching context', () => {
const {form, lintMark, buildMark} = setupFormWithContexts('org/src: CI / *');
initScopedWorkflowRequired(form);
expect(lintMark.classList.contains('tw-hidden')).toBe(false);
expect(buildMark.classList.contains('tw-hidden')).toBe(false);
});
test('a wildcard crossing "/" matches every matching context', () => {
const {form, lintMark, buildMark} = setupFormWithContexts('org/src: *');
initScopedWorkflowRequired(form);
expect(lintMark.classList.contains('tw-hidden')).toBe(false);
expect(buildMark.classList.contains('tw-hidden')).toBe(false);
});
@@ -0,0 +1,38 @@
import {addDelegatedEventListener, onInputDebounce, toggleElem} from '../../utils/dom.ts';
import {globMatch} from '../../utils/glob.ts';
// markRowMatchedContexts marks each expected status-check context whose row's textarea patterns match it.
function markRowMatchedContexts(row: HTMLElement) {
const textarea = row.querySelector<HTMLTextAreaElement>('.js-scoped-required-patterns')!;
const patterns = textarea.value.split(/[\r\n]+/).map((p) => p.trim()).filter(Boolean);
for (const ctxEl of row.querySelectorAll<HTMLElement>('.js-scoped-context')) {
const context = ctxEl.getAttribute('data-context')!;
const matched = patterns.some((p) => globMatch(context, p));
toggleElem(ctxEl.parentElement!.querySelector('.js-scoped-context-matched')!, matched);
}
}
// syncScopedRequiredRow shows a scoped workflow's status-check patterns textarea (and its expected-checks preview) only while the workflow is required.
function syncScopedRequiredRow(checkbox: HTMLInputElement) {
const row = checkbox.closest('tr')!;
const textarea = row.querySelector<HTMLTextAreaElement>('.js-scoped-required-patterns')!;
toggleElem(textarea, checkbox.checked);
toggleElem(row.querySelector('.js-scoped-required-hint')!, !checkbox.checked); // the "mark as required" hint shown in the textarea's place
const contexts = row.querySelector('.js-scoped-required-contexts'); // only rendered when the workflow has expected checks
if (contexts) toggleElem(contexts, checkbox.checked);
if (checkbox.checked && !textarea.value.trim()) {
textarea.value = textarea.getAttribute('data-default-pattern')!;
}
if (checkbox.checked) markRowMatchedContexts(row);
}
export function initScopedWorkflowRequired(form: HTMLElement) {
for (const checkbox of form.querySelectorAll<HTMLInputElement>('.js-scoped-required-toggle')) {
syncScopedRequiredRow(checkbox);
}
for (const textarea of form.querySelectorAll<HTMLTextAreaElement>('.js-scoped-required-patterns')) {
const row = textarea.closest('tr')!;
textarea.addEventListener('input', onInputDebounce(() => markRowMatchedContexts(row)));
}
addDelegatedEventListener(form, 'change', '.js-scoped-required-toggle', (checkbox: HTMLInputElement) => syncScopedRequiredRow(checkbox));
}
@@ -1,31 +1,18 @@
import {fomanticQuery} from '../../modules/fomantic/base.ts';
import {htmlEscape} from '../../utils/html.ts';
import {attachSearchBox} from '../../modules/search.ts';
const {appSubUrl} = window.config;
type RepoSearchResponse = {data: Array<{repository: {full_name: string}}>};
export function initCompSearchRepoBox(el: HTMLElement) {
const uid = el.getAttribute('data-uid');
const exclusive = el.getAttribute('data-exclusive');
// when set, the selected value is the full "owner/name" rather than the bare repo name, so a cross-owner search can be resolved unambiguously
const fullName = el.getAttribute('data-full-name') === 'true';
let url = `${appSubUrl}/repo/search?q={query}&uid=${uid}`;
if (exclusive === 'true') {
url += `&exclusive=true`;
}
fomanticQuery(el).search({
minCharacters: 2,
apiSettings: {
url,
onResponse(response: any) {
const items = [];
for (const item of response.data) {
items.push({
title: htmlEscape(item.repository.full_name.split('/')[1]),
description: htmlEscape(item.repository.full_name),
});
}
return {results: items};
},
},
searchFields: ['full_name'],
showNoResults: false,
});
if (exclusive === 'true') url += `&exclusive=true`;
attachSearchBox(el, url, (response: RepoSearchResponse) => response.data.map((item) => ({
title: fullName ? item.repository.full_name : item.repository.full_name.split('/')[1],
description: item.repository.full_name,
})));
}
@@ -1,49 +1,30 @@
import {htmlEscape} from '../../utils/html.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
import {attachSearchBox, type SearchResult} from '../../modules/search.ts';
const {appSubUrl} = window.config;
const looksLikeEmailAddressCheck = /^\S+@\S+$/;
type UserSearchResponse = {data: Array<{login: string; avatar_url: string; full_name: string}>};
export function initCompSearchUserBox() {
const searchUserBox = document.querySelector('#search-user-box');
if (!searchUserBox) return;
const box = document.querySelector<HTMLElement>('#search-user-box');
if (!box) return;
const allowEmailInput = searchUserBox.getAttribute('data-allow-email') === 'true';
const allowEmailDescription = searchUserBox.getAttribute('data-allow-email-description') ?? undefined;
const includeOrgs = searchUserBox.getAttribute('data-include-orgs') === 'true';
fomanticQuery(searchUserBox).search({
minCharacters: 2,
apiSettings: {
url: `${appSubUrl}/user/search_candidates?q={query}&orgs=${includeOrgs}`,
onResponse(response: any) {
const resultItems = [];
const searchQuery = searchUserBox.querySelector('input')!.value;
const searchQueryUppercase = searchQuery.toUpperCase();
for (const item of response.data) {
const resultItem = {
title: item.login,
image: item.avatar_url,
description: htmlEscape(item.full_name),
};
if (searchQueryUppercase === item.login.toUpperCase()) {
resultItems.unshift(resultItem); // add the exact match to the top
} else {
resultItems.push(resultItem);
}
}
const allowEmailInput = box.getAttribute('data-allow-email') === 'true';
const allowEmailDescription = box.getAttribute('data-allow-email-description') ?? undefined;
const includeOrgs = box.getAttribute('data-include-orgs') === 'true';
const url = `${appSubUrl}/user/search_candidates?q={query}&orgs=${includeOrgs}`;
if (allowEmailInput && !resultItems.length && looksLikeEmailAddressCheck.test(searchQuery)) {
const resultItem = {
title: searchQuery,
description: allowEmailDescription,
};
resultItems.push(resultItem);
}
return {results: resultItems};
},
},
searchFields: ['login', 'full_name'],
showNoResults: false,
attachSearchBox(box, url, (response: UserSearchResponse, query) => {
const items: SearchResult[] = [];
const queryUpper = query.toUpperCase();
for (const item of response.data) {
const result: SearchResult = {title: item.login, image: item.avatar_url, description: item.full_name};
if (queryUpper === item.login.toUpperCase()) items.unshift(result); // exact match floats to top
else items.push(result);
}
if (allowEmailInput && !items.length && looksLikeEmailAddressCheck.test(query)) {
items.push({title: query, description: allowEmailDescription});
}
return items;
});
}

Some files were not shown because too many files have changed in this diff Show More