This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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????;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+2
-2
@@ -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
Reference in New Issue
Block a user