mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-19 15:14:05 -05:00
feat: add copy button to action step header, improve other copy buttons (#37744)
- Adds a copy button to each action step header that copies the step's rendered log output to clipboard. - Extract a shared `copyToClipboard(target, content)` helper in `clipboard.ts` that adds SVG success/failure feedback. - `is-loading` height for the new helper is sourced from `--loading-size`. - Change actions log timestamp format to include seconds. The indented-markdown code-block fix has moved to #37748. <img width="244" height="165" alt="copystep" src="https://github.com/user-attachments/assets/ce286b51-f77b-4d82-b161-ca0aa7ec4fdc" /> <img width="187" height="150" alt="copybt" src="https://github.com/user-attachments/assets/5366b290-b776-496d-8dd4-58d5fa60be92" /> Fixes: https://github.com/go-gitea/gitea/issues/26116 --- This PR was written with the help of Claude Opus 4.7 --------- Signed-off-by: silverwind <me@silverwind.io> Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: Nicolas <bircni@icloud.com>
This commit is contained in:
@@ -105,6 +105,7 @@
|
|||||||
"copy_error": "Copy failed",
|
"copy_error": "Copy failed",
|
||||||
"copy_type_unsupported": "This file type cannot be copied",
|
"copy_type_unsupported": "This file type cannot be copied",
|
||||||
"copy_filename": "Copy filename",
|
"copy_filename": "Copy filename",
|
||||||
|
"copy_output": "Copy output",
|
||||||
"write": "Write",
|
"write": "Write",
|
||||||
"preview": "Preview",
|
"preview": "Preview",
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ If you introduce mistakes in it, Gitea JavaScript code wouldn't run correctly.
|
|||||||
sharedWorkerUri: '{{AssetURI "js/eventsource.sharedworker.js"}}',
|
sharedWorkerUri: '{{AssetURI "js/eventsource.sharedworker.js"}}',
|
||||||
{{/* this global i18n object should only contain general texts. for specialized texts, it should be provided inside the related modules by: (1) API response (2) HTML data-attribute (3) PageData */}}
|
{{/* this global i18n object should only contain general texts. for specialized texts, it should be provided inside the related modules by: (1) API response (2) HTML data-attribute (3) PageData */}}
|
||||||
i18n: {
|
i18n: {
|
||||||
copy_success: {{ctx.Locale.Tr "copy_success"}},
|
|
||||||
copy_error: {{ctx.Locale.Tr "copy_error"}},
|
|
||||||
error_occurred: {{ctx.Locale.Tr "error.occurred"}},
|
error_occurred: {{ctx.Locale.Tr "error.occurred"}},
|
||||||
remove_label_str: {{ctx.Locale.Tr "remove_label_str"}},
|
remove_label_str: {{ctx.Locale.Tr "remove_label_str"}},
|
||||||
modal_confirm: {{ctx.Locale.Tr "modal.confirm"}},
|
modal_confirm: {{ctx.Locale.Tr "modal.confirm"}},
|
||||||
modal_cancel: {{ctx.Locale.Tr "modal.cancel"}},
|
modal_cancel: {{ctx.Locale.Tr "modal.cancel"}},
|
||||||
more_items: {{ctx.Locale.Tr "more_items"}},
|
more_items: {{ctx.Locale.Tr "more_items"}},
|
||||||
|
copy_success: {{ctx.Locale.Tr "copy_success"}},
|
||||||
|
copy_error: {{ctx.Locale.Tr "copy_error"}},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
{{/* in case some pages don't render the pageData, we make sure it is an object to prevent null access */}}
|
{{/* in case some pages don't render the pageData, we make sure it is an object to prevent null access */}}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
data-locale-show-log-seconds="{{ctx.Locale.Tr "show_log_seconds"}}"
|
data-locale-show-log-seconds="{{ctx.Locale.Tr "show_log_seconds"}}"
|
||||||
data-locale-show-full-screen="{{ctx.Locale.Tr "show_full_screen"}}"
|
data-locale-show-full-screen="{{ctx.Locale.Tr "show_full_screen"}}"
|
||||||
data-locale-download-logs="{{ctx.Locale.Tr "download_logs"}}"
|
data-locale-download-logs="{{ctx.Locale.Tr "download_logs"}}"
|
||||||
|
data-locale-copy-output="{{ctx.Locale.Tr "copy_output"}}"
|
||||||
data-locale-logs-always-auto-scroll="{{ctx.Locale.Tr "actions.logs.always_auto_scroll"}}"
|
data-locale-logs-always-auto-scroll="{{ctx.Locale.Tr "actions.logs.always_auto_scroll"}}"
|
||||||
data-locale-logs-always-expand-running="{{ctx.Locale.Tr "actions.logs.always_expand_running"}}"
|
data-locale-logs-always-expand-running="{{ctx.Locale.Tr "actions.logs.always_expand_running"}}"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
{{svg "octicon-kebab-horizontal"}}
|
{{svg "octicon-kebab-horizontal"}}
|
||||||
</a>
|
</a>
|
||||||
<div class="menu">
|
<div class="menu">
|
||||||
{{$referenceUrl := ""}}
|
{{$referenceLink := ""}}
|
||||||
{{if .issue}}
|
{{if .issue}}
|
||||||
{{$referenceUrl = printf "%s#%s" ctx.RootData.Issue.Link .item.HashTag}}
|
{{$referenceLink = printf "%s#%s" ctx.RootData.Issue.Link .item.HashTag}}
|
||||||
{{else}}
|
{{else}}
|
||||||
{{$referenceUrl = printf "%s/files#%s" ctx.RootData.Issue.Link .item.HashTag}}
|
{{$referenceLink = printf "%s/files#%s" ctx.RootData.Issue.Link .item.HashTag}}
|
||||||
{{end}}
|
{{end}}
|
||||||
<div class="item context js-aria-clickable" data-clipboard-text-type="url" data-clipboard-text="{{$referenceUrl}}">{{ctx.Locale.Tr "repo.issues.context.copy_link"}}</div>
|
<div class="item context js-aria-clickable" data-clipboard-text="{{ctx.AppFullLink}}{{$referenceLink}}">{{ctx.Locale.Tr "repo.issues.context.copy_link"}}</div>
|
||||||
<div class="item context js-aria-clickable" data-clipboard-target="#{{.item.HashTag}}-raw">{{ctx.Locale.Tr "repo.issues.context.copy_source"}}</div>
|
<div class="item context js-aria-clickable" data-clipboard-target="#{{.item.HashTag}}-raw">{{ctx.Locale.Tr "repo.issues.context.copy_source"}}</div>
|
||||||
{{if ctx.RootData.IsSigned}}
|
{{if ctx.RootData.IsSigned}}
|
||||||
{{$needDivider := false}}
|
{{$needDivider := false}}
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
{{$needDivider = true}}
|
{{$needDivider = true}}
|
||||||
<div class="item context js-aria-clickable quote-reply {{if .diff}}quote-reply-diff{{end}}" data-target="{{.item.HashTag}}-raw">{{ctx.Locale.Tr "repo.issues.context.quote_reply"}}</div>
|
<div class="item context js-aria-clickable quote-reply {{if .diff}}quote-reply-diff{{end}}" data-target="{{.item.HashTag}}-raw">{{ctx.Locale.Tr "repo.issues.context.quote_reply"}}</div>
|
||||||
{{if not ctx.Consts.RepoUnitTypeIssues.UnitGlobalDisabled}}
|
{{if not ctx.Consts.RepoUnitTypeIssues.UnitGlobalDisabled}}
|
||||||
<div class="item context js-aria-clickable reference-issue" data-target="{{.item.HashTag}}-raw" data-modal="#reference-issue-modal" data-poster="{{.item.Poster.GetDisplayName}}" data-poster-username="{{.item.Poster.Name}}" data-reference="{{$referenceUrl}}">{{ctx.Locale.Tr "repo.issues.context.reference_issue"}}</div>
|
<div class="item context js-aria-clickable reference-issue" data-target="{{.item.HashTag}}-raw" data-modal="#reference-issue-modal" data-poster="{{.item.Poster.GetDisplayName}}" data-poster-username="{{.item.Poster.Name}}" data-reference="{{$referenceLink}}">{{ctx.Locale.Tr "repo.issues.context.reference_issue"}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if or ctx.RootData.Permission.IsAdmin .IsCommentPoster ctx.RootData.HasIssuesOrPullsWritePermission}}
|
{{if or ctx.RootData.Permission.IsAdmin .IsCommentPoster ctx.RootData.HasIssuesOrPullsWritePermission}}
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
|
|||||||
@@ -92,7 +92,7 @@
|
|||||||
<button class="ui dropdown basic compact jump button tw-px-3" data-tooltip-content="{{ctx.Locale.Tr "repo.more_operations"}}">
|
<button class="ui dropdown basic compact jump button tw-px-3" data-tooltip-content="{{ctx.Locale.Tr "repo.more_operations"}}">
|
||||||
{{svg "octicon-kebab-horizontal"}}
|
{{svg "octicon-kebab-horizontal"}}
|
||||||
<div class="menu">
|
<div class="menu">
|
||||||
<a class="item" data-clipboard-text="{{.Repository.Link}}/src/commit/{{.CommitID}}/{{PathEscapeSegments .TreePath}}" data-clipboard-text-type="url">
|
<a class="item" data-clipboard-text="{{ctx.AppFullLink}}{{.Repository.Link}}/src/commit/{{.CommitID}}/{{PathEscapeSegments .TreePath}}">
|
||||||
{{svg "octicon-link" 16}}{{ctx.Locale.Tr "repo.file_copy_permalink"}}
|
{{svg "octicon-link" 16}}{{ctx.Locale.Tr "repo.file_copy_permalink"}}
|
||||||
</a>
|
</a>
|
||||||
{{if and (not $.DisableDownloadSourceArchives) $.RefFullName}}
|
{{if and (not $.DisableDownloadSourceArchives) $.RefFullName}}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn.is-loading > *,
|
.btn.is-loading > *,
|
||||||
|
.btn-octicon.is-loading > *,
|
||||||
.button.is-loading > * {
|
.button.is-loading > * {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
@@ -23,7 +24,7 @@
|
|||||||
display: block;
|
display: block;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
height: min(4em, 66.6%);
|
height: var(--loading-size, min(4em, 66.6%));
|
||||||
width: fit-content; /* compat: safari - https://bugs.webkit.org/show_bug.cgi?id=267625 */
|
width: fit-content; /* compat: safari - https://bugs.webkit.org/show_bug.cgi?id=267625 */
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import {nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch} from 'vue';
|
|||||||
import {SvgIcon} from '../svg.ts';
|
import {SvgIcon} from '../svg.ts';
|
||||||
import ActionStatusIcon from './ActionStatusIcon.vue';
|
import ActionStatusIcon from './ActionStatusIcon.vue';
|
||||||
import {addDelegatedEventListener, createElementFromAttrs, toggleElem} from '../utils/dom.ts';
|
import {addDelegatedEventListener, createElementFromAttrs, toggleElem} from '../utils/dom.ts';
|
||||||
import {formatDatetime} from '../utils/time.ts';
|
import {formatDatetime, formatDatetimeISO} from '../utils/time.ts';
|
||||||
import {POST} from '../modules/fetch.ts';
|
import {POST} from '../modules/fetch.ts';
|
||||||
|
import {copyToClipboardWithFeedback} from '../modules/clipboard.ts';
|
||||||
import type {IntervalId} from '../types.ts';
|
import type {IntervalId} from '../types.ts';
|
||||||
import {toggleFullScreen} from '../utils.ts';
|
import {toggleFullScreen} from '../utils.ts';
|
||||||
import {localUserSettings} from '../modules/user-settings.ts';
|
import {localUserSettings} from '../modules/user-settings.ts';
|
||||||
@@ -201,6 +202,22 @@ function endLogGroup(stepIndex: number) {
|
|||||||
el._stepLogsActiveContainer = undefined;
|
el._stepLogsActiveContainer = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyStepOutput(event: MouseEvent, stepIndex: number) {
|
||||||
|
await copyToClipboardWithFeedback(event.currentTarget as HTMLElement, async () => {
|
||||||
|
const data = await fetchJobData([{step: stepIndex, cursor: null, expanded: true}]);
|
||||||
|
const stepLog = data.logs.stepsLog?.find((s) => s.step === stepIndex);
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const line of stepLog?.lines ?? []) {
|
||||||
|
const cmd = parseLogLineCommand(line);
|
||||||
|
if (cmd?.name === 'hidden' || cmd?.name === 'endgroup') continue;
|
||||||
|
const ts = formatDatetimeISO(line.timestamp);
|
||||||
|
const msg = createLogLineMessage(line, cmd).textContent ?? '';
|
||||||
|
lines.push(`${ts} ${msg}`);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// show/hide the step logs for a step
|
// show/hide the step logs for a step
|
||||||
function toggleStepLogs(idx: number) {
|
function toggleStepLogs(idx: number) {
|
||||||
currentJobStepsStates.value[idx].expanded = !currentJobStepsStates.value[idx].expanded;
|
currentJobStepsStates.value[idx].expanded = !currentJobStepsStates.value[idx].expanded;
|
||||||
@@ -216,7 +233,7 @@ function createLogLine(stepIndex: number, startTime: number, line: LogLine, cmd:
|
|||||||
String(line.index),
|
String(line.index),
|
||||||
);
|
);
|
||||||
const logTimeStamp = createElementFromAttrs('span', {class: 'log-time-stamp'},
|
const logTimeStamp = createElementFromAttrs('span', {class: 'log-time-stamp'},
|
||||||
formatDatetime(new Date(line.timestamp * 1000)), // for "Show timestamps"
|
formatDatetime(line.timestamp * 1000), // for "Show timestamps"
|
||||||
);
|
);
|
||||||
const logMsg = createLogLineMessage(line, cmd);
|
const logMsg = createLogLineMessage(line, cmd);
|
||||||
const seconds = Math.floor(line.timestamp - startTime);
|
const seconds = Math.floor(line.timestamp - startTime);
|
||||||
@@ -261,17 +278,14 @@ function appendLogs(stepIndex: number, startTime: number, logLines: LogLine[]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJobData(abortController: AbortController): Promise<JobData> {
|
// "cursor" is used to indicate the last position of the logs.
|
||||||
const logCursors = currentJobStepsStates.value.map((it, idx) => {
|
// It's only used by backend, frontend just reads it and passes it back, it can be any type.
|
||||||
// cursor is used to indicate the last position of the logs
|
// Frontend knows nothing about its type, never uses its value.
|
||||||
// it's only used by backend, frontend just reads it and passes it back, it can be any type.
|
// For example: backend can make cursor=null means the first time to fetch logs, cursor=1234 for a position, cursor=eof for no more logs, etc.
|
||||||
// for example: make cursor=null means the first time to fetch logs, cursor=eof means no more logs, etc
|
type LogCursor = {step: number, cursor: any, expanded: boolean};
|
||||||
return {step: idx, cursor: it.cursor, expanded: it.expanded};
|
|
||||||
});
|
async function fetchJobData(logCursors: LogCursor[], signal?: AbortSignal): Promise<JobData> {
|
||||||
const resp = await POST(props.actionsViewUrl, {
|
const resp = await POST(props.actionsViewUrl, {signal, data: {logCursors}});
|
||||||
signal: abortController.signal,
|
|
||||||
data: {logCursors},
|
|
||||||
});
|
|
||||||
return await resp.json();
|
return await resp.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,7 +300,8 @@ async function loadJob() {
|
|||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
loadingAbortController = abortController;
|
loadingAbortController = abortController;
|
||||||
try {
|
try {
|
||||||
const runJobResp = await fetchJobData(abortController);
|
const logCursors = currentJobStepsStates.value.map((it, idx) => ({step: idx, cursor: it.cursor, expanded: it.expanded}));
|
||||||
|
const runJobResp = await fetchJobData(logCursors, abortController.signal);
|
||||||
if (loadingAbortController !== abortController) return;
|
if (loadingAbortController !== abortController) return;
|
||||||
|
|
||||||
// FIXME: this logic is quite hacky and dirty, it should be refactored in a better way in the future
|
// FIXME: this logic is quite hacky and dirty, it should be refactored in a better way in the future
|
||||||
@@ -459,7 +474,7 @@ async function hashChangeListener() {
|
|||||||
<SvgIcon
|
<SvgIcon
|
||||||
v-if="isDone(run.status) && currentJobStepsStates[stepIdx].expanded && currentJobStepsStates[stepIdx].cursor === null"
|
v-if="isDone(run.status) && currentJobStepsStates[stepIdx].expanded && currentJobStepsStates[stepIdx].cursor === null"
|
||||||
name="gitea-running"
|
name="gitea-running"
|
||||||
class="tw-mr-2 rotate-clockwise"
|
class="rotate-clockwise"
|
||||||
/>
|
/>
|
||||||
<SvgIcon
|
<SvgIcon
|
||||||
v-else
|
v-else
|
||||||
@@ -467,8 +482,17 @@ async function hashChangeListener() {
|
|||||||
class="tw-mr-2 step-summary-chevron"
|
class="tw-mr-2 step-summary-chevron"
|
||||||
:class="{'tw-invisible': !isExpandable(jobStep.status)}"
|
:class="{'tw-invisible': !isExpandable(jobStep.status)}"
|
||||||
/>
|
/>
|
||||||
<ActionStatusIcon :status="jobStep.status" icon-variant="circle-fill" class="tw-mr-2"/>
|
<ActionStatusIcon :status="jobStep.status" icon-variant="circle-fill"/>
|
||||||
<span class="step-summary-msg gt-ellipsis">{{ jobStep.summary }}</span>
|
<span class="step-summary-msg gt-ellipsis">{{ jobStep.summary }}</span>
|
||||||
|
<button
|
||||||
|
v-if="isExpandable(jobStep.status)"
|
||||||
|
class="btn interact-fg step-copy-btn"
|
||||||
|
:aria-label="locale.copyOutput"
|
||||||
|
:data-tooltip-content="locale.copyOutput"
|
||||||
|
@click.stop="copyStepOutput($event, stepIdx)"
|
||||||
|
>
|
||||||
|
<SvgIcon name="octicon-copy" :size="14"/>
|
||||||
|
</button>
|
||||||
<span class="step-summary-duration">{{ jobStep.duration }}</span>
|
<span class="step-summary-duration">{{ jobStep.duration }}</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- the log elements could be a lot, do not use v-if to destroy/reconstruct the DOM,
|
<!-- the log elements could be a lot, do not use v-if to destroy/reconstruct the DOM,
|
||||||
@@ -553,6 +577,7 @@ async function hashChangeListener() {
|
|||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,8 +602,20 @@ async function hashChangeListener() {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-step-container .job-step-summary .step-summary-duration {
|
.job-step-container .job-step-summary .step-copy-btn {
|
||||||
margin-left: 16px;
|
visibility: hidden;
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-step-container .job-step-summary:hover .step-copy-btn,
|
||||||
|
.job-step-container .job-step-summary.selected .step-copy-btn {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.job-step-container .job-step-summary:focus-within .step-copy-btn {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-step-container .job-step-summary.selected {
|
.job-step-container .job-step-summary.selected {
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import {showTemporaryTooltip} from '../modules/tippy.ts';
|
|
||||||
import {toAbsoluteUrl} from '../utils.ts';
|
|
||||||
import {clippie} from 'clippie';
|
|
||||||
|
|
||||||
const {copy_success, copy_error} = window.config.i18n;
|
|
||||||
|
|
||||||
// Enable clipboard copy from HTML attributes. These properties are supported:
|
|
||||||
// - data-clipboard-text: Direct text to copy
|
|
||||||
// - data-clipboard-target: Holds a selector for an element. "value" of <input> or <textarea>, or "textContent" of <div> will be copied
|
|
||||||
// - data-clipboard-text-type: When set to 'url' will convert relative to absolute urls
|
|
||||||
export function initGlobalCopyToClipboardListener() {
|
|
||||||
document.addEventListener('click', async (e) => {
|
|
||||||
const target = (e.target as HTMLElement).closest('[data-clipboard-text], [data-clipboard-target]');
|
|
||||||
if (!target) return;
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
let text = target.getAttribute('data-clipboard-text');
|
|
||||||
if (text === null) {
|
|
||||||
const textSelector = target.getAttribute('data-clipboard-target')!;
|
|
||||||
const textTarget = document.querySelector(textSelector)!;
|
|
||||||
if (textTarget.nodeName === 'INPUT' || textTarget.nodeName === 'TEXTAREA') {
|
|
||||||
text = (textTarget as HTMLInputElement | HTMLTextAreaElement).value;
|
|
||||||
} else if (textTarget.nodeName === 'DIV') {
|
|
||||||
text = textTarget.textContent;
|
|
||||||
} else {
|
|
||||||
throw new Error(`Unsupported element for clipboard target: ${textSelector}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (text && target.getAttribute('data-clipboard-text-type') === 'url') {
|
|
||||||
text = toAbsoluteUrl(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (text) {
|
|
||||||
const success = await clippie(text);
|
|
||||||
showTemporaryTooltip(target, success ? copy_success : copy_error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,54 +1,25 @@
|
|||||||
import {clippie} from 'clippie';
|
import {copyToClipboardWithFeedback} from '../modules/clipboard.ts';
|
||||||
import {showTemporaryTooltip} from '../modules/tippy.ts';
|
|
||||||
import {convertImage} from '../utils.ts';
|
import {convertImage} from '../utils.ts';
|
||||||
import {GET} from '../modules/fetch.ts';
|
import {GET} from '../modules/fetch.ts';
|
||||||
import {registerGlobalEventFunc} from '../modules/observer.ts';
|
import {registerGlobalEventFunc} from '../modules/observer.ts';
|
||||||
|
|
||||||
const {i18n} = window.config;
|
|
||||||
|
|
||||||
export function initCopyContent() {
|
export function initCopyContent() {
|
||||||
registerGlobalEventFunc('click', 'onCopyContentButtonClick', async (btn: HTMLElement) => {
|
registerGlobalEventFunc('click', 'onCopyContentButtonClick', async (btn: HTMLElement) => {
|
||||||
if (btn.classList.contains('disabled') || btn.classList.contains('is-loading')) return;
|
if (btn.classList.contains('disabled') || btn.classList.contains('is-loading')) return;
|
||||||
|
await copyToClipboardWithFeedback(btn, async () => {
|
||||||
const rawFileLink = btn.getAttribute('data-raw-file-link');
|
const rawFileLink = btn.getAttribute('data-raw-file-link');
|
||||||
|
if (!rawFileLink) {
|
||||||
let content, isRasterImage = false;
|
const lineEls = document.querySelectorAll('.file-view .lines-code');
|
||||||
|
return Array.from(lineEls, (el) => el.textContent).join('');
|
||||||
// when "data-raw-link" is present, we perform a fetch. this is either because
|
}
|
||||||
// the text to copy is not in the DOM, or it is an image that should be
|
|
||||||
// fetched to copy in full resolution
|
|
||||||
if (rawFileLink) {
|
|
||||||
btn.classList.add('is-loading', 'loading-icon-2px');
|
|
||||||
try {
|
|
||||||
const res = await GET(rawFileLink, {credentials: 'include', redirect: 'follow'});
|
const res = await GET(rawFileLink, {credentials: 'include', redirect: 'follow'});
|
||||||
const contentType = res.headers.get('content-type')!;
|
const contentType = res.headers.get('content-type')!;
|
||||||
|
|
||||||
if (contentType.startsWith('image/') && !contentType.startsWith('image/svg')) {
|
if (contentType.startsWith('image/') && !contentType.startsWith('image/svg')) {
|
||||||
isRasterImage = true;
|
// browsers only accept image/png in the clipboard, convert other raster formats
|
||||||
content = await res.blob();
|
const blob = await res.blob();
|
||||||
} else {
|
return contentType === 'image/png' ? blob : convertImage(blob, 'image/png');
|
||||||
content = await res.text();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return showTemporaryTooltip(btn, i18n.copy_error);
|
|
||||||
} finally {
|
|
||||||
btn.classList.remove('is-loading', 'loading-icon-2px');
|
|
||||||
}
|
|
||||||
} else { // text, read from DOM
|
|
||||||
const lineEls = document.querySelectorAll('.file-view .lines-code');
|
|
||||||
content = Array.from(lineEls, (el) => el.textContent).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// try copy original first, if that fails, and it's an image, convert it to png
|
|
||||||
const success = await clippie(content);
|
|
||||||
if (success) {
|
|
||||||
showTemporaryTooltip(btn, i18n.copy_success);
|
|
||||||
} else {
|
|
||||||
if (isRasterImage) {
|
|
||||||
const success = await clippie(await convertImage(content as Blob, 'image/png'));
|
|
||||||
showTemporaryTooltip(btn, success ? i18n.copy_success : i18n.copy_error);
|
|
||||||
} else {
|
|
||||||
showTemporaryTooltip(btn, i18n.copy_error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return await res.text();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {svg} from '../svg.ts';
|
import {svg} from '../svg.ts';
|
||||||
import {html} from '../utils/html.ts';
|
import {html} from '../utils/html.ts';
|
||||||
import {clippie} from 'clippie';
|
import {copyToClipboardWithFeedback} from '../modules/clipboard.ts';
|
||||||
import {showTemporaryTooltip} from '../modules/tippy.ts';
|
|
||||||
import {GET, POST} from '../modules/fetch.ts';
|
import {GET, POST} from '../modules/fetch.ts';
|
||||||
import {showErrorToast} from '../modules/toast.ts';
|
import {showErrorToast} from '../modules/toast.ts';
|
||||||
import {createElementFromHTML, createElementFromAttrs} from '../utils/dom.ts';
|
import {createElementFromHTML, createElementFromAttrs} from '../utils/dom.ts';
|
||||||
@@ -9,8 +8,6 @@ import {errorMessage} from '../modules/errors.ts';
|
|||||||
import {isImageFile, isVideoFile} from '../utils.ts';
|
import {isImageFile, isVideoFile} from '../utils.ts';
|
||||||
import type Dropzone from '@deltablot/dropzone';
|
import type Dropzone from '@deltablot/dropzone';
|
||||||
|
|
||||||
const {i18n} = window.config;
|
|
||||||
|
|
||||||
type CustomDropzoneFile = Dropzone.DropzoneFile & {uuid: string};
|
type CustomDropzoneFile = Dropzone.DropzoneFile & {uuid: string};
|
||||||
|
|
||||||
// dropzone has its owner event dispatcher (emitter)
|
// dropzone has its owner event dispatcher (emitter)
|
||||||
@@ -48,14 +45,13 @@ export function generateMarkdownLinkForAttachment(file: Partial<CustomDropzoneFi
|
|||||||
function addCopyLink(file: Partial<CustomDropzoneFile>) {
|
function addCopyLink(file: Partial<CustomDropzoneFile>) {
|
||||||
// Create a "Copy Link" element, to conveniently copy the image or file link as Markdown to the clipboard
|
// Create a "Copy Link" element, to conveniently copy the image or file link as Markdown to the clipboard
|
||||||
// The "<a>" element has a hardcoded cursor: pointer because the default is overridden by .dropzone
|
// The "<a>" element has a hardcoded cursor: pointer because the default is overridden by .dropzone
|
||||||
const copyLinkEl = createElementFromHTML(`
|
const copyLinkEl = createElementFromHTML<HTMLDivElement>(`
|
||||||
<div class="tw-text-center">
|
<div class="tw-text-center">
|
||||||
<a href="#" class="tw-cursor-pointer">${svg('octicon-copy', 14)} Copy link</a>
|
<a href="#" class="tw-cursor-pointer">${svg('octicon-copy', 14)} Copy link</a>
|
||||||
</div>`);
|
</div>`);
|
||||||
copyLinkEl.addEventListener('click', async (e) => {
|
copyLinkEl.addEventListener('click', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const success = await clippie(generateMarkdownLinkForAttachment(file));
|
await copyToClipboardWithFeedback(copyLinkEl, generateMarkdownLinkForAttachment(file));
|
||||||
showTemporaryTooltip(e.target as Element, success ? i18n.copy_success : i18n.copy_error);
|
|
||||||
});
|
});
|
||||||
file.previewTemplate!.append(copyLinkEl);
|
file.previewTemplate!.append(copyLinkEl);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export function initRepositoryActionView() {
|
|||||||
showLogSeconds: el.getAttribute('data-locale-show-log-seconds'),
|
showLogSeconds: el.getAttribute('data-locale-show-log-seconds'),
|
||||||
showFullScreen: el.getAttribute('data-locale-show-full-screen'),
|
showFullScreen: el.getAttribute('data-locale-show-full-screen'),
|
||||||
downloadLogs: el.getAttribute('data-locale-download-logs'),
|
downloadLogs: el.getAttribute('data-locale-download-logs'),
|
||||||
|
copyOutput: el.getAttribute('data-locale-copy-output'),
|
||||||
status: {
|
status: {
|
||||||
unknown: el.getAttribute('data-locale-status-unknown'),
|
unknown: el.getAttribute('data-locale-status-unknown'),
|
||||||
waiting: el.getAttribute('data-locale-status-waiting'),
|
waiting: el.getAttribute('data-locale-status-waiting'),
|
||||||
|
|||||||
@@ -41,9 +41,8 @@ function selectRange(range: string): Element | null {
|
|||||||
const updateCopyPermalinkUrl = function (anchor: string) {
|
const updateCopyPermalinkUrl = function (anchor: string) {
|
||||||
if (!copyPermalink) return;
|
if (!copyPermalink) return;
|
||||||
let link = copyPermalink.getAttribute('data-url')!;
|
let link = copyPermalink.getAttribute('data-url')!;
|
||||||
link = `${link.replace(/#L\d+$|#L\d+-L\d+$/, '')}#${anchor}`;
|
link = `${window.location.origin}${link.replace(/#L\d+$|#L\d+-L\d+$/, '')}#${anchor}`;
|
||||||
copyPermalink.setAttribute('data-clipboard-text', link);
|
copyPermalink.setAttribute('data-clipboard-text', link);
|
||||||
copyPermalink.setAttribute('data-clipboard-text-type', 'url');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const rangeFields = range ? range.split('-') : [];
|
const rangeFields = range ? range.split('-') : [];
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import '../fomantic/build/fomantic.js';
|
|||||||
import '../css/index.css';
|
import '../css/index.css';
|
||||||
|
|
||||||
import {initDashboardRepoList} from './features/dashboard.ts';
|
import {initDashboardRepoList} from './features/dashboard.ts';
|
||||||
import {initGlobalCopyToClipboardListener} from './features/clipboard.ts';
|
import {initGlobalCopyToClipboardListener} from './modules/clipboard.ts';
|
||||||
|
import {initCopyContent} from './features/copycontent.ts';
|
||||||
import {initRepoGraphGit} from './features/repo-graph.ts';
|
import {initRepoGraphGit} from './features/repo-graph.ts';
|
||||||
import {initHeatmap} from './features/heatmap.ts';
|
import {initHeatmap} from './features/heatmap.ts';
|
||||||
import {initImageDiff} from './features/imagediff.ts';
|
import {initImageDiff} from './features/imagediff.ts';
|
||||||
@@ -40,7 +41,6 @@ import {initRepoBranchButton} from './features/repo-branch.ts';
|
|||||||
import {initCommonOrganization} from './features/common-organization.ts';
|
import {initCommonOrganization} from './features/common-organization.ts';
|
||||||
import {initRepoWikiForm} from './features/repo-wiki.ts';
|
import {initRepoWikiForm} from './features/repo-wiki.ts';
|
||||||
import {initRepository, initBranchSelectorTabs} from './features/repo-legacy.ts';
|
import {initRepository, initBranchSelectorTabs} from './features/repo-legacy.ts';
|
||||||
import {initCopyContent} from './features/copycontent.ts';
|
|
||||||
import {initCaptcha} from './features/captcha.ts';
|
import {initCaptcha} from './features/captcha.ts';
|
||||||
import {initRepositoryActionView} from './features/repo-actions.ts';
|
import {initRepositoryActionView} from './features/repo-actions.ts';
|
||||||
import {initGlobalTooltips} from './modules/tippy.ts';
|
import {initGlobalTooltips} from './modules/tippy.ts';
|
||||||
|
|||||||
90
web_src/js/modules/clipboard.ts
Normal file
90
web_src/js/modules/clipboard.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import {clippie, type ClippieContent} from 'clippie';
|
||||||
|
import {showTemporaryTooltip} from './tippy.ts';
|
||||||
|
import {sleep} from '../utils.ts';
|
||||||
|
import {svg} from '../svg.ts';
|
||||||
|
import {createElementFromHTML} from '../utils/dom.ts';
|
||||||
|
|
||||||
|
const {copy_success, copy_error} = window.config.i18n;
|
||||||
|
const pendingFeedback = new WeakSet<HTMLElement>();
|
||||||
|
|
||||||
|
/** copy the copiable content to clipboard, return "true" on success, otherwise "false" */
|
||||||
|
export async function copyToClipboard(content: ClippieContent): Promise<boolean> {
|
||||||
|
return await clippie(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copy `content` to the clipboard. `target` is used to:
|
||||||
|
* - avoid duplicate copy actions (especially when the content will be fetched from an async function)
|
||||||
|
* - provide feedback to end users (its `.octicon-copy` is swapped to show success/fail feedback, or a tooltip if it has none)
|
||||||
|
* When `content` is a function, `target` also shows a spinner while it resolves. */
|
||||||
|
export async function copyToClipboardWithFeedback(target: HTMLElement, content: ClippieContent | (() => Promise<ClippieContent>)) {
|
||||||
|
if (pendingFeedback.has(target)) return;
|
||||||
|
pendingFeedback.add(target);
|
||||||
|
|
||||||
|
let success = false;
|
||||||
|
const feedbackSvg = target.querySelector<SVGElement>('.octicon-copy');
|
||||||
|
|
||||||
|
// prepare copiable "content"
|
||||||
|
try {
|
||||||
|
if (typeof content === 'function') {
|
||||||
|
if (feedbackSvg) target.style.setProperty('--loading-size', `${feedbackSvg.getAttribute('width')!}px`);
|
||||||
|
target.classList.add('is-loading', 'loading-icon-2px');
|
||||||
|
try {
|
||||||
|
content = await content();
|
||||||
|
} finally {
|
||||||
|
target.classList.remove('is-loading', 'loading-icon-2px');
|
||||||
|
target.style.removeProperty('--loading-size');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
success = await copyToClipboard(content);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// show feedback
|
||||||
|
if (feedbackSvg) {
|
||||||
|
const restore = replaceWithFeedbackSvg(feedbackSvg, success);
|
||||||
|
await sleep(1000);
|
||||||
|
restore();
|
||||||
|
} else {
|
||||||
|
showTemporaryTooltip(target, success ? copy_success : copy_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingFeedback.delete(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceWithFeedbackSvg(origSvg: SVGElement, success: boolean): () => void {
|
||||||
|
const size = Number(origSvg.getAttribute('width')!);
|
||||||
|
const {icon, color} = success ?
|
||||||
|
{icon: 'octicon-check', color: 'tw-text-green'} as const :
|
||||||
|
{icon: 'octicon-x', color: 'tw-text-red'} as const;
|
||||||
|
const newSvg = createElementFromHTML<SVGElement>(svg(icon, size, color));
|
||||||
|
origSvg.replaceWith(newSvg);
|
||||||
|
return () => newSvg.replaceWith(origSvg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable clipboard copy from HTML attributes. These properties are supported:
|
||||||
|
// - data-clipboard-text: Direct text to copy
|
||||||
|
// - data-clipboard-target: Holds a selector for an element. "value" of <input> or <textarea>, or "textContent" of <div> will be copied
|
||||||
|
export function initGlobalCopyToClipboardListener() {
|
||||||
|
document.addEventListener('click', async (e) => {
|
||||||
|
const target = (e.target as HTMLElement).closest<HTMLElement>('[data-clipboard-text], [data-clipboard-target]');
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
let text = target.getAttribute('data-clipboard-text');
|
||||||
|
if (text === null) {
|
||||||
|
const textSelector = target.getAttribute('data-clipboard-target')!;
|
||||||
|
const textTarget = document.querySelector(textSelector)!;
|
||||||
|
if (textTarget.nodeName === 'INPUT' || textTarget.nodeName === 'TEXTAREA') {
|
||||||
|
text = (textTarget as HTMLInputElement | HTMLTextAreaElement).value;
|
||||||
|
} else if (textTarget.nodeName === 'DIV') {
|
||||||
|
text = textTarget.textContent;
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported element for clipboard target: ${textSelector}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// now, text can not be null
|
||||||
|
await copyToClipboardWithFeedback(target, text);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import {clippie} from 'clippie';
|
|
||||||
import {createTippy} from '../tippy.ts';
|
import {createTippy} from '../tippy.ts';
|
||||||
|
import {copyToClipboard} from '../clipboard.ts';
|
||||||
import {keySymbols} from '../../utils.ts';
|
import {keySymbols} from '../../utils.ts';
|
||||||
import {goToDefinitionAt} from './utils.ts';
|
import {goToDefinitionAt} from './utils.ts';
|
||||||
import type {Instance} from 'tippy.js';
|
import type {Instance} from 'tippy.js';
|
||||||
@@ -95,13 +95,13 @@ function buildMenuItems(cm: CodemirrorModules, view: EditorView, togglePalette:
|
|||||||
'separator',
|
'separator',
|
||||||
{label: 'Cut', keys: 'Mod+X', disabled: !hasSelection, run: async (v) => {
|
{label: 'Cut', keys: 'Mod+X', disabled: !hasSelection, run: async (v) => {
|
||||||
const {from, to} = v.state.selection.main;
|
const {from, to} = v.state.selection.main;
|
||||||
if (await clippie(v.state.doc.sliceString(from, to))) {
|
if (await copyToClipboard(v.state.doc.sliceString(from, to))) {
|
||||||
v.dispatch({changes: {from, to}});
|
v.dispatch({changes: {from, to}});
|
||||||
}
|
}
|
||||||
}},
|
}},
|
||||||
{label: 'Copy', keys: 'Mod+C', disabled: !hasSelection, run: async (v) => {
|
{label: 'Copy', keys: 'Mod+C', disabled: !hasSelection, run: async (v) => {
|
||||||
const {from, to} = v.state.selection.main;
|
const {from, to} = v.state.selection.main;
|
||||||
await clippie(v.state.doc.sliceString(from, to));
|
await copyToClipboard(v.state.doc.sliceString(from, to));
|
||||||
}},
|
}},
|
||||||
{label: 'Paste', keys: 'Mod+V', run: async (view) => {
|
{label: 'Paste', keys: 'Mod+V', run: async (view) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -65,18 +65,25 @@ export function fillEmptyStartDaysWithZeroes(startDays: number[], data: DayDataO
|
|||||||
|
|
||||||
let dateFormat: Intl.DateTimeFormat;
|
let dateFormat: Intl.DateTimeFormat;
|
||||||
|
|
||||||
/** Format a Date object to document's locale, but with 24h format from user's current locale because this
|
// ISO 8601 UTC with 7-digit fractional seconds, matching Go's `2006-01-02T15:04:05.0000000Z07:00`
|
||||||
* option is a personal preference of the user, not something that the document's locale should dictate. */
|
export function formatDatetimeISO(unixSeconds: number): string {
|
||||||
|
const base = new Date(unixSeconds * 1000).toISOString().slice(0, 19);
|
||||||
|
const frac = unixSeconds - Math.floor(unixSeconds);
|
||||||
|
const fracInt = Math.floor(frac * 10_000_000);
|
||||||
|
return `${base}.${String(fracInt).padStart(7, '0')}Z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format a Date to a localized format, for example "21 May 2026, 14:30:45". */
|
||||||
export function formatDatetime(date: Date | number): string {
|
export function formatDatetime(date: Date | number): string {
|
||||||
if (!dateFormat) {
|
if (!dateFormat) {
|
||||||
// TODO: replace `hour12` with `Intl.Locale.prototype.getHourCycles` once there is broad browser support
|
|
||||||
dateFormat = new Intl.DateTimeFormat(getCurrentLocale(), {
|
dateFormat = new Intl.DateTimeFormat(getCurrentLocale(), {
|
||||||
day: 'numeric',
|
day: '2-digit',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
hour: 'numeric',
|
hour: '2-digit',
|
||||||
hour12: !Number.isInteger(Number(new Intl.DateTimeFormat([], {hour: 'numeric'}).format())),
|
hourCycle: new Intl.DateTimeFormat([], {hour: 'numeric'}).resolvedOptions().hourCycle === 'h12' ? 'h12' : 'h23',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return dateFormat.format(date);
|
return dateFormat.format(date);
|
||||||
|
|||||||
Reference in New Issue
Block a user