Workflow Artifact Info Hover (#37100)
Add expiry metadata to action artifacts in the run view and show it on hover. --------- Signed-off-by: Nicolas <bircni@icloud.com> Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: Claude (Opus 4.6) <noreply@anthropic.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
@@ -183,6 +183,7 @@ type ActionArtifactMeta struct {
|
|||||||
ArtifactName string
|
ArtifactName string
|
||||||
FileSize int64
|
FileSize int64
|
||||||
Status ArtifactStatus
|
Status ArtifactStatus
|
||||||
|
ExpiredUnix timeutil.TimeStamp
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListUploadedArtifactsMeta returns all uploaded artifacts meta of a run
|
// ListUploadedArtifactsMeta returns all uploaded artifacts meta of a run
|
||||||
@@ -191,7 +192,7 @@ func ListUploadedArtifactsMeta(ctx context.Context, repoID, runID int64) ([]*Act
|
|||||||
return arts, db.GetEngine(ctx).Table("action_artifact").
|
return arts, db.GetEngine(ctx).Table("action_artifact").
|
||||||
Where("repo_id=? AND run_id=? AND (status=? OR status=?)", repoID, runID, ArtifactStatusUploadConfirmed, ArtifactStatusExpired).
|
Where("repo_id=? AND run_id=? AND (status=? OR status=?)", repoID, runID, ArtifactStatusUploadConfirmed, ArtifactStatusExpired).
|
||||||
GroupBy("artifact_name").
|
GroupBy("artifact_name").
|
||||||
Select("artifact_name, sum(file_size) as file_size, max(status) as status").
|
Select("artifact_name, sum(file_size) as file_size, max(status) as status, max(expired_unix) as expired_unix").
|
||||||
Find(&arts)
|
Find(&arts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,7 @@
|
|||||||
"unpin": "Unpin",
|
"unpin": "Unpin",
|
||||||
"artifacts": "Artifacts",
|
"artifacts": "Artifacts",
|
||||||
"expired": "Expired",
|
"expired": "Expired",
|
||||||
|
"artifact_expires_at": "Expires at %s",
|
||||||
"confirm_delete_artifact": "Are you sure you want to delete the artifact '%s'?",
|
"confirm_delete_artifact": "Are you sure you want to delete the artifact '%s'?",
|
||||||
"archived": "Archived",
|
"archived": "Archived",
|
||||||
"concept_system_global": "Global",
|
"concept_system_global": "Global",
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ func MockActionsView(ctx *context.Context) {
|
|||||||
func MockActionsRunsJobs(ctx *context.Context) {
|
func MockActionsRunsJobs(ctx *context.Context) {
|
||||||
runID := ctx.PathParamInt64("run")
|
runID := ctx.PathParamInt64("run")
|
||||||
|
|
||||||
|
alignTime := func(v, unit int64) int64 {
|
||||||
|
return (v + unit) / unit * unit
|
||||||
|
}
|
||||||
resp := &actions.ViewResponse{}
|
resp := &actions.ViewResponse{}
|
||||||
resp.State.Run.RepoID = 12345
|
resp.State.Run.RepoID = 12345
|
||||||
resp.State.Run.TitleHTML = `mock run title <a href="/">link</a>`
|
resp.State.Run.TitleHTML = `mock run title <a href="/">link</a>`
|
||||||
@@ -96,24 +99,28 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{
|
resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{
|
||||||
Name: "artifact-a",
|
Name: "artifact-a",
|
||||||
Size: 100 * 1024,
|
Size: 100 * 1024,
|
||||||
Status: "expired",
|
Status: "expired",
|
||||||
|
ExpiresUnix: alignTime(time.Now().Add(-24*time.Hour).Unix(), 3600),
|
||||||
})
|
})
|
||||||
resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{
|
resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{
|
||||||
Name: "artifact-b",
|
Name: "artifact-b",
|
||||||
Size: 1024 * 1024,
|
Size: 1024 * 1024,
|
||||||
Status: "completed",
|
Status: "completed",
|
||||||
|
ExpiresUnix: alignTime(time.Now().Add(24*time.Hour).Unix(), 3600),
|
||||||
})
|
})
|
||||||
resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{
|
resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{
|
||||||
Name: "artifact-very-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong",
|
Name: "artifact-very-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong",
|
||||||
Size: 100 * 1024,
|
Size: 100 * 1024,
|
||||||
Status: "expired",
|
Status: "expired",
|
||||||
|
ExpiresUnix: alignTime(time.Now().Add(-24*time.Hour).Unix(), 3600),
|
||||||
})
|
})
|
||||||
resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{
|
resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{
|
||||||
Name: "artifact-really-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong",
|
Name: "artifact-really-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong",
|
||||||
Size: 1024 * 1024,
|
Size: 1024 * 1024,
|
||||||
Status: "completed",
|
Status: "completed",
|
||||||
|
ExpiresUnix: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
|
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
|
||||||
|
|||||||
@@ -248,9 +248,10 @@ type ViewRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ArtifactsViewItem struct {
|
type ArtifactsViewItem struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
ExpiresUnix int64 `json:"expiresUnix"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ViewResponse struct {
|
type ViewResponse struct {
|
||||||
@@ -344,9 +345,10 @@ func getActionsViewArtifacts(ctx context.Context, repoID, runID int64) (artifact
|
|||||||
}
|
}
|
||||||
for _, art := range artifacts {
|
for _, art := range artifacts {
|
||||||
artifactsViewItems = append(artifactsViewItems, &ArtifactsViewItem{
|
artifactsViewItems = append(artifactsViewItems, &ArtifactsViewItem{
|
||||||
Name: art.ArtifactName,
|
Name: art.ArtifactName,
|
||||||
Size: art.FileSize,
|
Size: art.FileSize,
|
||||||
Status: util.Iif(art.Status == actions_model.ArtifactStatusExpired, "expired", "completed"),
|
Status: util.Iif(art.Status == actions_model.ArtifactStatusExpired, "expired", "completed"),
|
||||||
|
ExpiresUnix: int64(art.ExpiredUnix),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return artifactsViewItems, nil
|
return artifactsViewItems, nil
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
<div>numeric: <relative-time datetime="2024-03-11" threshold="P0Y" prefix="" weekday="" year="" day="numeric" month="numeric"></relative-time></div>
|
<div>numeric: <relative-time datetime="2024-03-11" threshold="P0Y" prefix="" weekday="" year="" day="numeric" month="numeric"></relative-time></div>
|
||||||
<div>weekday: <relative-time datetime="2024-03-11" threshold="P0Y" prefix="" weekday="long" year="" month="numeric"></relative-time></div>
|
<div>weekday: <relative-time datetime="2024-03-11" threshold="P0Y" prefix="" weekday="long" year="" month="numeric"></relative-time></div>
|
||||||
<div>with time: <relative-time datetime="2024-03-11T19:00:00-05:00" threshold="P0Y" prefix="" weekday="long" year="" month="numeric"></relative-time></div>
|
<div>with time: <relative-time datetime="2024-03-11T19:00:00-05:00" threshold="P0Y" prefix="" weekday="long" year="" month="numeric"></relative-time></div>
|
||||||
|
<div>minutes: <relative-time datetime="2024-03-11T19:30:45-05:00" threshold="P0Y" prefix="" weekday="" year="numeric" month="short" hour="numeric" minute="2-digit"></relative-time></div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2>Threshold</h2>
|
<h2>Threshold</h2>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
data-locale-status-blocked="{{ctx.Locale.Tr "actions.status.blocked"}}"
|
data-locale-status-blocked="{{ctx.Locale.Tr "actions.status.blocked"}}"
|
||||||
data-locale-artifacts-title="{{ctx.Locale.Tr "artifacts"}}"
|
data-locale-artifacts-title="{{ctx.Locale.Tr "artifacts"}}"
|
||||||
data-locale-artifact-expired="{{ctx.Locale.Tr "expired"}}"
|
data-locale-artifact-expired="{{ctx.Locale.Tr "expired"}}"
|
||||||
|
data-locale-artifact-expires-at="{{ctx.Locale.Tr "artifact_expires_at"}}"
|
||||||
data-locale-confirm-delete-artifact="{{ctx.Locale.Tr "confirm_delete_artifact"}}"
|
data-locale-confirm-delete-artifact="{{ctx.Locale.Tr "confirm_delete_artifact"}}"
|
||||||
data-locale-show-timestamps="{{ctx.Locale.Tr "show_timestamps"}}"
|
data-locale-show-timestamps="{{ctx.Locale.Tr "show_timestamps"}}"
|
||||||
data-locale-show-log-seconds="{{ctx.Locale.Tr "show_log_seconds"}}"
|
data-locale-show-log-seconds="{{ctx.Locale.Tr "show_log_seconds"}}"
|
||||||
|
|||||||
@@ -36,3 +36,11 @@ h4.divider {
|
|||||||
.divider.divider-text::after {
|
.divider.divider-text::after {
|
||||||
margin-left: .75em;
|
margin-left: .75em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-divider {
|
||||||
|
display: inline-block;
|
||||||
|
border-left: 1px solid var(--color-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
width: 1px;
|
||||||
|
margin: 0 var(--gap-inline);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import {buildArtifactTooltipHtml} from './ActionRunArtifacts.ts';
|
||||||
|
import {normalizeTestHtml} from '../utils/testhelper.ts';
|
||||||
|
|
||||||
|
describe('buildArtifactTooltipHtml', () => {
|
||||||
|
test('active artifact', () => {
|
||||||
|
const result = buildArtifactTooltipHtml({
|
||||||
|
name: 'artifact.zip',
|
||||||
|
size: 1024 * 1024,
|
||||||
|
status: 'completed',
|
||||||
|
expiresUnix: Date.UTC(2026, 2, 20, 12, 0, 0) / 1000,
|
||||||
|
}, '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>
|
||||||
|
<span> (extra)</span>
|
||||||
|
<span class="inline-divider">,</span>
|
||||||
|
<span>1.0 MiB</span>
|
||||||
|
</span>
|
||||||
|
`));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no expiry', () => {
|
||||||
|
const result = buildArtifactTooltipHtml({
|
||||||
|
name: 'artifact.zip',
|
||||||
|
size: 512,
|
||||||
|
status: 'completed',
|
||||||
|
expiresUnix: 0,
|
||||||
|
}, 'Expires at %s');
|
||||||
|
expect(normalizeTestHtml(result)).toBe(`<span class="flex-text-inline">512 B</span>`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import {html} from '../utils/html.ts';
|
||||||
|
import {formatBytes} from '../utils.ts';
|
||||||
|
import type {ActionsArtifact} from '../modules/gitea-actions.ts';
|
||||||
|
|
||||||
|
export function buildArtifactTooltipHtml(artifact: ActionsArtifact, expiresAtLocale: string): string {
|
||||||
|
const sizeText = formatBytes(artifact.size);
|
||||||
|
if (artifact.expiresUnix <= 0) {
|
||||||
|
return html`<span class="flex-text-inline">${sizeText}</span>`; // use the same layout as below
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
<span>${suffix}</span>
|
||||||
|
<span class="inline-divider">,</span>
|
||||||
|
<span>${sizeText}</span>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -5,7 +5,8 @@ import {toRefs} from 'vue';
|
|||||||
import {POST, DELETE} from '../modules/fetch.ts';
|
import {POST, DELETE} from '../modules/fetch.ts';
|
||||||
import ActionRunSummaryView from './ActionRunSummaryView.vue';
|
import ActionRunSummaryView from './ActionRunSummaryView.vue';
|
||||||
import ActionRunJobView from './ActionRunJobView.vue';
|
import ActionRunJobView from './ActionRunJobView.vue';
|
||||||
import {createActionRunViewStore} from "./ActionRunView.ts";
|
import {createActionRunViewStore} from './ActionRunView.ts';
|
||||||
|
import {buildArtifactTooltipHtml} from './ActionRunArtifacts.ts';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'RepoActionView',
|
name: 'RepoActionView',
|
||||||
@@ -20,7 +21,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const locale = props.locale;
|
const locale = props.locale;
|
||||||
const store = createActionRunViewStore(props.actionsUrl, props.runId);
|
const store = createActionRunViewStore(props.actionsUrl, props.runId);
|
||||||
const {currentRun: run , runArtifacts: artifacts} = toRefs(store.viewData);
|
const {currentRun: run, runArtifacts: artifacts} = toRefs(store.viewData);
|
||||||
|
|
||||||
function cancelRun() {
|
function cancelRun() {
|
||||||
POST(`${run.value.link}/cancel`);
|
POST(`${run.value.link}/cancel`);
|
||||||
@@ -120,18 +121,24 @@ async function deleteArtifact(name: string) {
|
|||||||
<ul class="ui relaxed list flex-items-block">
|
<ul class="ui relaxed list flex-items-block">
|
||||||
<li class="item" v-for="artifact in artifacts" :key="artifact.name">
|
<li class="item" v-for="artifact in artifacts" :key="artifact.name">
|
||||||
<template v-if="artifact.status !== 'expired'">
|
<template v-if="artifact.status !== 'expired'">
|
||||||
<a class="tw-flex-1 flex-text-block" target="_blank" :href="run.link+'/artifacts/'+artifact.name">
|
<a
|
||||||
<SvgIcon name="octicon-file" class="tw-text-text"/>
|
class="tw-flex-1 flex-text-block muted" target="_blank"
|
||||||
|
:href="run.link+'/artifacts/'+encodeURIComponent(artifact.name)"
|
||||||
|
:data-tooltip-content="buildArtifactTooltipHtml(artifact, locale.artifactExpiresAt)"
|
||||||
|
data-tooltip-render="html"
|
||||||
|
data-tooltip-placement="top-end"
|
||||||
|
>
|
||||||
|
<SvgIcon name="octicon-file" class="tw-text-text-light"/>
|
||||||
<span class="tw-flex-1 gt-ellipsis">{{ artifact.name }}</span>
|
<span class="tw-flex-1 gt-ellipsis">{{ artifact.name }}</span>
|
||||||
</a>
|
</a>
|
||||||
<a v-if="run.canDeleteArtifact" @click="deleteArtifact(artifact.name)">
|
<a v-if="run.canDeleteArtifact" class="muted" @click="deleteArtifact(artifact.name)">
|
||||||
<SvgIcon name="octicon-trash" class="tw-text-text"/>
|
<SvgIcon name="octicon-trash"/>
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
<span v-else class="flex-text-block tw-flex-1 tw-text-grey-light">
|
<span v-else class="flex-text-block tw-flex-1 tw-text-text-light-2">
|
||||||
<SvgIcon name="octicon-file"/>
|
<SvgIcon name="octicon-file"/>
|
||||||
<span class="tw-flex-1 gt-ellipsis">{{ artifact.name }}</span>
|
<span class="tw-flex-1 gt-ellipsis">{{ artifact.name }}</span>
|
||||||
<span class="ui label tw-text-grey-light tw-flex-shrink-0">{{ locale.artifactExpired }}</span>
|
<span class="ui label tw-flex-shrink-0">{{ locale.artifactExpired }}</span>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -251,6 +258,7 @@ async function deleteArtifact(name: string) {
|
|||||||
|
|
||||||
.left-list-header {
|
.left-list-header {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
color: var(--color-text-light-2);
|
color: var(--color-text-light-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function initRepositoryActionView() {
|
|||||||
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
|
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
|
||||||
areYouSure: el.getAttribute('data-locale-are-you-sure'),
|
areYouSure: el.getAttribute('data-locale-are-you-sure'),
|
||||||
artifactExpired: el.getAttribute('data-locale-artifact-expired'),
|
artifactExpired: el.getAttribute('data-locale-artifact-expired'),
|
||||||
|
artifactExpiresAt: el.getAttribute('data-locale-artifact-expires-at'),
|
||||||
confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'),
|
confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'),
|
||||||
showTimeStamps: el.getAttribute('data-locale-show-timestamps'),
|
showTimeStamps: el.getAttribute('data-locale-show-timestamps'),
|
||||||
showLogSeconds: el.getAttribute('data-locale-show-log-seconds'),
|
showLogSeconds: el.getAttribute('data-locale-show-log-seconds'),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// see "models/actions/status.go", if it needs to be used somewhere else, move it to a shared file like "types/actions.ts"
|
// see "models/actions/status.go", if it needs to be used somewhere else, move it to a shared file like "types/actions.ts"
|
||||||
export type ActionsRunStatus = 'unknown' | 'waiting' | 'running' | 'success' | 'failure' | 'cancelled' | 'skipped' | 'blocked';
|
export type ActionsRunStatus = 'unknown' | 'waiting' | 'running' | 'success' | 'failure' | 'cancelled' | 'skipped' | 'blocked';
|
||||||
|
export type ActionsArtifactStatus = 'expired' | 'completed';
|
||||||
|
|
||||||
export type ActionsRun = {
|
export type ActionsRun = {
|
||||||
repoId: number,
|
repoId: number,
|
||||||
@@ -49,5 +50,7 @@ export type ActionsJob = {
|
|||||||
|
|
||||||
export type ActionsArtifact = {
|
export type ActionsArtifact = {
|
||||||
name: string;
|
name: string;
|
||||||
status: string;
|
size: number;
|
||||||
|
status: ActionsArtifactStatus;
|
||||||
|
expiresUnix: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import tippy, {followCursor} from 'tippy.js';
|
|||||||
import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';
|
import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';
|
||||||
import type {Content, Instance, Placement, Props} from 'tippy.js';
|
import type {Content, Instance, Placement, Props} from 'tippy.js';
|
||||||
import {html} from '../utils/html.ts';
|
import {html} from '../utils/html.ts';
|
||||||
|
import {stripTags} from '../utils.ts';
|
||||||
|
|
||||||
type TippyOpts = {
|
type TippyOpts = {
|
||||||
role?: string,
|
role?: string,
|
||||||
@@ -85,6 +86,7 @@ function attachTooltip(target: Element, content: Content | null = null): Instanc
|
|||||||
role: 'tooltip',
|
role: 'tooltip',
|
||||||
theme: 'tooltip',
|
theme: 'tooltip',
|
||||||
hideOnClick,
|
hideOnClick,
|
||||||
|
allowHTML: target.getAttribute('data-tooltip-render') === 'html',
|
||||||
placement: target.getAttribute('data-tooltip-placement') as Placement || 'top-start',
|
placement: target.getAttribute('data-tooltip-placement') as Placement || 'top-start',
|
||||||
followCursor: target.getAttribute('data-tooltip-follow-cursor') as Props['followCursor'] || false,
|
followCursor: target.getAttribute('data-tooltip-follow-cursor') as Props['followCursor'] || false,
|
||||||
...(target.getAttribute('data-tooltip-interactive') === 'true' ? {interactive: true, aria: {content: 'describedby', expanded: false}} : {}),
|
...(target.getAttribute('data-tooltip-interactive') === 'true' ? {interactive: true, aria: {content: 'describedby', expanded: false}} : {}),
|
||||||
@@ -127,7 +129,10 @@ function attachLazyTooltip(el: HTMLElement): void {
|
|||||||
if (!el.hasAttribute('aria-label')) {
|
if (!el.hasAttribute('aria-label')) {
|
||||||
const content = el.getAttribute('data-tooltip-content');
|
const content = el.getAttribute('data-tooltip-content');
|
||||||
if (content) {
|
if (content) {
|
||||||
el.setAttribute('aria-label', content);
|
const isHtml = el.getAttribute('data-tooltip-render') === 'html';
|
||||||
|
let ariaLabelValue = content;
|
||||||
|
if (isHtml) ariaLabelValue = stripTags(content).replace(/\s+/g, ' ').trim();
|
||||||
|
el.setAttribute('aria-label', ariaLabelValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
dirname, basename, extname, isObject, stripTags, parseIssueHref,
|
dirname, basename, extname, formatBytes, isObject, stripTags, parseIssueHref,
|
||||||
translateMonth, translateDay, blobToDataURI,
|
translateMonth, translateDay, blobToDataURI,
|
||||||
toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, isImageFile, isVideoFile, parseRepoOwnerPathInfo,
|
toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, isImageFile, isVideoFile, parseRepoOwnerPathInfo,
|
||||||
urlQueryEscape,
|
urlQueryEscape,
|
||||||
@@ -122,6 +122,17 @@ test('encodeURLEncodedBase64, decodeURLEncodedBase64', () => {
|
|||||||
expect(new Uint8Array(decodeURLEncodedBase64('YQ=='))).toEqual(uint8array('a'));
|
expect(new Uint8Array(decodeURLEncodedBase64('YQ=='))).toEqual(uint8array('a'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('formatBytes', () => {
|
||||||
|
expect(formatBytes(-1)).toBe('0 B');
|
||||||
|
expect(formatBytes(0)).toBe('0 B');
|
||||||
|
expect(formatBytes(512)).toBe('512 B');
|
||||||
|
expect(formatBytes(1024)).toBe('1.0 KiB');
|
||||||
|
expect(formatBytes(1536)).toBe('1.5 KiB');
|
||||||
|
expect(formatBytes(10 * 1024)).toBe('10 KiB');
|
||||||
|
expect(formatBytes(1024 * 1024)).toBe('1.0 MiB');
|
||||||
|
expect(formatBytes(1024 * 1024 * 1024)).toBe('1.0 GiB');
|
||||||
|
});
|
||||||
|
|
||||||
test('file detection', () => {
|
test('file detection', () => {
|
||||||
for (const name of ['a.avif', 'a.jpg', '/a.jpeg', '.file.png', '.webp', 'file.svg']) {
|
for (const name of ['a.avif', 'a.jpg', '/a.jpeg', '.file.png', '.webp', 'file.svg']) {
|
||||||
expect(isImageFile({name})).toBeTruthy();
|
expect(isImageFile({name})).toBeTruthy();
|
||||||
|
|||||||
@@ -203,6 +203,17 @@ export function isVideoFile({name, type}: {name?: string, type?: string}): boole
|
|||||||
return Boolean(/\.(mpe?g|mp4|mkv|webm)$/i.test(name || '') || type?.startsWith('video/'));
|
return Boolean(/\.(mpe?g|mp4|mkv|webm)$/i.test(name || '') || type?.startsWith('video/'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const byteUnits = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB'];
|
||||||
|
|
||||||
|
export function formatBytes(num: number, precision = 2): string {
|
||||||
|
if (!Number.isFinite(num) || num < 0) return `0 ${byteUnits[0]}`;
|
||||||
|
if (num < 1024) return `${num} ${byteUnits[0]}`;
|
||||||
|
const exp = Math.min(Math.floor(Math.log2(num) / 10), byteUnits.length - 1);
|
||||||
|
const value = num / (1024 ** exp);
|
||||||
|
const digits = Math.max(0, precision - 1 - Math.floor(Math.log10(value)));
|
||||||
|
return `${value.toFixed(digits)} ${byteUnits[exp]}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function toggleFullScreen(fullScreenEl: HTMLElement, isFullScreen: boolean, sourceParentSelector?: string): void {
|
export function toggleFullScreen(fullScreenEl: HTMLElement, isFullScreen: boolean, sourceParentSelector?: string): void {
|
||||||
// hide other elements
|
// hide other elements
|
||||||
const headerEl = document.querySelector('#navbar')!;
|
const headerEl = document.querySelector('#navbar')!;
|
||||||
|
|||||||
@@ -20,3 +20,11 @@ export function dedent(str: string) {
|
|||||||
|
|
||||||
return str.replace(new RegExp(`^[ \\t]{${minIndent}}`, 'gm'), '').trim();
|
return str.replace(new RegExp(`^[ \\t]{${minIndent}}`, 'gm'), '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeTestHtml(s: string) {
|
||||||
|
const lines = s.replace(/>\s+</g, '>\n<').trim().split('\n');
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
lines[i] = lines[i].trim();
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user