Skip to content

Commit b7e95cc

Browse files
silverwindclaudewxiaoguangbircni
authored
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: #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>
1 parent 2e96e82 commit b7e95cc

16 files changed

Lines changed: 194 additions & 130 deletions

File tree

options/locale/locale_en-US.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@
105105
"copy_error": "Copy failed",
106106
"copy_type_unsupported": "This file type cannot be copied",
107107
"copy_filename": "Copy filename",
108+
"copy_output": "Copy output",
108109
"write": "Write",
109110
"preview": "Preview",
110111
"loading": "Loading…",

templates/base/head_script.tmpl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,13 @@ If you introduce mistakes in it, Gitea JavaScript code wouldn't run correctly.
1919
sharedWorkerUri: '{{AssetURI "js/eventsource.sharedworker.js"}}',
2020
{{/* 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 */}}
2121
i18n: {
22-
copy_success: {{ctx.Locale.Tr "copy_success"}},
23-
copy_error: {{ctx.Locale.Tr "copy_error"}},
2422
error_occurred: {{ctx.Locale.Tr "error.occurred"}},
2523
remove_label_str: {{ctx.Locale.Tr "remove_label_str"}},
2624
modal_confirm: {{ctx.Locale.Tr "modal.confirm"}},
2725
modal_cancel: {{ctx.Locale.Tr "modal.cancel"}},
2826
more_items: {{ctx.Locale.Tr "more_items"}},
27+
copy_success: {{ctx.Locale.Tr "copy_success"}},
28+
copy_error: {{ctx.Locale.Tr "copy_error"}},
2929
},
3030
};
3131
{{/* in case some pages don't render the pageData, we make sure it is an object to prevent null access */}}

templates/repo/actions/view_component.tmpl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
data-locale-show-log-seconds="{{ctx.Locale.Tr "show_log_seconds"}}"
3737
data-locale-show-full-screen="{{ctx.Locale.Tr "show_full_screen"}}"
3838
data-locale-download-logs="{{ctx.Locale.Tr "download_logs"}}"
39+
data-locale-copy-output="{{ctx.Locale.Tr "copy_output"}}"
3940
data-locale-logs-always-auto-scroll="{{ctx.Locale.Tr "actions.logs.always_auto_scroll"}}"
4041
data-locale-logs-always-expand-running="{{ctx.Locale.Tr "actions.logs.always_expand_running"}}"
4142
>

templates/repo/issue/view_content/context_menu.tmpl

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,21 @@
33
{{svg "octicon-kebab-horizontal"}}
44
</a>
55
<div class="menu">
6-
{{$referenceUrl := ""}}
6+
{{$referenceLink := ""}}
77
{{if .issue}}
8-
{{$referenceUrl = printf "%s#%s" ctx.RootData.Issue.Link .item.HashTag}}
8+
{{$referenceLink = printf "%s#%s" ctx.RootData.Issue.Link .item.HashTag}}
99
{{else}}
10-
{{$referenceUrl = printf "%s/files#%s" ctx.RootData.Issue.Link .item.HashTag}}
10+
{{$referenceLink = printf "%s/files#%s" ctx.RootData.Issue.Link .item.HashTag}}
1111
{{end}}
12-
<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>
12+
<div class="item context js-aria-clickable" data-clipboard-text="{{ctx.AppFullLink}}{{$referenceLink}}">{{ctx.Locale.Tr "repo.issues.context.copy_link"}}</div>
1313
<div class="item context js-aria-clickable" data-clipboard-target="#{{.item.HashTag}}-raw">{{ctx.Locale.Tr "repo.issues.context.copy_source"}}</div>
1414
{{if ctx.RootData.IsSigned}}
1515
{{$needDivider := false}}
1616
{{if not ctx.RootData.Repository.IsArchived}}
1717
{{$needDivider = true}}
1818
<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>
1919
{{if not ctx.Consts.RepoUnitTypeIssues.UnitGlobalDisabled}}
20-
<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>
20+
<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>
2121
{{end}}
2222
{{if or ctx.RootData.Permission.IsAdmin .IsCommentPoster ctx.RootData.HasIssuesOrPullsWritePermission}}
2323
<div class="divider"></div>

