Merge branch 'main' into main

This commit is contained in:
Karthik Bhandary authored and GitHub committed 2026-04-27 19:18:38 +05:30
commit ce940d2b92
714 files changed
+12752 -7192

No files matched your search

+1 -1
View File
@@ -55,5 +55,5 @@
padding: 1em 1.5em;
border: 1px solid var(--color-info-border);
background: var(--color-info-bg);
color: var(--color-info-text);
color: var(--color-text);
}
+6 -6
View File
@@ -501,10 +501,10 @@ img.ui.avatar,
}
blockquote.attention-note {
border-left-color: var(--color-blue-dark-1);
border-left-color: var(--color-info-text);
}
strong.attention-note, svg.attention-note {
color: var(--color-blue-dark-1);
color: var(--color-info-text);
}
blockquote.attention-tip {
@@ -515,10 +515,10 @@ strong.attention-tip, svg.attention-tip {
}
blockquote.attention-important {
border-left-color: var(--color-violet-dark-1);
border-left-color: var(--color-priority-text);
}
strong.attention-important, svg.attention-important {
color: var(--color-violet-dark-1);
color: var(--color-priority-text);
}
blockquote.attention-warning {
@@ -529,10 +529,10 @@ strong.attention-warning, svg.attention-warning {
}
blockquote.attention-caution {
border-left-color: var(--color-red-dark-1);
border-left-color: var(--color-error-text);
}
strong.attention-caution, svg.attention-caution {
color: var(--color-red-dark-1);
color: var(--color-error-text);
}
/* FIXME: this is a longstanding dirty patch since 2015, it only makes the pages more messy and shouldn't be used */
+10 -7
View File
@@ -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);
}
+1
View File
@@ -1,6 +1,7 @@
.breadcrumb {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 3px;
overflow-wrap: anywhere;
}
-11
View File
@@ -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;
}
-32
View File
@@ -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;
+3 -13
View File
@@ -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 */
@@ -204,14 +198,14 @@ textarea:focus,
.ui.form .field.error input {
background-color: var(--color-error-bg);
border-color: var(--color-error-border);
color: var(--color-error-text);
color: var(--color-input-text);
}
.ui.form .field.error textarea:focus,
.ui.form .field.error select:focus,
.ui.form .field.error input:focus {
background-color: var(--color-error-bg);
border-color: var(--color-error-border);
color: var(--color-error-text);
color: var(--color-input-text);
}
.ui.form .field.error select {
@@ -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;
+2 -164
View File
@@ -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;
}
-4
View File
@@ -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);
+1 -19
View File
@@ -38,7 +38,7 @@
.ui.input.error > input {
background: var(--color-error-bg);
border-color: var(--color-error-border);
color: var(--color-error-text);
color: var(--color-input-text);
}
.ui.icon.input > i.icon {
@@ -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);
-26
View File
@@ -184,12 +184,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 +215,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 +226,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 {
+1 -44
View File
@@ -173,10 +173,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;
@@ -188,9 +184,6 @@
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 +259,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;
@@ -381,9 +364,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 +503,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 +596,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 +603,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 +613,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 +635,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;
}
+20 -8
View File
@@ -12,6 +12,25 @@
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:first-child {
margin-top: 0;
}
@@ -51,7 +70,6 @@
.ui.blue.message,
.ui.attached.blue.message {
background: var(--color-info-bg);
color: var(--color-info-text);
border-color: var(--color-info-border);
}
@@ -60,27 +78,21 @@
.ui.positive.message,
.ui.attached.positive.message {
background: var(--color-success-bg);
color: var(--color-success-text);
border-color: var(--color-success-border);
}
.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);
color: var(--color-error-text);
border-color: var(--color-error-border);
}
.ui.warning.message,
.ui.attached.warning.message,
.ui.yellow.message,
.ui.attached.yellow.message {
.ui.attached.warning.message {
background: var(--color-warning-bg);
color: var(--color-warning-text);
border-color: var(--color-warning-border);
}
-28
View File
@@ -226,18 +226,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 +238,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;
-15
View File
@@ -31,21 +31,6 @@
color: var(--color-text-light-3);
}
.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%;
+6 -5
View File
@@ -199,18 +199,19 @@ td .commit-summary {
}
.repo-editor-header {
/* it should match ".repo-button-row" so the tree toggle button stays aligned */
margin: 8px 0;
display: flex;
margin: 1rem 0;
padding: 3px 0;
width: 100%;
gap: 0.5em;
align-items: center;
gap: 8px;
width: 100%;
}
.repo-editor-header input {
vertical-align: middle !important;
width: auto !important;
padding: 7px 8px !important;
height: 30px !important;
padding: 5px 8px !important;
margin-right: 5px !important;
}
+7 -4
View File
@@ -163,16 +163,19 @@ gitea-theme-meta-info {
--color-error-bg: #322226;
--color-error-bg-active: #49262a;
--color-error-bg-hover: #3c2427;
--color-error-text: var(--color-text);
--color-error-text: #f85149;
--color-success-border: #225633;
--color-success-bg: #1c3329;
--color-success-text: var(--color-text);
--color-success-text: #3fb950;
--color-warning-border: #5f481a;
--color-warning-bg: #342e1f;
--color-warning-text: var(--color-text);
--color-warning-text: #d29922;
--color-info-border: #254a7e;
--color-info-bg: #1b283a;
--color-info-text: var(--color-text);
--color-info-text: #2f81f7;
--color-priority-border: #4a268d;
--color-priority-bg: #251c39;
--color-priority-text: #a371f7;
--color-red-badge: #db2828;
--color-red-badge-bg: #db28281a;
--color-red-badge-hover-bg: #db28284d;
+7 -4
View File
@@ -163,16 +163,19 @@ gitea-theme-meta-info {
--color-error-bg: #ffebe9;
--color-error-bg-active: #ffcecb;
--color-error-bg-hover: #ffdcd7;
--color-error-text: var(--color-text);
--color-error-text: #d1242f;
--color-success-border: #4ac26b66;
--color-success-bg: #dafbe1;
--color-success-text: var(--color-text);
--color-success-text: #1a7f37;
--color-warning-border: #d4a72c66;
--color-warning-bg: #fff8c5;
--color-warning-text: var(--color-text);
--color-warning-text: #9a6700;
--color-info-border: #54aeff66;
--color-info-bg: #ddf4ff;
--color-info-text: var(--color-text);
--color-info-text: #0969da;
--color-priority-border: #b9a1ff66;
--color-priority-bg: #f3e7ff;
--color-priority-text: #8250df;
--color-red-badge: #db2828;
--color-red-badge-bg: #db28281a;
--color-red-badge-hover-bg: #db28284d;
@@ -3,17 +3,19 @@ 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: Date.UTC(2026, 2, 20, 12, 0, 0) / 1000,
expiresUnix,
}, 'Expires at %s (extra)');
expect(normalizeTestHtml(result)).toBe(normalizeTestHtml(`<span class="flex-text-inline">
<span>Expires at </span>
<relative-time datetime="2026-03-20T12:00:00.000Z" threshold="P0Y" prefix="" weekday="" year="numeric" month="short" hour="numeric" minute="2-digit">
2026-03-20T12:00:00.000Z
<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>
+3 -4
View File
@@ -7,15 +7,14 @@ export function buildArtifactTooltipHtml(artifact: ActionsArtifact, expiresAtLoc
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');
const datetime = new Date(artifact.expiresUnix * 1000).toISOString();
return html`
<span class="flex-text-inline">
<span>${prefix}</span>
<relative-time datetime="${datetime}" threshold="P0Y" prefix="" weekday="" year="numeric" month="short" hour="numeric" minute="2-digit">
${datetime}
<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>
+15 -5
View File
@@ -77,9 +77,8 @@ defineOptions({
const props = defineProps<{
store: ActionRunViewStore,
runId: number;
jobId: number;
actionsUrl: string;
actionsViewUrl: string;
locale: Record<string, any>;
}>();
const store = props.store;
@@ -270,8 +269,7 @@ async function fetchJobData(abortController: AbortController): Promise<JobData>
// 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, {
const resp = await POST(props.actionsViewUrl, {
signal: abortController.signal,
data: {logCursors},
});
@@ -663,6 +661,14 @@ async function hashChangeListener() {
background: var(--color-warning-bg);
}
.job-step-logs .log-line-notice {
background: var(--color-info-bg);
}
.job-step-logs .log-line-debug {
background: var(--color-secondary-alpha-30);
}
.job-step-logs .log-cmd-error > .log-msg-label {
color: var(--color-error-text);
}
@@ -671,7 +677,11 @@ async function hashChangeListener() {
color: var(--color-warning-text);
}
.job-step-logs .log-cmd-debug {
.job-step-logs .log-cmd-notice > .log-msg-label {
color: var(--color-info-text);
}
.job-step-logs .log-cmd-debug > .log-msg-label {
color: var(--color-violet);
}
+18 -4
View File
@@ -13,11 +13,18 @@ const props = defineProps<{
locale: Record<string, any>;
}>();
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);
const triggerUser = computed(() => {
const currentAttempt = run.value.attempts.find((attempt) => attempt.current);
if (currentAttempt) {
return {name: currentAttempt.triggerUserName, link: currentAttempt.triggerUserLink};
}
const pusher = run.value.commit.pusher;
return pusher.displayName ? {name: pusher.displayName, link: pusher.link} : null;
});
onMounted(async () => {
@@ -32,7 +39,14 @@ onBeforeUnmount(() => {
<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=""/>
<span>{{ isRerun ? locale.rerun : locale.triggeredVia.replace('%s', run.triggerEvent) }}</span>
<template v-if="triggerUser">
<span>•</span>
<a v-if="triggerUser.link" class="muted" :href="triggerUser.link">{{ triggerUser.name }}</a>
<span v-else class="muted">{{ triggerUser.name }}</span>
</template>
<span>•</span>
<relative-time :datetime="run.triggeredAt || ''" prefix=""/>
</div>
<div class="flex-text-block">
<ActionRunStatus :locale-status="locale.status[run.status]" :status="run.status" :size="16"/>
+5 -3
View File
@@ -91,6 +91,7 @@ export function createEmptyActionsRun(): ActionsRun {
return {
repoId: 0,
link: '',
viewLink: '',
title: '',
titleHTML: '',
status: '' as ActionsRunStatus, // do not show the status before initialized, otherwise it would show an incorrect "error" icon
@@ -103,6 +104,8 @@ export function createEmptyActionsRun(): ActionsRun {
workflowID: '',
workflowLink: '',
isSchedule: false,
runAttempt: 0,
attempts: [],
duration: '',
triggeredAt: 0,
triggerEvent: '',
@@ -125,7 +128,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 +140,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;
+168 -28
View File
@@ -1,21 +1,23 @@
<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';
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 +26,112 @@ 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 {months, on} = props.locale.heatMapLocale;
const {noDataText, tooltipUnit} = props.locale;
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 = `${months[cursor.getMonth()]} ${cursor.getDate()}, ${cursor.getFullYear()}`;
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 +146,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>
+12 -35
View File
@@ -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 -->
@@ -230,7 +230,7 @@ function clearMergeMessage() {
bottom: -1px;
position: absolute;
align-items: center;
color: var(--color-info-text);
color: var(--color-text);
background-color: var(--color-info-bg);
border: 1px solid var(--color-info-border);
border-left: none;
@@ -238,7 +238,7 @@ function clearMergeMessage() {
}
.auto-merge-small:hover {
color: var(--color-info-text);
color: var(--color-text);
background-color: var(--color-info-bg);
border: 1px solid var(--color-info-border);
}
+61 -14
View File
@@ -5,6 +5,7 @@ import {toRefs} from 'vue';
import {POST, DELETE} from '../modules/fetch.ts';
import ActionRunSummaryView from './ActionRunSummaryView.vue';
import ActionRunJobView from './ActionRunJobView.vue';
import type {ActionsRunAttempt} from '../modules/gitea-actions.ts';
import {createActionRunViewStore} from './ActionRunView.ts';
import {buildArtifactTooltipHtml} from './ActionRunArtifacts.ts';
@@ -13,16 +14,28 @@ defineOptions({
});
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 store = createActionRunViewStore(props.actionsViewUrl);
const {currentRun: run, runArtifacts: artifacts} = toRefs(store.viewData);
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);
}
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`);
}
@@ -33,7 +46,7 @@ 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();
}
</script>
@@ -51,10 +64,10 @@ async function deleteArtifact(name: string) {
<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 }}
@@ -72,10 +85,45 @@ 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">
<ActionRunStatus :locale-status="locale.status[attempt.status]" :status="attempt.status" :size="14" class="flex-text-block"/>
<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>
<span>
<a v-if="run.workflowLink" class="muted" :href="run.workflowLink"><b>{{ run.workflowID }}</b></a>
<b v-else>{{ run.workflowID }}</b>
:
</span>
<template v-if="run.isSchedule">
{{ locale.scheduled }}
</template>
@@ -94,8 +142,8 @@ async function deleteArtifact(name: string) {
<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"/>
<a class="job-brief-item silenced" :href="run.viewLink" :class="!props.jobId ? 'selected' : ''">
<SvgIcon name="octicon-home"/>
<span class="gt-ellipsis">{{ locale.summary }}</span>
</a>
@@ -105,7 +153,7 @@ async function deleteArtifact(name: string) {
<!-- 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">
<a class="tw-contents silenced" :href="job.link">
<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"/>
@@ -123,7 +171,7 @@ async function deleteArtifact(name: string) {
<template v-if="artifact.status !== 'expired'">
<a
class="tw-flex-1 flex-text-block muted" target="_blank"
:href="run.link+'/artifacts/'+encodeURIComponent(artifact.name)"
:href="buildArtifactLink(artifact.name)"
:data-tooltip-content="buildArtifactTooltipHtml(artifact, locale.artifactExpiresAt)"
data-tooltip-render="html"
data-tooltip-placement="top-end"
@@ -136,7 +184,7 @@ async function deleteArtifact(name: string) {
</a>
</template>
<span v-else class="flex-text-block tw-flex-1 tw-text-text-light-2">
<SvgIcon name="octicon-file"/>
<SvgIcon name="octicon-file-removed"/>
<span class="tw-flex-1 gt-ellipsis">{{ artifact.name }}</span>
<span class="ui label tw-flex-shrink-0">{{ locale.artifactExpired }}</span>
</span>
@@ -167,9 +215,8 @@ async function deleteArtifact(name: string) {
v-else
:store="store"
:locale="locale"
:run-id="props.runId"
:actions-view-url="props.actionsViewUrl"
:job-id="props.jobId"
:actions-url="props.actionsUrl"
/>
</div>
</div>
+2 -1
View File
@@ -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;
}
+2 -1
View File
@@ -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;
}
+2 -1
View File
@@ -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;
}
+36 -27
View File
@@ -83,9 +83,11 @@ const saveState = () => {
localUserSettings.setJsonObject(settingKeyStates, Object.fromEntries(sortedStates));
};
const minNodeWidth = 168;
const maxNodeWidth = 232;
const nodeWidth = computed(() => {
const maxNameLength = Math.max(...props.jobs.map(j => j.name.length));
return Math.min(Math.max(140, maxNameLength * 8), 180);
const maxNameLength = Math.max(...props.jobs.map(j => j.name.length), 0);
return Math.min(Math.max(minNodeWidth, maxNameLength * 8), maxNodeWidth);
});
const horizontalSpacing = computed(() => nodeWidth.value + 84);
@@ -342,8 +344,8 @@ const graphMetrics = computed(() => {
};
})
const nodeHeight = 48;
const verticalSpacing = 88;
const nodeHeight = 52;
const verticalSpacing = 90;
const margin = 40;
const minScale = 0.3;
@@ -589,7 +591,7 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
:d="edge.path"
fill="none"
stroke="var(--color-secondary-alpha-50)"
stroke-width="1.75"
stroke-width="1.5"
:class="['node-edge', { 'highlighted-edge': isEdgeHighlighted(edge) }]"
/>
@@ -608,10 +610,10 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
:y="job.y"
:width="nodeWidth"
:height="nodeHeight"
rx="10"
fill="var(--color-button)"
stroke="var(--color-light-border)"
stroke-width="1.25"
rx="8"
fill="var(--color-box-body)"
stroke="var(--color-secondary)"
stroke-width="1"
class="job-rect"
/>
@@ -619,7 +621,7 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
v-if="nodesWithIncomingEdge.has(job.id)"
:cx="job.x"
:cy="job.y + nodeHeight / 2"
r="6"
r="4.5"
class="node-port"
/>
@@ -627,13 +629,13 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
v-if="nodesWithOutgoingEdge.has(job.id)"
:cx="job.x + nodeWidth"
:cy="job.y + nodeHeight / 2"
r="6"
r="4.5"
class="node-port"
/>
<foreignObject
:x="job.x + 10"
:y="job.y + 14"
:y="job.y + 16"
width="20"
height="20"
class="job-status-fg-obj"
@@ -644,10 +646,10 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
</foreignObject>
<foreignObject
:x="job.x + 36"
:y="job.y"
:width="nodeWidth - 40"
:height="nodeHeight"
:x="job.x + 38"
:y="job.y + 2"
:width="nodeWidth - 44"
:height="nodeHeight - 4"
>
<div class="job-text-wrap">
<span class="job-name">{{ job.name }}</span>
@@ -707,7 +709,7 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
.graph-container {
flex: 1;
overflow: hidden;
padding: 12px 16px 20px;
padding: 10px 14px 18px;
border-radius: 0 0 var(--border-radius) var(--border-radius);
cursor: grab;
position: relative;
@@ -730,7 +732,7 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
}
.highlighted-edge {
stroke-width: 2.25 !important;
stroke-width: 2 !important;
stroke: var(--color-workflow-edge-hover) !important;
}
@@ -749,18 +751,20 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
padding-right: 8px;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 1px;
padding: 4px 8px 4px 0;
overflow: hidden;
}
.job-name {
flex: 1;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
font-size: 12px;
font-weight: var(--font-weight-semibold);
color: var(--color-text);
user-select: none;
@@ -768,10 +772,13 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
}
.job-duration {
font-size: 11px;
font-size: 10px;
line-height: 1.2;
color: var(--color-text-light-2);
white-space: nowrap;
flex-shrink: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
user-select: none;
pointer-events: none;
}
@@ -792,11 +799,13 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
.node-port {
fill: var(--color-box-body);
stroke: var(--color-light-border);
stroke-width: 1.5;
stroke-width: 1.25;
opacity: 0.85;
pointer-events: none;
}
.node-edge {
transition: stroke-width 0.2s ease, opacity 0.2s ease;
opacity: 0.75;
}
</style>
+4 -4
View File
@@ -1,8 +1,8 @@
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 {urlQueryEscape} from '../../utils.ts';
import {showFomanticModal} from '../../modules/fomantic/modal.ts';
import {pathEscape} from '../../utils/url.ts';
const {appSubUrl} = window.config;
@@ -232,7 +232,7 @@ function initAdminAuthentication() {
const elAuthName = document.querySelector<HTMLInputElement>('#auth_name')!;
const onAuthNameChange = function () {
// appSubUrl is either empty or is a path that starts with `/` and doesn't have a trailing slash.
document.querySelector('#oauth2-callback-url')!.textContent = `${window.location.origin}${appSubUrl}/user/oauth2/${urlQueryEscape(elAuthName.value)}/callback`;
document.querySelector('#oauth2-callback-url')!.textContent = `${window.location.origin}${appSubUrl}/user/oauth2/${pathEscape(elAuthName.value)}/callback`;
};
elAuthName.addEventListener('input', onAuthNameChange);
onAuthNameChange();
@@ -250,7 +250,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
+1 -1
View File
@@ -29,7 +29,7 @@ test('ConfigFormValueMapper', () => {
mapper.fillFromSystemConfig();
const formData = mapper.collectToFormData();
const result: Record<string, string> = {};
const keys = [], values = [];
const keys: string[] = [], values: string[] = [];
for (const [key, value] of formData.entries()) {
if (key === 'key') keys.push(value as string);
if (key === 'value') values.push(value as string);
+2 -1
View File
@@ -2,6 +2,7 @@ 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';
const {appSubUrl} = window.config;
@@ -26,7 +27,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;
}
});
+4 -3
View File
@@ -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'));
});
}
+4 -4
View File
@@ -1,6 +1,6 @@
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';
@@ -42,7 +42,7 @@ export function initGlobalDeleteButton(): void {
}
}
fomanticQuery(modal).modal({
showFomanticModal(modal, {
closable: false,
onApprove: () => {
// if `data-type="form"` exists, then submit the form by the selector provided by `data-form="..."`
@@ -76,7 +76,7 @@ export function initGlobalDeleteButton(): void {
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');
});
});
}
}
@@ -174,7 +174,7 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) {
}
}
fomanticQuery(elModal).modal('show');
showFomanticModal(elModal);
}
export function initGlobalButtons(): void {
@@ -1,4 +1,4 @@
import {execPseudoSelectorCommands} from './common-fetch-action.ts';
import {execPseudoSelectorCommands, handleFetchActionSuccessJson} from './common-fetch-action.ts';
test('execPseudoSelectorCommands', () => {
window.document.body.innerHTML = `
@@ -37,3 +37,23 @@ test('execPseudoSelectorCommands', () => {
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();
});
+12 -9
View File
@@ -1,6 +1,7 @@
import {GET, request} from '../modules/fetch.ts';
import {hideToastsAll, showErrorToast} from '../modules/toast.ts';
import {addDelegatedEventListener, createElementFromHTML} from '../utils/dom.ts';
import {errorMessage} from '../modules/errors.ts';
import {confirmModal, createConfirmModal} from './comp/ConfirmModal.ts';
import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
@@ -33,7 +34,7 @@ function fetchActionDoRedirect(redirect: string) {
// * 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.href = redirect;
window.location.assign(redirect);
return;
}
@@ -65,11 +66,13 @@ function toggleLoadingIndicator(el: HTMLElement, opt: FetchActionOpts, isLoading
}
}
async function handleFetchActionSuccessJson(el: HTMLElement, respJson: any) {
export async function handleFetchActionSuccessJson(el: HTMLElement, respJson: any) {
ignoreAreYouSure(el); // ignore the areYouSure check before reloading
if (typeof respJson?.redirect === 'string') {
fetchActionDoRedirect(respJson.redirect);
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();
}
}
@@ -90,7 +93,7 @@ async function handleFetchActionSuccess(el: HTMLElement, opt: FetchActionOpts, r
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(await resp.text()) : null;
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.
@@ -134,10 +137,10 @@ async function performActionRequest(el: HTMLElement, opt: FetchActionOpts) {
return;
}
await handleFetchActionError(resp);
} catch (e) {
if (e.name !== 'AbortError') {
console.error(`Fetch action request error:`, e);
showErrorToast(`Error: ${e.message ?? e}`);
} catch (err) {
if ((err as Error).name !== 'AbortError') {
console.error(`Fetch action request error:`, err);
showErrorToast(`Error: ${errorMessage(err)}`);
}
} finally {
toggleLoadingIndicator(el, opt, false);
+2 -1
View File
@@ -1,6 +1,7 @@
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';
@@ -100,7 +101,7 @@ export function initGlobalDropdown() {
}
export function initGlobalComponent() {
fomanticQuery('.ui.menu.tabular:not(.custom) .item').tab();
registerGlobalInitFunc('initTabSwitcher', initTabSwitcher);
registerGlobalInitFunc('initAvatarUploader', initAvatarUploaderWithCropper);
registerGlobalInitFunc('initSearchRepoBox', initCompSearchRepoBox);
}
+19 -22
View File
@@ -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() {
+4 -5
View File
@@ -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');
});
});
}
+3 -3
View File
@@ -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());
+2 -1
View File
@@ -5,6 +5,7 @@ import {showTemporaryTooltip} from '../modules/tippy.ts';
import {GET, POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {createElementFromHTML, createElementFromAttrs} from '../utils/dom.ts';
import {errorMessage} from '../modules/errors.ts';
import {isImageFile, isVideoFile} from '../utils.ts';
import type Dropzone from '@deltablot/dropzone';
@@ -149,7 +150,7 @@ export async function initDropzone(dropzoneEl: HTMLElement) {
} catch (error) {
// TODO: if listing the existing attachments failed, it should stop from operating the content or attachments,
// otherwise the attachments might be lost.
showErrorToast(`Failed to load attachments: ${error}`);
showErrorToast(`Failed to load attachments: ${errorMessage(error)}`);
console.error(error);
}
});
+2 -1
View File
@@ -2,6 +2,7 @@ import type {InplaceRenderPlugin} from '../render/plugin.ts';
import {newInplacePluginPdfViewer} from '../render/plugins/inplace-pdf-viewer.ts';
import {registerGlobalInitFunc} from '../modules/observer.ts';
import {createElementFromHTML} from '../utils/dom.ts';
import {errorMessage} from '../modules/errors.ts';
import {html} from '../utils/html.ts';
import {basename} from '../utils.ts';
@@ -30,7 +31,7 @@ async function renderRawFileToContainer(container: HTMLElement, rawFileLink: str
rendered = true;
}
} catch (e) {
errorMsg = `${e}`;
errorMsg = errorMessage(e);
} finally {
container.classList.remove('is-loading');
}
+2 -5
View File
@@ -1,7 +1,6 @@
import {GET} from '../modules/fetch.ts';
import {hideElem, loadElem, queryElemChildren, queryElems} from '../utils/dom.ts';
import {parseDom} from '../utils.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
type ImageContext = {
imageBefore: HTMLImageElement | undefined,
@@ -94,15 +93,13 @@ function createContext(imageAfter: HTMLImageElement, imageBefore: HTMLImageEleme
}
class ImageDiff {
containerEl: HTMLElement;
diffContainerWidth: number;
containerEl!: HTMLElement;
diffContainerWidth!: number;
async init(containerEl: HTMLElement) {
this.containerEl = containerEl;
containerEl.setAttribute('data-image-diff-loaded', 'true');
fomanticQuery(containerEl).find('.ui.menu.tabular .item').tab();
// the container may be hidden by "viewed" checkbox, so use the parent's width for reference
this.diffContainerWidth = Math.max(containerEl.closest('.diff-file-box')!.clientWidth - 300, 100);
+75
View File
@@ -0,0 +1,75 @@
import {parseIssueHref} from '../utils.ts';
import {GET} from '../modules/fetch.ts';
import {createApp} from 'vue';
import {createTippy, getAttachedTippyInstance} from '../modules/tippy.ts';
import {addDelegatedEventListener} from '../utils/dom.ts';
import type {Issue} from '../types.ts';
type IssueInfo = {
convertedIssue: Issue,
renderedLabels: string,
};
const issueInfoCache = new Map<string, IssueInfo>();
async function getIssueInfo(url: string): Promise<IssueInfo> {
if (issueInfoCache.has(url)) return issueInfoCache.get(url)!;
const resp = await GET(url);
if (!resp.ok) throw new Error(resp.statusText || 'Unknown network error');
const data = await resp.json();
issueInfoCache.set(url, data);
return data;
}
async function showRefIssuePopup(link: HTMLAnchorElement) {
const [data, {default: ContextPopup}] = await Promise.all([
getIssueInfo(`${link.pathname}/info`),
import('../components/ContextPopup.vue'),
]);
const el = document.createElement('div');
const app = createApp(ContextPopup, {
issue: data.convertedIssue,
renderedLabels: data.renderedLabels,
});
app.mount(el);
// suppress ancestor title like from .commit-summary to prevent double tooltip
link.title = '';
createTippy(link, {
theme: 'default',
content: el,
trigger: 'mouseenter focus',
placement: 'top-start',
interactive: true,
role: 'dialog',
interactiveBorder: 5,
onDestroy: () => app.unmount(),
}).show();
}
export function initRefIssueContextPopup() {
const selector = 'a[href]:not([data-ref-issue-popup]):not(.ref-external-issue)';
addDelegatedEventListener<HTMLAnchorElement, MouseEvent>(document, 'mouseover', selector, (link) => {
if (!parseIssueHref(link.getAttribute('href')!).ownerName) return;
if (!link.classList.contains('ref-issue') && !link.closest('[data-ref-issue-container]')) return;
if (getAttachedTippyInstance(link)) return;
link.setAttribute('data-ref-issue-popup', '');
// delay so a mouse passing over the link doesn't fire a fetch
let timer: ReturnType<typeof setTimeout>;
const cancel = () => {
clearTimeout(timer);
link.removeAttribute('data-ref-issue-popup');
link.removeEventListener('mouseleave', cancel);
};
timer = setTimeout(async () => {
link.removeEventListener('mouseleave', cancel);
try {
await showRefIssuePopup(link);
} catch (err) {
console.error('Failed to load issue info:', err);
link.removeAttribute('data-ref-issue-popup');
}
}, 300);
link.addEventListener('mouseleave', cancel);
});
}
+5 -7
View File
@@ -4,8 +4,6 @@ import RepoActionView from '../components/RepoActionView.vue';
export function initRepositoryActionView() {
const el = document.querySelector('#repo-action-view');
if (!el) return;
const runId = parseInt(el.getAttribute('data-run-id')!);
const jobId = parseInt(el.getAttribute('data-job-id')!);
// TODO: the parent element's full height doesn't work well now,
// but we can not pollute the global style at the moment, only fix the height problem for pages with this component
@@ -13,25 +11,25 @@ export function initRepositoryActionView() {
if (parentFullHeight) parentFullHeight.classList.add('tw-pb-0');
const view = createApp(RepoActionView, {
runId,
jobId,
actionsUrl: el.getAttribute('data-actions-url'),
jobId: parseInt(el.getAttribute('data-job-id')!),
actionsViewUrl: el.getAttribute('data-actions-view-url'),
locale: {
approve: el.getAttribute('data-locale-approve'),
cancel: el.getAttribute('data-locale-cancel'),
rerun: el.getAttribute('data-locale-rerun'),
rerun_all: el.getAttribute('data-locale-rerun-all'),
rerun_failed: el.getAttribute('data-locale-rerun-failed'),
latest: el.getAttribute('data-locale-latest'),
latestAttempt: el.getAttribute('data-locale-latest-attempt'),
attempt: el.getAttribute('data-locale-attempt'),
scheduled: el.getAttribute('data-locale-runs-scheduled'),
commit: el.getAttribute('data-locale-runs-commit'),
pushedBy: el.getAttribute('data-locale-runs-pushed-by'),
workflowGraph: el.getAttribute('data-locale-runs-workflow-graph'),
summary: el.getAttribute('data-locale-summary'),
allJobs: el.getAttribute('data-locale-all-jobs'),
triggeredVia: el.getAttribute('data-locale-triggered-via'),
totalDuration: el.getAttribute('data-locale-total-duration'),
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
areYouSure: el.getAttribute('data-locale-are-you-sure'),
artifactExpired: el.getAttribute('data-locale-artifact-expired'),
artifactExpiresAt: el.getAttribute('data-locale-artifact-expires-at'),
confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'),
+2 -2
View File
@@ -1,5 +1,5 @@
import {toggleElem} from '../utils/dom.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {showFomanticModal} from '../modules/fomantic/modal.ts';
export function initRepoBranchButton() {
initRepoCreateBranchButton();
@@ -18,7 +18,7 @@ function initRepoCreateBranchButton() {
const fromSpanName = el.getAttribute('data-modal-from-span') || '#modal-create-branch-from-span';
document.querySelector(fromSpanName)!.textContent = el.getAttribute('data-branch-from');
fomanticQuery(el.getAttribute('data-modal')!).modal('show');
showFomanticModal(document.querySelector(el.getAttribute('data-modal')!));
});
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
import {queryElems} from '../utils/dom.ts';
import {errorMessage} from '../modules/errors.ts';
import {POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {sleep} from '../utils.ts';
@@ -26,7 +27,7 @@ async function onDownloadArchive(e: Event) {
window.location.href = el.href; // the archive is ready, start real downloading
} catch (e) {
console.error(e);
showErrorToast(`Failed to download the archive: ${e}`, {duration: 2500});
showErrorToast(`Failed to download the archive: ${errorMessage(e)}`, {duration: 2500});
} finally {
targetLoading.classList.remove('is-loading', 'loading-icon-2px');
}
+2 -1
View File
@@ -6,6 +6,7 @@ import {initViewedCheckboxListenerFor, initExpandAndCollapseFilesButton} from '.
import {initImageDiff} from './imagediff.ts';
import {showErrorToast} from '../modules/toast.ts';
import {queryElemSiblings, hideElem, showElem, animateOnce, addDelegatedEventListener, createElementFromHTML, queryElems} from '../utils/dom.ts';
import {errorMessage} from '../modules/errors.ts';
import {POST, GET} from '../modules/fetch.ts';
import {createTippy} from '../modules/tippy.ts';
import {invertFileFolding} from './file-fold.ts';
@@ -85,7 +86,7 @@ function initRepoDiffConversationForm() {
}
} catch (error) {
console.error('Error:', error);
showErrorToast(`Submit form failed: ${error}`);
showErrorToast(`Submit form failed: ${errorMessage(error)}`);
} finally {
form?.classList.remove('is-loading');
}
-2
View File
@@ -9,13 +9,11 @@ import {applyAreYouSure, ignoreAreYouSure} from '../vendor/jquery.are-you-sure.t
import {submitFormFetchAction} from './common-fetch-action.ts';
import {dirname} from '../utils.ts';
import {pathEscapeSegments} from '../utils/url.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {showErrorToast} from '../modules/toast.ts';
function initEditPreviewTab(elForm: HTMLFormElement) {
const elTabMenu = elForm.querySelector('.repo-editor-menu');
if (!elTabMenu) return;
fomanticQuery(elTabMenu.querySelectorAll('.item')).tab();
const elTreePath = elForm.querySelector<HTMLInputElement>('input#tree_path');
const elTextarea = elForm.querySelector<HTMLTextAreaElement>('.tab[data-tab="write"] textarea');
+5 -5
View File
@@ -4,6 +4,7 @@ import {GET, POST} from '../modules/fetch.ts';
import {createElementFromHTML, showElem} from '../utils/dom.ts';
import {parseIssuePageInfo} from '../utils.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {hideFomanticModal, showFomanticModal} from '../modules/fomantic/modal.ts';
let i18nTextEdited: string;
let i18nTextOptions: string;
@@ -28,7 +29,6 @@ function showContentHistoryDetail(issueBaseUrl: string, commentId: string, histo
</div>`);
document.body.append(elDetailDialog);
const elOptionsDropdown = elDetailDialog.querySelector('.ui.dropdown.dialog-header-options')!;
const $fomanticDialog = fomanticQuery(elDetailDialog);
const $fomanticDropdownOptions = fomanticQuery(elOptionsDropdown);
$fomanticDropdownOptions.dropdown({
showOnFocus: false,
@@ -46,7 +46,7 @@ function showContentHistoryDetail(issueBaseUrl: string, commentId: string, histo
const resp = await response.json();
if (resp.ok) {
$fomanticDialog.modal('hide');
hideFomanticModal(elDetailDialog);
} else {
showErrorToast(resp.message);
}
@@ -63,7 +63,7 @@ function showContentHistoryDetail(issueBaseUrl: string, commentId: string, histo
$fomanticDropdownOptions.dropdown('clear', true);
},
});
$fomanticDialog.modal({
showFomanticModal(elDetailDialog, {
async onShow() {
try {
const params = new URLSearchParams();
@@ -86,9 +86,9 @@ function showContentHistoryDetail(issueBaseUrl: string, commentId: string, histo
}
},
onHidden() {
$fomanticDialog.remove();
elDetailDialog.remove();
},
}).modal('show');
});
}
function showContentHistoryMenu(issueBaseUrl: string, elCommentItem: Element, commentId: string) {
+2 -1
View File
@@ -3,6 +3,7 @@ import {getComboMarkdownEditor, initComboMarkdownEditor, ComboMarkdownEditor} fr
import {POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {hideElem, querySingleVisibleElem, showElem} from '../utils/dom.ts';
import {errorMessage} from '../modules/errors.ts';
import {triggerUploadStateChanged} from './comp/EditorUpload.ts';
import {convertHtmlToMarkdown} from '../markup/html2markdown.ts';
import {applyAreYouSure, reinitializeAreYouSure} from '../vendor/jquery.are-you-sure.ts';
@@ -73,7 +74,7 @@ async function tryOnEditContent(e: Event) {
}
comboMarkdownEditor.dropzoneSubmitReload();
} catch (error) {
showErrorToast(`Failed to save the content: ${error}`);
showErrorToast(`Failed to save the content: ${errorMessage(error)}`);
console.error(error);
} finally {
renderContent.classList.remove('is-loading');
+4 -1
View File
@@ -2,6 +2,7 @@ import {updateIssuesMeta} from './repo-common.ts';
import {toggleElem, queryElems, isElemVisible} from '../utils/dom.ts';
import {html, htmlRaw} from '../utils/html.ts';
import {confirmModal} from './comp/ConfirmModal.ts';
import {errorMessage} from '../modules/errors.ts';
import {showErrorToast} from '../modules/toast.ts';
import {createSortable} from '../modules/sortable.ts';
import {DELETE, POST} from '../modules/fetch.ts';
@@ -87,7 +88,9 @@ function initRepoIssueListCheckboxes() {
await updateIssuesMeta(url, action, issueIDs, elementId);
window.location.reload();
} catch (err) {
showErrorToast(err.responseJSON?.error ?? err.message);
// FIXME: this logic (including updateIssuesMeta) is not right, should refactor to our JSONError framework
const e = err as {responseJSON?: {error: string}};
showErrorToast(e.responseJSON?.error ?? errorMessage(err));
}
},
));
@@ -2,6 +2,7 @@ import {fomanticQuery} from '../modules/fomantic/base.ts';
import {GET, POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {addDelegatedEventListener, queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
import {errorMessage} from '../modules/errors.ts';
import {parseDom} from '../utils.ts';
export function syncIssueMainContentTimelineItems(oldMainContent: Element, newMainContent: Element) {
@@ -42,7 +43,7 @@ export class IssueSidebarComboList {
elDropdown: HTMLElement;
elList: HTMLElement | null;
elComboValue: HTMLInputElement;
initialValues: string[];
initialValues: string[] = [];
container: HTMLElement;
elIssueMainContent: HTMLElement;
@@ -129,7 +130,7 @@ export class IssueSidebarComboList {
await this.reloadPagePartially();
} catch (e) {
console.error('Failed to update to backend', e);
showErrorToast(`Failed to update to backend: ${e}`);
showErrorToast(`Failed to update to backend: ${errorMessage(e)}`);
} finally {
this.elIssueSidebar.classList.remove('is-loading');
}
+4 -2
View File
@@ -1,3 +1,4 @@
import {errorMessage} from '../modules/errors.ts';
import {htmlEscape} from '../utils/html.ts';
import {createTippy} from '../modules/tippy.ts';
import {
@@ -15,6 +16,7 @@ import {GET, POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {initRepoIssueSidebar} from './repo-issue-sidebar.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {showFomanticModal} from '../modules/fomantic/modal.ts';
import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
import {registerGlobalInitFunc} from '../modules/observer.ts';
@@ -334,7 +336,7 @@ export function initRepoIssueReferenceIssue() {
const modal = document.querySelector(modalSelector)!;
const textarea = modal.querySelector<HTMLTextAreaElement>('textarea[name="content"]')!;
textarea.value = `${content}\n\n_Originally posted by @${poster} in ${reference}_`;
fomanticQuery(modal).modal('show');
showFomanticModal(modal);
});
}
@@ -425,7 +427,7 @@ export function initRepoIssueTitleEdit() {
window.location.reload();
} catch (error) {
console.error(error);
showErrorToast(error.message);
showErrorToast(errorMessage(error));
}
});
}
+2 -2
View File
@@ -1,7 +1,7 @@
import {contrastColor} from '../utils/color.ts';
import {createSortable} from '../modules/sortable.ts';
import {POST, request} from '../modules/fetch.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {hideFomanticModal} from '../modules/fomantic/modal.ts';
import {queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
import type {SortableEvent} from 'sortablejs';
import {toggleFullScreen} from '../utils.ts';
@@ -138,7 +138,7 @@ function initRepoProjectColumnEdit(writableProjectBoard: Element): void {
queryElemChildren(elBoardColumn, '.divider', (divider: HTMLElement) => divider.style.removeProperty('color'));
}
fomanticQuery(elModal).modal('hide');
hideFomanticModal(elModal);
} finally {
elForm.classList.remove('is-loading');
}
+4 -3
View File
@@ -3,6 +3,7 @@ import {hideToastsAll, showErrorToast} from '../modules/toast.ts';
import {getComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
import {hideElem} from '../utils/dom.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {hideFomanticModal, showFomanticModal} from '../modules/fomantic/modal.ts';
import {registerGlobalEventFunc, registerGlobalInitFunc} from '../modules/observer.ts';
import {htmlEscape} from '../utils/html.ts';
import {compareVersions} from 'compare-versions';
@@ -112,7 +113,7 @@ function initGenerateReleaseNotes(elForm: HTMLFormElement) {
}
} finally {
elModal.classList.remove('loading', 'disabled');
fomanticQuery(elModal).modal('hide');
hideFomanticModal(elModal);
comboEditor.focus();
}
};
@@ -139,12 +140,12 @@ function initGenerateReleaseNotes(elForm: HTMLFormElement) {
}
$dropdown.dropdown('set selected', guessPreviousReleaseTag(tagName, existingTags));
fomanticQuery(elModal).modal({
showFomanticModal(elModal, {
onApprove: () => {
doSubmit(tagName); // don't await, need to return false to keep the modal
return false;
},
}).modal('show');
});
};
buttonShowModal.addEventListener('click', doShowModal);
@@ -2,6 +2,7 @@ import {createSortable} from '../modules/sortable.ts';
import {POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {queryElemChildren} from '../utils/dom.ts';
import {errorMessage} from '../modules/errors.ts';
export function initRepoSettingsBranchesDrag() {
const protectedBranchesList = document.querySelector<HTMLElement>('#protected-branches-list');
@@ -23,8 +24,7 @@ export function initRepoSettingsBranchesDrag() {
},
});
} catch (err) {
const errorMessage = String(err);
showErrorToast(`Failed to update branch protection rule priority:, error: ${errorMessage}`);
showErrorToast(`Failed to update branch protection rule priority: ${errorMessage(err)}`);
}
})();
},
+3 -3
View File
@@ -50,9 +50,9 @@ export async function attachTribute(element: HTMLElement) {
const tribute = new Tribute({
collection: [
emojiCollection as TributeCollection<any>,
mentionCollection as TributeCollection<any>,
],
emojiCollection,
mentionCollection,
] as TributeCollection<any>[],
noMatchTemplate: () => '',
});
tribute.attach(element);
+5 -4
View File
@@ -1,5 +1,6 @@
import {encodeURLEncodedBase64, decodeURLEncodedBase64} from '../utils.ts';
import {hideElem, showElem} from '../utils/dom.ts';
import {errorMessage} from '../modules/errors.ts';
import {GET, POST} from '../modules/fetch.ts';
const {appSubUrl} = window.config;
@@ -80,7 +81,7 @@ async function loginPasskey() {
window.location.href = reply?.redirect ?? `${appSubUrl}/`;
} catch (err) {
webAuthnError('general', err.message);
webAuthnError('general', errorMessage(err));
}
}
@@ -104,7 +105,7 @@ async function login2FA() {
await verifyAssertion(credential);
} catch (err) {
if (!options.publicKey.extensions?.appid) {
webAuthnError('general', err.message);
webAuthnError('general', errorMessage(err));
return;
}
delete options.publicKey.extensions.appid;
@@ -114,7 +115,7 @@ async function login2FA() {
});
await verifyAssertion(credential);
} catch (err) {
webAuthnError('general', err.message);
webAuthnError('general', errorMessage(err));
}
}
}
@@ -262,6 +263,6 @@ async function webAuthnRegisterRequest() {
});
await webauthnRegistered(credential);
} catch (err) {
webAuthnError('unknown', err);
webAuthnError('unknown', errorMessage(err));
}
}
+2
View File
@@ -63,6 +63,7 @@ import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFor
import {callInitFunctions} from './modules/init.ts';
import {initRepoViewFileTree} from './features/repo-view-file-tree.ts';
import {initActionsPermissionsForm} from './features/common-actions-permissions.ts';
import {initRefIssueContextPopup} from './features/ref-issue.ts';
import {initGlobalShortcut} from './modules/shortcut.ts';
import {initDevtest} from './modules/devtest.ts';
@@ -98,6 +99,7 @@ const initPerformanceTracer = callInitFunctions([
initImageDiff,
initMarkupAnchors,
initMarkupContent,
initRefIssueContextPopup,
initSshKeyFormParser,
initStopwatch,
initTableSort,
+4 -2
View File
@@ -1,8 +1,10 @@
export function displayError(el: Element, err: Error): void {
import {errorMessage} from '../modules/errors.ts';
export function displayError(el: Element, err: unknown): void {
el.classList.remove('is-loading');
const errorNode = document.createElement('pre');
errorNode.setAttribute('class', 'ui message error markup-block-error');
errorNode.textContent = err.message || String(err);
errorNode.textContent = errorMessage(err);
el.before(errorNode);
el.setAttribute('data-render-done', 'true');
}
-2
View File
@@ -5,7 +5,6 @@ import {initMarkupRenderAsciicast} from './asciicast.ts';
import {initMarkupTasklist} from './tasklist.ts';
import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts';
import {initExternalRenderIframe} from './render-iframe.ts';
import {initMarkupRefIssue} from './refissue.ts';
import {toggleElemClass} from '../utils/dom.ts';
// code that runs for all markup content
@@ -26,6 +25,5 @@ export function initMarkupContent(): void {
initMarkupCodeMermaid(el);
initMarkupCodeMath(el);
initMarkupRenderAsciicast(el);
initMarkupRefIssue(el);
});
}
-42
View File
@@ -1,42 +0,0 @@
import {queryElems} from '../utils/dom.ts';
import {parseIssueHref} from '../utils.ts';
import {createApp} from 'vue';
import {createTippy, getAttachedTippyInstance} from '../modules/tippy.ts';
export function initMarkupRefIssue(el: HTMLElement) {
queryElems(el, '.ref-issue', (el) => {
el.addEventListener('mouseenter', showMarkupRefIssuePopup);
el.addEventListener('focus', showMarkupRefIssuePopup);
});
}
function showMarkupRefIssuePopup(e: MouseEvent | FocusEvent) {
const refIssue = e.currentTarget as HTMLElement;
if (getAttachedTippyInstance(refIssue)) return;
if (refIssue.classList.contains('ref-external-issue')) return;
const issuePathInfo = parseIssueHref(refIssue.getAttribute('href')!);
if (!issuePathInfo.ownerName) return;
const el = document.createElement('div');
const onShowAsync = async () => {
const {default: ContextPopup} = await import('../components/ContextPopup.vue');
const view = createApp(ContextPopup, {
// backend: GetIssueInfo
loadIssueInfoUrl: `${window.config.appSubUrl}/${issuePathInfo.ownerName}/${issuePathInfo.repoName}/issues/${issuePathInfo.indexString}/info`,
});
view.mount(el);
};
const tippy = createTippy(refIssue, {
theme: 'default',
content: el,
trigger: 'mouseenter focus',
placement: 'top-start',
interactive: true,
role: 'dialog',
interactiveBorder: 5,
// onHide() { return false }, // help to keep the popup and debug the layout
onShow: () => { onShowAsync() },
});
tippy.show();
}
+2 -1
View File
@@ -1,4 +1,5 @@
import {generateElemId} from '../utils/dom.ts';
import {errorMessage} from '../modules/errors.ts';
import {isDarkTheme} from '../utils.ts';
import {GET} from '../modules/fetch.ts';
@@ -11,7 +12,7 @@ function safeRenderIframeLink(link: any): string | null {
}
return url.href;
} catch (e) {
console.error(`Failed to parse link: ${link}, error: ${e}`);
console.error(`Failed to parse link: ${link}, error: ${errorMessage(e)}`);
return null;
}
}
+9 -2
View File
@@ -1,9 +1,10 @@
import {showInfoToast, showWarningToast, showErrorToast} from './toast.ts';
import type {Toast} from './toast.ts';
import {registerGlobalInitFunc} from './observer.ts';
import {fomanticQuery} from './fomantic/base.ts';
import {showFomanticModal} from './fomantic/modal.ts';
import {createElementFromHTML} from '../utils/dom.ts';
import {html} from '../utils/html.ts';
import {showGlobalErrorMessage} from './errors.ts';
type LevelMap = Record<string, (message: string) => Toast | null>;
@@ -24,7 +25,7 @@ function initDevtestPage() {
if (modalButtons) {
for (const el of document.querySelectorAll('.ui.modal:not([data-skip-button])')) {
const btn = createElementFromHTML(html`<button class="ui button">${el.id}</button`);
btn.addEventListener('click', () => fomanticQuery(el).modal('show'));
btn.addEventListener('click', () => showFomanticModal(el));
modalButtons.append(btn);
}
}
@@ -54,4 +55,10 @@ function initDevtestPage() {
export function initDevtest() {
registerGlobalInitFunc('initDevtestPage', initDevtestPage);
registerGlobalInitFunc('initDevtestDetailsErrorMessage', () => {
for (let i = 0; i < 2; i++) {
showGlobalErrorMessage('showGlobalErrorMessage single message', 'warning');
showGlobalErrorMessage('showGlobalErrorMessage message with details', 'error', `detail message 1\nvery lo${'o'.repeat(200)}ng line 2\nline 3`);
}
});
}
+23 -2
View File
@@ -1,4 +1,8 @@
import {isGiteaError, showGlobalErrorMessage} from './errors.ts';
import {isGiteaError, processWindowErrorEvent, showGlobalErrorMessage} from './errors.ts';
beforeEach(() => {
document.body.innerHTML = '<div class="page-content"></div>';
});
test('isGiteaError', () => {
expect(isGiteaError('', '')).toBe(true);
@@ -16,7 +20,6 @@ test('isGiteaError', () => {
});
test('showGlobalErrorMessage', () => {
document.body.innerHTML = '<div class="page-content"></div>';
showGlobalErrorMessage('test msg 1');
showGlobalErrorMessage('test msg 2');
showGlobalErrorMessage('test msg 1'); // duplicated
@@ -25,3 +28,21 @@ test('showGlobalErrorMessage', () => {
expect(document.body.innerHTML).toContain('>test msg 2<');
expect(document.querySelectorAll('.js-global-error').length).toEqual(2);
});
test('processWindowErrorEvent renders stack trace in details', () => {
const error = new Error('boom');
error.stack = `Error: boom\n at fn (${window.location.origin}/assets/js/index.js:1:1)`;
processWindowErrorEvent({error, type: 'error'} as ErrorEvent & PromiseRejectionEvent);
expect(document.querySelector('.js-global-error summary')!.textContent).toContain('JavaScript error: boom');
expect(document.querySelector('.js-global-error pre')!.textContent).toContain('/assets/js/index.js:1:1');
});
test('processWindowErrorEvent falls back to message without stack', () => {
processWindowErrorEvent({
error: {message: 'script error'}, type: 'error',
filename: `${window.location.origin}/assets/js/x.js`, lineno: 5, colno: 10,
} as ErrorEvent & PromiseRejectionEvent);
const msgText = document.querySelector('.js-global-error .ui.message')!.textContent;
expect(msgText).toContain('JavaScript error: script error');
expect(msgText).toContain('@ 5:10');
});
+32 -17
View File
@@ -2,25 +2,40 @@
import {html} from '../utils/html.ts';
import type {Intent} from '../types.ts';
export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error') {
const msgContainer = document.querySelector('.page-content') ?? document.body;
if (!msgContainer) {
export function errorMessage(err: unknown): string {
return (err as Error)?.message || String(err);
}
export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error', details?: string) {
const parentContainer = document.querySelector('.page-content') ?? document.body;
if (!parentContainer) {
alert(`${msgType}: ${msg}`);
return;
}
const msgCompact = msg.replace(/\W/g, '').trim(); // compact the message to a data attribute to avoid too many duplicated messages
let msgDiv = msgContainer.querySelector<HTMLDivElement>(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`);
if (!msgDiv) {
// compact the message to a data attribute to avoid too many duplicated messages
const msgCompact = `${msgType}-${msg.trim()}`.replace(/[^-\w\u{80}-\u{10FFFF}]+/gu, '');
let msgContainer = parentContainer.querySelector<HTMLDivElement>(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`);
if (!msgContainer) {
const el = document.createElement('div');
el.innerHTML = html`<div class="ui container js-global-error tw-my-[--page-spacing]"><div class="ui ${msgType} message tw-text-center tw-whitespace-pre-line"></div></div>`;
msgDiv = el.childNodes[0] as HTMLDivElement;
el.innerHTML = html`<div class="ui container js-global-error tw-my-[--page-spacing]"><details class="ui ${msgType} message"><summary></summary></details></div>`;
msgContainer = el.childNodes[0] as HTMLDivElement;
}
// merge duplicated messages into "the message (count)" format
const msgCount = Number(msgDiv.getAttribute(`data-global-error-msg-count`)) + 1;
msgDiv.setAttribute(`data-global-error-msg-compact`, msgCompact);
msgDiv.setAttribute(`data-global-error-msg-count`, msgCount.toString());
msgDiv.querySelector('.ui.message')!.textContent = msg + (msgCount > 1 ? ` (${msgCount})` : '');
msgContainer.prepend(msgDiv);
const msgCount = Number(msgContainer.getAttribute(`data-global-error-msg-count`)) + 1;
msgContainer.setAttribute(`data-global-error-msg-compact`, msgCompact);
msgContainer.setAttribute(`data-global-error-msg-count`, msgCount.toString());
const msgElem = msgContainer.querySelector('details')!;
const msgSummary = msgElem.querySelector('summary')!;
msgSummary.textContent = msg + (msgCount > 1 ? ` (${msgCount})` : '');
if (details) {
let msgDetailsPre = msgElem.querySelector('pre');
if (!msgDetailsPre) msgDetailsPre = document.createElement('pre');
msgDetailsPre.textContent = details;
msgElem.append(msgDetailsPre);
}
parentContainer.prepend(msgContainer);
}
// Detect whether an error originated from Gitea's own scripts, not from
@@ -49,9 +64,9 @@ export function processWindowErrorEvent({error, reason, message, type, filename,
// Filter out errors from browser extensions or other non-Gitea scripts.
if (!isGiteaError(filename ?? '', err?.stack ?? '')) return;
let msg = err?.message ?? message;
if (lineno) msg += ` (${filename} @ ${lineno}:${colno})`;
const dot = msg.endsWith('.') ? '' : '.';
const renderedType = type === 'unhandledrejection' ? 'promise rejection' : type;
showGlobalErrorMessage(`JavaScript ${renderedType}: ${msg}${dot} Open browser console to see more details.`);
let msg = err?.message ?? message;
if (!err?.stack && lineno) msg += ` (${filename} @ ${lineno}:${colno})`;
const dot = msg.endsWith('.') ? '' : '.';
showGlobalErrorMessage(`JavaScript ${renderedType}: ${msg}${dot} Open browser console to see more details.`, 'error', err?.stack);
}
-6
View File
@@ -1,11 +1,8 @@
import {initAriaCheckboxPatch} from './fomantic/checkbox.ts';
import {initAriaFormFieldPatch} from './fomantic/form.ts';
import {initAriaDropdownPatch} from './fomantic/dropdown.ts';
import {initAriaModalPatch} from './fomantic/modal.ts';
import {initFomanticTransition} from './fomantic/transition.ts';
import {initFomanticDimmer} from './fomantic/dimmer.ts';
import {svg} from '../svg.ts';
import {initFomanticTab} from './fomantic/tab.ts';
export const fomanticMobileScreen = window.matchMedia('only screen and (max-width: 767.98px)');
@@ -24,11 +21,8 @@ export function initGiteaFomantic() {
initFomanticTransition();
initFomanticDimmer();
initFomanticTab();
// Use the patches to improve accessibility, these patches are designed to be as independent as possible, make it easy to modify or remove in the future.
initAriaCheckboxPatch();
initAriaFormFieldPatch();
initAriaDropdownPatch();
initAriaModalPatch();
}
+1 -1
View File
@@ -50,7 +50,7 @@ However, the templates still have the Fomantic-style HTML layout:
</div>
```
We call `initAriaCheckboxPatch` to link the `input` and `label` which makes clicking the
We call `initAriaLabels` to link the `input` and `label` which makes clicking the
label etc. work. There is still a problem: These checkboxes are not friendly to screen readers,
so we add IDs to all the Fomantic UI checkboxes automatically by JS. If the `label` part is empty,
then the checkbox needs to get the `aria-label` attribute manually.
+30 -2
View File
@@ -1,6 +1,6 @@
import {generateElemId} from '../../utils/dom.ts';
import {generateElemId, queryElems} from '../../utils/dom.ts';
export function linkLabelAndInput(label: Element, input: Element) {
function linkLabelAndInput(label: Element, input: Element) {
const labelFor = label.getAttribute('for');
const inputId = input.getAttribute('id');
@@ -13,6 +13,34 @@ export function linkLabelAndInput(label: Element, input: Element) {
}
}
function patchLabels(parent: ParentNode, containerSelector: string, labelSelector: string, inputSelector: string, marker: string) {
// Sample layout for this function:
// <div parent>
// <div container><label/><input/></div>
// <div container><label/><input/></div>
// </div>
//
// OR the parent is also the container:
// <div parent container><label/><input/></div>
const patchLabelContainer = (container: Element) => {
if (container.hasAttribute(marker)) return;
const label = container.querySelector(labelSelector);
const input = container.querySelector(inputSelector);
if (!label || !input) return;
linkLabelAndInput(label, input);
container.setAttribute(marker, 'true');
};
queryElems(parent, containerSelector, patchLabelContainer);
if (parent instanceof Element && parent.matches(containerSelector)) patchLabelContainer(parent);
}
// link labels and inputs in `.ui.checkbox` and `.ui.form .field` so labels are clickable and accessible
export function initAriaLabels(container: ParentNode) {
patchLabels(container, '.ui.checkbox', 'label', 'input', 'data-checkbox-patched');
patchLabels(container, '.ui.form .field', ':scope > label', ':scope > input, :scope > select', 'data-field-patched');
}
export function fomanticQuery(s: string | Element | NodeListOf<Element>): ReturnType<typeof $> {
// intentionally make it only work for query selector, it isn't used for creating HTML elements (for safety)
return typeof s === 'string' ? $(document).find(s) : $(s);
-13
View File
@@ -1,13 +0,0 @@
import {linkLabelAndInput} from './base.ts';
export function initAriaCheckboxPatch() {
// link the label and the input element so it's clickable and accessible
for (const el of document.querySelectorAll('.ui.checkbox')) {
if (el.hasAttribute('data-checkbox-patched')) continue;
const label = el.querySelector('label');
const input = el.querySelector('input');
if (!label || !input) continue;
linkLabelAndInput(label, input);
el.setAttribute('data-checkbox-patched', 'true');
}
}
+1 -1
View File
@@ -324,7 +324,7 @@ export function hideScopedEmptyDividers(container: Element) {
handleScopeSwitch(itemScope);
}
if (!isHidden(item)) {
curScopeVisibleItems.push(item as HTMLElement);
curScopeVisibleItems.push(item);
}
}
handleScopeSwitch('');
-13
View File
@@ -1,13 +0,0 @@
import {linkLabelAndInput} from './base.ts';
export function initAriaFormFieldPatch() {
// link the label and the input element so it's clickable and accessible
for (const el of document.querySelectorAll('.ui.form .field')) {
if (el.hasAttribute('data-field-patched')) continue;
const label = el.querySelector(':scope > label');
const input = el.querySelector(':scope > input, :scope > select');
if (!label || !input) continue;
linkLabelAndInput(label, input);
el.setAttribute('data-field-patched', 'true');
}
}
+21
View File
@@ -2,6 +2,27 @@ import type {FomanticInitFunction} from '../../types.ts';
import {queryElems} from '../../utils/dom.ts';
import {hideToastsFrom} from '../toast.ts';
type ModalOpts = {
closable?: boolean;
onApprove?: (this: HTMLElement) => boolean | void;
onShow?: (this: HTMLElement) => void | Promise<void>;
onHide?: (this: HTMLElement) => void;
onHidden?: (this: HTMLElement) => void;
};
// thin wrapper around Fomantic's jQuery modal plugin so callers don't have to touch jQuery or fomanticQuery
export function showFomanticModal(el: Element | null, opts: ModalOpts = {}) {
if (!el) return;
const $el = $(el);
if (Object.keys(opts).length) $el.modal(opts);
$el.modal('show');
}
export function hideFomanticModal(el: Element | null) {
if (!el) return;
$(el).modal('hide');
}
const fomanticModalFn = $.fn.modal;
// use our own `$.fn.modal` to patch Fomantic's modal module
+15 -15
View File
@@ -1,18 +1,18 @@
import {queryElemSiblings} from '../../utils/dom.ts';
export function initFomanticTab() {
$.fn.tab = function (this: any) {
for (const elBtn of this) {
const tabName = elBtn.getAttribute('data-tab');
if (!tabName) continue;
elBtn.addEventListener('click', () => {
const elTab = document.querySelector(`.ui.tab[data-tab="${tabName}"]`)!;
queryElemSiblings(elTab, `.ui.tab`, (el) => el.classList.remove('active'));
queryElemSiblings(elBtn, `[data-tab]`, (el) => el.classList.remove('active'));
elBtn.classList.add('active');
elTab.classList.add('active');
});
}
return this;
};
export function initTabSwitcher(tabItemContainer: Element) {
// Clicking a `.item[data-tab]` menu item activates the matching `.ui.tab[data-tab=...]` panel
// This design is from Fomantic UI, and it has problems like :
// * The panel selector is global, callers should make sure the "data-tab" values don't conflict on the same page
const tabItems = tabItemContainer.querySelectorAll('.item[data-tab]');
for (const elItem of tabItems) {
const tabName = elItem.getAttribute('data-tab')!;
elItem.addEventListener('click', () => {
const elPanel = document.querySelector(`.ui.tab[data-tab="${tabName}"]`)!;
queryElemSiblings(elPanel, '.ui.tab', (el) => el.classList.remove('active'));
queryElemSiblings(elItem, '.item[data-tab]', (el) => el.classList.remove('active'));
elItem.classList.add('active');
elPanel.classList.add('active');
});
}
}
+16
View File
@@ -5,6 +5,7 @@ export type ActionsArtifactStatus = 'expired' | 'completed';
export type ActionsRun = {
repoId: number,
link: string,
viewLink: string,
title: string,
titleHTML: string,
status: ActionsRunStatus,
@@ -17,6 +18,8 @@ export type ActionsRun = {
workflowID: string,
workflowLink: string,
isSchedule: boolean,
runAttempt: number,
attempts: Array<ActionsRunAttempt>,
duration: string,
triggeredAt: number,
triggerEvent: string,
@@ -38,8 +41,21 @@ export type ActionsRun = {
},
};
export type ActionsRunAttempt = {
attempt: number;
status: ActionsRunStatus;
done: boolean;
link: string;
current: boolean;
latest: boolean;
triggeredAt: number;
triggerUserName: string;
triggerUserLink: string;
};
export type ActionsJob = {
id: number;
link: string;
jobId: string;
name: string;
status: ActionsRunStatus;
+17 -13
View File
@@ -1,17 +1,18 @@
import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';
import type {Promisable} from '../types.ts';
import type {InitPerformanceTracer} from './init.ts';
import {initAriaLabels} from './fomantic/base.ts';
let globalSelectorObserverInited = false;
type SelectorHandler = {selector: string, handler: (el: HTMLElement) => void};
const selectorHandlers: SelectorHandler[] = [];
type SelectorHandler<T extends Element> = {selector: string, handler: (el: T) => void};
const selectorHandlers: SelectorHandler<Element>[] = [];
type GlobalEventFunc<T extends HTMLElement, E extends Event> = (el: T, e: E) => Promisable<void>;
const globalEventFuncs: Record<string, GlobalEventFunc<HTMLElement, Event>> = {};
type GlobalInitFunc<T extends HTMLElement> = (el: T) => Promisable<void>;
const globalInitFuncs: Record<string, GlobalInitFunc<HTMLElement>> = {};
type GlobalInitFunc<T extends Element> = (el: T) => Promisable<void>;
const globalInitFuncs: Record<string, GlobalInitFunc<Element>> = {};
// It handles the global events for all `<div data-global-click="onSomeElemClick"></div>` elements.
export function registerGlobalEventFunc<T extends HTMLElement, E extends Event>(event: string, name: string, func: GlobalEventFunc<T, E>) {
@@ -23,32 +24,32 @@ export function registerGlobalEventFunc<T extends HTMLElement, E extends Event>(
// ATTENTION: For most cases, it's recommended to use registerGlobalInitFunc instead,
// Because this selector-based approach is less efficient and less maintainable.
// But if there are already a lot of elements on many pages, this selector-based approach is more convenient for exiting code.
export function registerGlobalSelectorFunc(selector: string, handler: (el: HTMLElement) => void) {
selectorHandlers.push({selector, handler});
export function registerGlobalSelectorFunc<T extends Element>(selector: string, handler: (el: T) => void) {
selectorHandlers.push({selector, handler: handler as (el: Element) => void});
// Then initAddedElementObserver will call this handler for all existing elements after all handlers are added.
// This approach makes the init stage only need to do one "querySelectorAll".
if (!globalSelectorObserverInited) return;
for (const el of document.querySelectorAll<HTMLElement>(selector)) {
for (const el of document.querySelectorAll<T>(selector)) {
handler(el);
}
}
// It handles the global init functions for all `<div data-global-int="initSomeElem"></div>` elements.
export function registerGlobalInitFunc<T extends HTMLElement>(name: string, handler: GlobalInitFunc<T>) {
globalInitFuncs[name] = handler as GlobalInitFunc<HTMLElement>;
globalInitFuncs[name] = handler as GlobalInitFunc<Element>;
// The "global init" functions are managed internally and called by callGlobalInitFunc
// They must be ready before initGlobalSelectorObserver is called.
if (globalSelectorObserverInited) throw new Error('registerGlobalInitFunc() must be called before initGlobalSelectorObserver()');
}
function callGlobalInitFunc(el: HTMLElement) {
function callGlobalInitFunc(el: Element) {
// TODO: GLOBAL-INIT-MULTIPLE-FUNCTIONS: maybe in the future we need to extend it to support multiple functions, for example: `data-global-init="func1 func2 func3"`
const initFunc = el.getAttribute('data-global-init')!;
const func = globalInitFuncs[initFunc];
if (!func) throw new Error(`Global init function "${initFunc}" not found`);
// when an element node is removed and added again, it should not be re-initialized again.
type GiteaGlobalInitElement = Partial<HTMLElement> & {_giteaGlobalInited: boolean};
type GiteaGlobalInitElement = Partial<Element> & {_giteaGlobalInited: boolean};
if ((el as GiteaGlobalInitElement)._giteaGlobalInited) return;
(el as GiteaGlobalInitElement)._giteaGlobalInited = true;
@@ -80,20 +81,23 @@ export function initGlobalSelectorObserver(perfTracer: InitPerformanceTracer | n
const mutation = mutationList[i];
const len = mutation.addedNodes.length;
for (let i = 0; i < len; i++) {
const addedNode = mutation.addedNodes[i] as HTMLElement;
const addedNode = mutation.addedNodes[i] as ParentNode;
if (!isDocumentFragmentOrElementNode(addedNode)) continue;
initAriaLabels(addedNode);
for (const {selector, handler} of selectorHandlers) {
if (addedNode.matches(selector)) {
if ((addedNode instanceof Element) && addedNode.matches(selector)) {
handler(addedNode);
}
for (const el of addedNode.querySelectorAll<HTMLElement>(selector)) {
for (const el of addedNode.querySelectorAll?.<HTMLElement>(selector) ?? []) {
handler(el);
}
}
}
}
});
initAriaLabels(document);
if (perfTracer) {
for (const {selector, handler} of selectorHandlers) {
perfTracer.recordCall(`initGlobalSelectorObserver ${selector}`, () => {
+6
View File
@@ -32,6 +32,7 @@ import octiconFile from '../../public/assets/img/svg/octicon-file.svg';
import octiconFileCode from '../../public/assets/img/svg/octicon-file-code.svg';
import octiconFileDirectoryFill from '../../public/assets/img/svg/octicon-file-directory-fill.svg';
import octiconFileDirectoryOpenFill from '../../public/assets/img/svg/octicon-file-directory-open-fill.svg';
import octiconFileRemoved from '../../public/assets/img/svg/octicon-file-removed.svg';
import octiconFileSubmodule from '../../public/assets/img/svg/octicon-file-submodule.svg';
import octiconFileSymlinkFile from '../../public/assets/img/svg/octicon-file-symlink-file.svg';
import octiconFilter from '../../public/assets/img/svg/octicon-filter.svg';
@@ -44,7 +45,9 @@ import octiconGitPullRequestClosed from '../../public/assets/img/svg/octicon-git
import octiconGitPullRequestDraft from '../../public/assets/img/svg/octicon-git-pull-request-draft.svg';
import octiconGrabber from '../../public/assets/img/svg/octicon-grabber.svg';
import octiconHeading from '../../public/assets/img/svg/octicon-heading.svg';
import octiconHistory from '../../public/assets/img/svg/octicon-history.svg';
import octiconHorizontalRule from '../../public/assets/img/svg/octicon-horizontal-rule.svg';
import octiconHome from '../../public/assets/img/svg/octicon-home.svg';
import octiconImage from '../../public/assets/img/svg/octicon-image.svg';
import octiconIssueClosed from '../../public/assets/img/svg/octicon-issue-closed.svg';
import octiconIssueOpened from '../../public/assets/img/svg/octicon-issue-opened.svg';
@@ -116,6 +119,7 @@ const svgs = {
'octicon-file-code': octiconFileCode,
'octicon-file-directory-fill': octiconFileDirectoryFill,
'octicon-file-directory-open-fill': octiconFileDirectoryOpenFill,
'octicon-file-removed': octiconFileRemoved,
'octicon-file-submodule': octiconFileSubmodule,
'octicon-file-symlink-file': octiconFileSymlinkFile,
'octicon-filter': octiconFilter,
@@ -128,7 +132,9 @@ const svgs = {
'octicon-git-pull-request-draft': octiconGitPullRequestDraft,
'octicon-grabber': octiconGrabber,
'octicon-heading': octiconHeading,
'octicon-history': octiconHistory,
'octicon-horizontal-rule': octiconHorizontalRule,
'octicon-home': octiconHome,
'octicon-image': octiconImage,
'octicon-issue-closed': octiconIssueClosed,
'octicon-issue-opened': octiconIssueOpened,
+2
View File
@@ -42,12 +42,14 @@ export type Issue = {
body: string,
state: 'open' | 'closed',
created_at: string,
html_url: string,
pull_request?: {
draft: boolean;
merged: boolean;
},
repository: {
full_name: string,
html_url: string,
},
labels: Array<string>,
};
-7
View File
@@ -2,7 +2,6 @@ import {
dirname, basename, extname, formatBytes, isObject, stripTags, parseIssueHref,
translateMonth, translateDay, blobToDataURI,
toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, isImageFile, isVideoFile, parseRepoOwnerPathInfo,
urlQueryEscape,
} from './utils.ts';
test('dirname', () => {
@@ -34,12 +33,6 @@ test('stripTags', () => {
expect(stripTags('<a>test</a>')).toEqual('test');
});
test('urlQueryEscape', () => {
const input = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
const expected = '%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~';
expect(urlQueryEscape(input)).toEqual(expected);
});
test('parseIssueHref', () => {
expect(parseIssueHref('/owner/repo/issues/1')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues', indexString: '1'});
expect(parseIssueHref('/owner/repo/pulls/1?query')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'pulls', indexString: '1'});
-9
View File
@@ -51,15 +51,6 @@ export function stripTags(text: string): string {
return text;
}
export function urlQueryEscape(s: string) {
// See "TestQueryEscape" in backend
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#encoding_for_rfc3986
return encodeURIComponent(s).replace(
/[!'()*]/g,
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
);
}
export function parseIssueHref(href: string): IssuePathInfo {
// FIXME: it should use pathname and trim the appSubUrl ahead
const path = (href || '').replace(/[#?].*$/, '');
+2 -1
View File
@@ -2,6 +2,7 @@ import emojis from '../../../assets/emoji.json' with {type: 'json'};
import {GET} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {parseIssuePageInfo} from '../utils.ts';
import {errorMessage} from '../modules/errors.ts';
import type {Issue, Mention} from '../types.ts';
const maxMatches = 6;
@@ -47,7 +48,7 @@ export function fetchMentions(mentionsUrl: string): Promise<Mention[]> {
if (!res.ok) throw new Error(res.statusText);
return await res.json() as Mention[];
} catch (e) {
showErrorToast(`Failed to load mentions: ${e}`);
showErrorToast(`Failed to load mentions: ${errorMessage(e)}`);
return [];
}
})();
+18 -4
View File
@@ -1,8 +1,22 @@
import {linkifyURLs, pathEscapeSegments, toOriginUrl} from './url.ts';
import {linkifyURLs, pathEscape, pathEscapeSegments, toOriginUrl, urlQueryEscape} from './url.ts';
test('pathEscapeSegments', () => {
expect(pathEscapeSegments('a/b/c')).toEqual('a/b/c');
expect(pathEscapeSegments('a/b/ c')).toEqual('a/b/%20c');
describe('escape', () => {
const queryNonAscii = " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
test('urlQueryEscape', () => {
const expected = '+%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~';
expect(urlQueryEscape(queryNonAscii)).toEqual(expected);
});
test('pathEscape', () => {
const expected = '%20%21%22%23$%25&%27%28%29%2A+%2C-.%2F:%3B%3C=%3E%3F@%5B%5C%5D%5E_%60%7B%7C%7D~';
expect(pathEscape(queryNonAscii)).toEqual(expected);
});
test('pathEscapeSegments', () => {
expect(pathEscapeSegments('a/b/c')).toEqual('a/b/c');
expect(pathEscapeSegments('a/b/ c')).toEqual('a/b/%20c');
expect(pathEscapeSegments('a/b+c')).toEqual('a/b+c');
});
});
test('linkifyURLs', () => {
+29 -1
View File
@@ -1,5 +1,33 @@
export function urlQueryEscape(s: string) {
// See "TestQueryEscape" in backend
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#encoding_for_rfc3986
return encodeURIComponent(s).replace(
/[!'()*]/g,
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
).replaceAll('%20', '+');
}
export function pathEscape(s: string): string {
// See "TestPathEscape" in backend
return encodeURIComponent(s).replace(
/[!'()*]/g,
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
).replaceAll(/%(\w\w)/g, (v) => {
switch (v) {
case '%24': return '$';
case '%26': return '&';
case '%2B': return '+';
case '%3A': return ':';
case '%3D': return '=';
case '%40': return '@';
default: return v;
}
});
}
export function pathEscapeSegments(s: string): string {
return s.split('/').map(encodeURIComponent).join('/');
// The same as backend's PathEscapeSegments
return s.split('/').map(pathEscape).join('/');
}
// Match HTML tags (to skip) or URLs (to linkify) in HTML content
+8 -8
View File
@@ -3,13 +3,13 @@ import {addDelegatedEventListener, generateElemId, isDocumentFragmentOrElementNo
import octiconKebabHorizontal from '../../../public/assets/img/svg/octicon-kebab-horizontal.svg';
window.customElements.define('overflow-menu', class extends HTMLElement {
popup: HTMLDivElement;
overflowItems: Array<HTMLElement>;
button: HTMLButtonElement | null;
menuItemsEl: HTMLElement;
resizeObserver: ResizeObserver;
mutationObserver: MutationObserver;
lastWidth: number;
popup!: HTMLDivElement;
overflowItems: Array<HTMLElement> = [];
button: HTMLButtonElement | null = null;
menuItemsEl!: HTMLElement;
resizeObserver!: ResizeObserver;
mutationObserver!: MutationObserver;
lastWidth!: number;
updateButtonActivationState() {
if (!this.button || !this.popup) return;
@@ -100,7 +100,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
const itemOverFlowMenuButton = this.querySelector<HTMLButtonElement>('.overflow-menu-button');
// move items in popup back into the menu items for subsequent measurement
for (const item of this.overflowItems || []) {
for (const item of this.overflowItems) {
if (!itemFlexSpace || item.getAttribute('data-after-flex-space')) {
this.menuItemsEl.append(item);
} else {
@@ -54,6 +54,26 @@ test('switches to datetime format after default threshold', async () => {
expect(getText(el)).toMatch(/on [A-Z][a-z]{2} \d{1,2}/);
});
test('accepts unix seconds as integer string', async () => {
const el = createRelativeTime(String(Math.floor(Date.now() / 1000) - 3 * 60));
await Promise.resolve();
expect(getText(el)).toBe('3 minutes ago');
});
test('ignores fractional unix seconds', async () => {
const el = createRelativeTime('1700000000.5');
el.shadowRoot!.textContent = 'fallback';
await Promise.resolve();
expect(getText(el)).toBe('fallback');
});
test('ignores negative unix seconds', async () => {
const el = createRelativeTime('-86400');
el.shadowRoot!.textContent = 'fallback';
await Promise.resolve();
expect(getText(el)).toBe('fallback');
});
test('ignores invalid datetime', async () => {
const el = createRelativeTime('bogus');
el.shadowRoot!.textContent = 'fallback';
@@ -61,6 +81,13 @@ test('ignores invalid datetime', async () => {
expect(getText(el)).toBe('fallback');
});
test('ignores partial numeric datetime', async () => {
const el = createRelativeTime('123abc');
el.shadowRoot!.textContent = 'fallback';
await Promise.resolve();
expect(getText(el)).toBe('fallback');
});
test('handles empty datetime', async () => {
const el = createRelativeTime('');
el.shadowRoot!.textContent = 'fallback';
@@ -109,6 +136,18 @@ test('respects lang from parent element', async () => {
expect(getText(el)).toBe('vor 3 Tagen');
});
test('falls back when navigator.language is invalid', async () => {
vi.spyOn(navigator, 'language', 'get').mockReturnValue('undefined');
try {
const el = document.createElement('relative-time');
el.setAttribute('datetime', new Date(Date.now() - 3 * 60 * 1000).toISOString());
await Promise.resolve();
expect(getText(el)).toBe('3 minutes ago');
} finally {
vi.restoreAllMocks();
}
});
test('switches to datetime with P1D threshold', async () => {
const el = createRelativeTime(new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), {
lang: 'en-US',
+9 -7
View File
@@ -28,6 +28,7 @@ type FormatStyle = 'long' | 'short' | 'narrow';
const unitNames = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second'] as const;
const durationRe = /^[-+]?P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
const unixSecondsRe = /^\d+$/;
function parseDurationMs(str: string): number {
const m = durationRe.exec(str);
@@ -258,13 +259,13 @@ class RelativeTime extends HTMLElement {
}
get #lang(): string {
const lang = this.closest('[lang]')?.getAttribute('lang');
if (lang) {
for (const candidate of [this.closest('[lang]')?.getAttribute('lang'), navigator.language]) {
if (!candidate) continue;
try {
return new Intl.Locale(lang).toString();
} catch { /* invalid locale, fall through */ }
return String(new Intl.Locale(candidate));
} catch {}
}
return navigator.language ?? 'en';
return 'en';
}
get second(): 'numeric' | '2-digit' | undefined {
@@ -364,7 +365,8 @@ class RelativeTime extends HTMLElement {
}
get date(): Date | null {
const parsed = Date.parse(this.datetime);
const dt = this.datetime;
const parsed = unixSecondsRe.test(dt) ? Number(dt) * 1000 : Date.parse(dt);
return Number.isNaN(parsed) ? null : new Date(parsed);
}
@@ -432,7 +434,7 @@ class RelativeTime extends HTMLElement {
const value = d[`${unit}s` as keyof Duration] as number;
if (value || (duration.blank && unit === 'second')) {
try {
parts.push(new Intl.NumberFormat(locale, {style: 'unit', unit, unitDisplay: style} as Intl.NumberFormatOptions).format(value));
parts.push(new Intl.NumberFormat(locale, {style: 'unit', unit, unitDisplay: style}).format(value));
} catch { // PaleMoon lacks Intl.NumberFormat unit style support
parts.push(`${value} ${value === 1 ? unit : `${unit}s`}`);
}