templates/repo/view_content.tmpl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@
9292
<button class="ui dropdown basic compact jump button tw-px-3" data-tooltip-content="{{ctx.Locale.Tr "repo.more_operations"}}">
9393
{{svg "octicon-kebab-horizontal"}}
9494
<div class="menu">
95-
<a class="item" data-clipboard-text="{{.Repository.Link}}/src/commit/{{.CommitID}}/{{PathEscapeSegments .TreePath}}" data-clipboard-text-type="url">
95+
<a class="item" data-clipboard-text="{{ctx.AppFullLink}}{{.Repository.Link}}/src/commit/{{.CommitID}}/{{PathEscapeSegments .TreePath}}">
9696
{{svg "octicon-link" 16}}{{ctx.Locale.Tr "repo.file_copy_permalink"}}
9797
</a>
9898
{{if and (not $.DisableDownloadSourceArchives) $.RefFullName}}

web_src/css/modules/animations.css

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
}
1414

1515
.btn.is-loading > *,
16+
.btn-octicon.is-loading > *,
1617
.button.is-loading > * {
1718
opacity: 0;
1819
}
@@ -23,7 +24,7 @@
2324
display: block;
2425
left: 50%;
2526
top: 50%;
26-
height: min(4em, 66.6%);
27+
height: var(--loading-size, min(4em, 66.6%));
2728
width: fit-content; /* compat: safari - https://bugs.webkit.org/show_bug.cgi?id=267625 */
2829
aspect-ratio: 1;
2930
transform: translate(-50%, -50%);

web_src/js/components/ActionRunJobView.vue

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import {nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch} from 'vue';
33
import {SvgIcon} from '../svg.ts';
44
import ActionStatusIcon from './ActionStatusIcon.vue';
55
import {addDelegatedEventListener, createElementFromAttrs, toggleElem} from '../utils/dom.ts';
6-
import {formatDatetime} from '../utils/time.ts';
6+
import {formatDatetime, formatDatetimeISO} from '../utils/time.ts';
77
import {POST} from '../modules/fetch.ts';
8+
import {copyToClipboardWithFeedback} from '../modules/clipboard.ts';
89
import type {IntervalId} from '../types.ts';
910
import {toggleFullScreen} from '../utils.ts';
1011
import {localUserSettings} from '../modules/user-settings.ts';
@@ -201,6 +202,22 @@ function endLogGroup(stepIndex: number) {
201202
el._stepLogsActiveContainer = undefined;
202203
}
203204
205+
async function copyStepOutput(event: MouseEvent, stepIndex: number) {
206+
await copyToClipboardWithFeedback(event.currentTarget as HTMLElement, async () => {
207+
const data = await fetchJobData([{step: stepIndex, cursor: null, expanded: true}]);
208+
const stepLog = data.logs.stepsLog?.find((s) => s.step === stepIndex);
209+
const lines: string[] = [];
210+
for (const line of stepLog?.lines ?? []) {
211+
const cmd = parseLogLineCommand(line);
212+
if (cmd?.name === 'hidden' || cmd?.name === 'endgroup') continue;
213+
const ts = formatDatetimeISO(line.timestamp);
214+
const msg = createLogLineMessage(line, cmd).textContent ?? '';
215+
lines.push(`${ts} ${msg}`);
216+
}
217+
return lines.join('\n');
218+
});
219+
}
220+
204221
// show/hide the step logs for a step
205222
function toggleStepLogs(idx: number) {
206223
currentJobStepsStates.value[idx].expanded = !currentJobStepsStates.value[idx].expanded;
@@ -216,7 +233,7 @@ function createLogLine(stepIndex: number, startTime: number, line: LogLine, cmd:
216233
String(line.index),
217234
);
218235
const logTimeStamp = createElementFromAttrs('span', {class: 'log-time-stamp'},
219-
formatDatetime(new Date(line.timestamp * 1000)), // for "Show timestamps"
236+
formatDatetime(line.timestamp * 1000), // for "Show timestamps"
220237
);
221238
const logMsg = createLogLineMessage(line, cmd);
222239
const seconds = Math.floor(line.timestamp - startTime);
@@ -261,17 +278,14 @@ function appendLogs(stepIndex: number, startTime: number, logLines: LogLine[]) {
261278
}
262279
}
263280
264-
async function fetchJobData(abortController: AbortController): Promise<JobData> {
265-
const logCursors = currentJobStepsStates.value.map((it, idx) => {
266-
// cursor is used to indicate the last position of the logs
267-
// it's only used by backend, frontend just reads it and passes it back, it can be any type.
268-
// for example: make cursor=null means the first time to fetch logs, cursor=eof means no more logs, etc
269-
return {step: idx, cursor: it.cursor, expanded: it.expanded};
270-
});
271-
const resp = await POST(props.actionsViewUrl, {
272-
signal: abortController.signal,
273-
data: {logCursors},
274-
});
281+
// "cursor" is used to indicate the last position of the logs.
282+
// It's only used by backend, frontend just reads it and passes it back, it can be any type.
283+
// Frontend knows nothing about its type, never uses its value.
284+
// 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.
285+
type LogCursor = {step: number, cursor: any, expanded: boolean};
286+
287+
async function fetchJobData(logCursors: LogCursor[], signal?: AbortSignal): Promise<JobData> {
288+
const resp = await POST(props.actionsViewUrl, {signal, data: {logCursors}});
275289
return await resp.json();
276290
}
277291
@@ -286,7 +300,8 @@ async function loadJob() {
286300
const abortController = new AbortController();
287301
loadingAbortController = abortController;
288302
try {
289-
const runJobResp = await fetchJobData(abortController);
303+
const logCursors = currentJobStepsStates.value.map((it, idx) => ({step: idx, cursor: it.cursor, expanded: it.expanded}));
304+
const runJobResp = await fetchJobData(logCursors, abortController.signal);
290305
if (loadingAbortController !== abortController) return;
291306
292307
// FIXME: this logic is quite hacky and dirty, it should be refactored in a better way in the future
@@ -459,16 +474,25 @@ async function hashChangeListener() {
459474
<SvgIcon
460475
v-if="isDone(run.status) && currentJobStepsStates[stepIdx].expanded && currentJobStepsStates[stepIdx].cursor === null"
461476
name="gitea-running"
462-
class="tw-mr-2 rotate-clockwise"
477+
class="rotate-clockwise"
463478
/>
464479
<SvgIcon
465480
v-else
466481
name="octicon-chevron-right"
467482
class="tw-mr-2 step-summary-chevron"
468483
:class="{'tw-invisible': !isExpandable(jobStep.status)}"
469484
/>
470-
<ActionStatusIcon :status="jobStep.status" icon-variant="circle-fill" class="tw-mr-2"/>
485+
<ActionStatusIcon :status="jobStep.status" icon-variant="circle-fill"/>
471486
<span class="step-summary-msg gt-ellipsis">{{ jobStep.summary }}</span>
487+
<button
488+
v-if="isExpandable(jobStep.status)"
489+
class="btn interact-fg step-copy-btn"
490+
:aria-label="locale.copyOutput"
491+
:data-tooltip-content="locale.copyOutput"
492+
@click.stop="copyStepOutput($event, stepIdx)"
493+
>
494+
<SvgIcon name="octicon-copy" :size="14"/>
495+
</button>
472496
<span class="step-summary-duration">{{ jobStep.duration }}</span>
473497
</div>
474498
<!-- the log elements could be a lot, do not use v-if to destroy/reconstruct the DOM,
@@ -553,6 +577,7 @@ async function hashChangeListener() {
553577
padding: 5px 10px;
554578
display: flex;
555579
align-items: center;
580+
gap: 8px;
556581
border-radius: var(--border-radius);
557582
}
558583
@@ -577,8 +602,20 @@ async function hashChangeListener() {
577602
flex: 1;
578603
}
579604
580-
.job-step-container .job-step-summary .step-summary-duration {
581-
margin-left: 16px;
605+
.job-step-container .job-step-summary .step-copy-btn {
606+
visibility: hidden;
607+
margin: 0 4px;
608+
}
609+
610+
.job-step-container .job-step-summary:hover .step-copy-btn,
611+
.job-step-container .job-step-summary.selected .step-copy-btn {
612+
visibility: visible;
613+
}
614+
615+
@media (hover: none) {
616+
.job-step-container .job-step-summary:focus-within .step-copy-btn {
617+
visibility: visible;
618+
}
582619
}
583620
584621
.job-step-container .job-step-summary.selected {

web_src/js/features/clipboard.ts

Lines changed: 0 additions & 40 deletions
This file was deleted.

web_src/js/features/copycontent.ts

Lines changed: 14 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,25 @@
1-
import {clippie} from 'clippie';
2-
import {showTemporaryTooltip} from '../modules/tippy.ts';
1+
import {copyToClipboardWithFeedback} from '../modules/clipboard.ts';
32
import {convertImage} from '../utils.ts';
43
import {GET} from '../modules/fetch.ts';
54
import {registerGlobalEventFunc} from '../modules/observer.ts';
65

7-
const {i18n} = window.config;
8-
96
export function initCopyContent() {
107
registerGlobalEventFunc('click', 'onCopyContentButtonClick', async (btn: HTMLElement) => {
118
if (btn.classList.contains('disabled') || btn.classList.contains('is-loading')) return;
12-
const rawFileLink = btn.getAttribute('data-raw-file-link');
13-
14-
let content, isRasterImage = false;
15-
16-
// when "data-raw-link" is present, we perform a fetch. this is either because
17-
// the text to copy is not in the DOM, or it is an image that should be
18-
// fetched to copy in full resolution
19-
if (rawFileLink) {
20-
btn.classList.add('is-loading', 'loading-icon-2px');
21-
try {
22-
const res = await GET(rawFileLink, {credentials: 'include', redirect: 'follow'});
23-
const contentType = res.headers.get('content-type')!;
24-
25-
if (contentType.startsWith('image/') && !contentType.startsWith('image/svg')) {
26-
isRasterImage = true;
27-
content = await res.blob();
28-
} else {
29-
content = await res.text();
30-
}
31-
} catch {
32-
return showTemporaryTooltip(btn, i18n.copy_error);
33-
} finally {
34-
btn.classList.remove('is-loading', 'loading-icon-2px');
9+
await copyToClipboardWithFeedback(btn, async () => {
10+
const rawFileLink = btn.getAttribute('data-raw-file-link');
11+
if (!rawFileLink) {
12+
const lineEls = document.querySelectorAll('.file-view .lines-code');
13+
return Array.from(lineEls, (el) => el.textContent).join('');
3514
}
36-
} else { // text, read from DOM
37-
const lineEls = document.querySelectorAll('.file-view .lines-code');
38-
content = Array.from(lineEls, (el) => el.textContent).join('');
39-
}
40-
41-
// try copy original first, if that fails, and it's an image, convert it to png
42-
const success = await clippie(content);
43-
if (success) {
44-
showTemporaryTooltip(btn, i18n.copy_success);
45-
} else {
46-
if (isRasterImage) {
47-
const success = await clippie(await convertImage(content as Blob, 'image/png'));
48-
showTemporaryTooltip(btn, success ? i18n.copy_success : i18n.copy_error);
49-
} else {
50-
showTemporaryTooltip(btn, i18n.copy_error);
15+
const res = await GET(rawFileLink, {credentials: 'include', redirect: 'follow'});
16+
const contentType = res.headers.get('content-type')!;
17+
if (contentType.startsWith('image/') && !contentType.startsWith('image/svg')) {
18+
// browsers only accept image/png in the clipboard, convert other raster formats
19+
const blob = await res.blob();
20+
return contentType === 'image/png' ? blob : convertImage(blob, 'image/png');
5121
}
52-
}
22+
return await res.text();
23+
});
5324
});
5425
}

web_src/js/features/dropzone.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,13 @@
11
import {svg} from '../svg.ts';
22
import {html} from '../utils/html.ts';
3-
import {clippie} from 'clippie';
4-
import {showTemporaryTooltip} from '../modules/tippy.ts';
3+
import {copyToClipboardWithFeedback} from '../modules/clipboard.ts';
54
import {GET, POST} from '../modules/fetch.ts';
65
import {showErrorToast} from '../modules/toast.ts';
76
import {createElementFromHTML, createElementFromAttrs} from '../utils/dom.ts';
87
import {errorMessage} from '../modules/errors.ts';
98
import {isImageFile, isVideoFile} from '../utils.ts';
109
import type Dropzone from '@deltablot/dropzone';
1110

12-
const {i18n} = window.config;
13-
1411
type CustomDropzoneFile = Dropzone.DropzoneFile & {uuid: string};
1512

1613
// dropzone has its owner event dispatcher (emitter)
@@ -48,14 +45,13 @@ export function generateMarkdownLinkForAttachment(file: Partial<CustomDropzoneFi
4845
function addCopyLink(file: Partial<CustomDropzoneFile>) {
4946
// Create a "Copy Link" element, to conveniently copy the image or file link as Markdown to the clipboard
5047
// The "<a>" element has a hardcoded cursor: pointer because the default is overridden by .dropzone
51-
const copyLinkEl = createElementFromHTML(`
48+
const copyLinkEl = createElementFromHTML<HTMLDivElement>(`
5249
<div class="tw-text-center">
5350
<a href="#" class="tw-cursor-pointer">${svg('octicon-copy', 14)} Copy link</a>
5451
</div>`);
5552
copyLinkEl.addEventListener('click', async (e) => {
5653
e.preventDefault();
57-
const success = await clippie(generateMarkdownLinkForAttachment(file));
58-
showTemporaryTooltip(e.target as Element, success ? i18n.copy_success : i18n.copy_error);
54+
await copyToClipboardWithFeedback(copyLinkEl, generateMarkdownLinkForAttachment(file));
5955
});
6056
file.previewTemplate!.append(copyLinkEl);
6157
}

0 commit comments

Comments
 (0)