Skip to content
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
1a2fc5f
feat(actions): add copy button to action step header
silverwind May 17, 2026
2bdfde1
revert markdown indented code-block wrapper
silverwind May 18, 2026
2892fe1
fix(actions): skip endgroup lines when copying collapsed step
silverwind May 18, 2026
f1276ae
Merge branch 'main' into copy-step-output
silverwind May 18, 2026
e1d5634
Merge branch 'main' into copy-step-output
silverwind May 19, 2026
8ac230a
refactor: polish step copy button and clipboard helper
silverwind May 20, 2026
14d5e47
refactor(actions): restore original cursor and timestamp comments
silverwind May 20, 2026
82709c2
refactor(clipboard): consolidate copy helpers into modules/clipboard.ts
silverwind May 20, 2026
d3a3a9f
Apply suggestion from @silverwind
silverwind May 20, 2026
1fee2c5
refactor(clipboard): move initCopyContent back to features/copyconten…
silverwind May 20, 2026
7f37d14
fix(clipboard): drop empty-content check, always copy and show feedback
silverwind May 20, 2026
a5a1250
fix(time): truncate fractional seconds instead of rounding
silverwind May 20, 2026
2cf17f8
fix(clipboard): restore tooltip feedback for icon-less copy triggers
silverwind May 20, 2026
4d296b3
clarify
wxiaoguang May 20, 2026
50c04bf
use async sleep
wxiaoguang May 20, 2026
66eaedc
add more comment for "cursor" type
wxiaoguang May 20, 2026
b150b3f
remove useless null check
wxiaoguang May 20, 2026
6404d38
remove unnecessary data-clipboard-text-type="url"
wxiaoguang May 20, 2026
90de798
copyContentToClipboard
wxiaoguang May 20, 2026
7888702
avoid duplicate querySelector('.octicon-copy')
wxiaoguang May 20, 2026
0324cdc
Merge branch 'main' into copy-step-output
bircni May 20, 2026
a9211be
formatDatetime: resolve hourCycle like relative-time
silverwind May 21, 2026
4e4306b
rename copy functions so feedback is explicit in the name
silverwind May 21, 2026
e0f6086
Merge branch 'main' into copy-step-output
silverwind May 21, 2026
9803db1
Merge remote-tracking branch 'origin/main' into copy-step-output
silverwind May 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions options/locale/locale_en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
"copy_error": "Copy failed",
"copy_type_unsupported": "This file type cannot be copied",
"copy_filename": "Copy filename",
"copy_output": "Copy output",
"write": "Write",
"preview": "Preview",
"loading": "Loading…",
Expand Down
4 changes: 2 additions & 2 deletions templates/base/head_script.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ If you introduce mistakes in it, Gitea JavaScript code wouldn't run correctly.
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 */}}
i18n: {
copy_success: {{ctx.Locale.Tr "copy_success"}},
copy_error: {{ctx.Locale.Tr "copy_error"}},
error_occurred: {{ctx.Locale.Tr "error.occurred"}},
remove_label_str: {{ctx.Locale.Tr "remove_label_str"}},
modal_confirm: {{ctx.Locale.Tr "modal.confirm"}},
modal_cancel: {{ctx.Locale.Tr "modal.cancel"}},
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 */}}
Expand Down
1 change: 1 addition & 0 deletions templates/repo/actions/view_component.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
data-locale-show-log-seconds="{{ctx.Locale.Tr "show_log_seconds"}}"
data-locale-show-full-screen="{{ctx.Locale.Tr "show_full_screen"}}"
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-expand-running="{{ctx.Locale.Tr "actions.logs.always_expand_running"}}"
>
Expand Down
10 changes: 5 additions & 5 deletions templates/repo/issue/view_content/context_menu.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,21 @@
{{svg "octicon-kebab-horizontal"}}
</a>
<div class="menu">
{{$referenceUrl := ""}}
{{$referenceLink := ""}}
{{if .issue}}
{{$referenceUrl = printf "%s#%s" ctx.RootData.Issue.Link .item.HashTag}}
{{$referenceLink = printf "%s#%s" ctx.RootData.Issue.Link .item.HashTag}}
{{else}}
{{$referenceUrl = printf "%s/files#%s" ctx.RootData.Issue.Link .item.HashTag}}
{{$referenceLink = printf "%s/files#%s" ctx.RootData.Issue.Link .item.HashTag}}
{{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>
{{if ctx.RootData.IsSigned}}
{{$needDivider := false}}
{{if not ctx.RootData.Repository.IsArchived}}
{{$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>
{{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}}
{{if or ctx.RootData.Permission.IsAdmin .IsCommentPoster ctx.RootData.HasIssuesOrPullsWritePermission}}
<div class="divider"></div>
Expand Down
2 changes: 1 addition & 1 deletion templates/repo/view_content.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@
<button class="ui dropdown basic compact jump button tw-px-3" data-tooltip-content="{{ctx.Locale.Tr "repo.more_operations"}}">
{{svg "octicon-kebab-horizontal"}}
<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"}}
</a>
{{if and (not $.DisableDownloadSourceArchives) $.RefFullName}}
Expand Down
3 changes: 2 additions & 1 deletion web_src/css/modules/animations.css
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
}

.btn.is-loading > *,
.btn-octicon.is-loading > *,
.button.is-loading > * {
opacity: 0;
}
Expand All @@ -23,7 +24,7 @@
display: block;
left: 50%;
top: 50%;
height: min(4em, 66.6%);
height: var(--loading-size, min(4em, 66.6%));
width: fit-content; /* compat: safari - https://bugs.webkit.org/show_bug.cgi?id=267625 */
aspect-ratio: 1;
transform: translate(-50%, -50%);
Expand Down
75 changes: 56 additions & 19 deletions web_src/js/components/ActionRunJobView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import {nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch} from 'vue';
import {SvgIcon} from '../svg.ts';
import ActionStatusIcon from './ActionStatusIcon.vue';
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 {copyToClipboardWithFeedback} from '../modules/clipboard.ts';
import type {IntervalId} from '../types.ts';
import {toggleFullScreen} from '../utils.ts';
import {localUserSettings} from '../modules/user-settings.ts';
Expand Down Expand Up @@ -201,6 +202,22 @@ function endLogGroup(stepIndex: number) {
el._stepLogsActiveContainer = undefined;
}

async function copyStepOutput(event: MouseEvent, stepIndex: number) {
await copyToClipboardWithFeedback(event.currentTarget as HTMLElement, async () => {
const data = await fetchJobData([{step: stepIndex, cursor: null, expanded: true}]);
Comment thread
silverwind marked this conversation as resolved.
const stepLog = data.logs.stepsLog?.find((s) => s.step === stepIndex);
const lines: string[] = [];
for (const line of stepLog?.lines ?? []) {
const cmd = parseLogLineCommand(line);
if (cmd?.name === 'hidden' || cmd?.name === 'endgroup') continue;
const ts = formatDatetimeISO(line.timestamp);
const msg = createLogLineMessage(line, cmd).textContent ?? '';
lines.push(`${ts} ${msg}`);
}
return lines.join('\n');
});
}

// show/hide the step logs for a step
function toggleStepLogs(idx: number) {
currentJobStepsStates.value[idx].expanded = !currentJobStepsStates.value[idx].expanded;
Expand All @@ -216,7 +233,7 @@ function createLogLine(stepIndex: number, startTime: number, line: LogLine, cmd:
String(line.index),
);
const logTimeStamp = createElementFromAttrs('span', {class: 'log-time-stamp'},
formatDatetime(new Date(line.timestamp * 1000)), // for "Show timestamps"
formatDatetime(line.timestamp * 1000), // for "Show timestamps"
);
const logMsg = createLogLineMessage(line, cmd);
const seconds = Math.floor(line.timestamp - startTime);
Expand Down Expand Up @@ -261,17 +278,14 @@ function appendLogs(stepIndex: number, startTime: number, logLines: LogLine[]) {
}
}

async function fetchJobData(abortController: AbortController): Promise<JobData> {
const logCursors = currentJobStepsStates.value.map((it, idx) => {
// cursor is used to indicate the last position of the logs
// it's only used by backend, frontend just reads it and passes it back, it can be any type.
// for example: make cursor=null means the first time to fetch logs, cursor=eof means no more logs, etc
return {step: idx, cursor: it.cursor, expanded: it.expanded};
});
const resp = await POST(props.actionsViewUrl, {
signal: abortController.signal,
data: {logCursors},
});
// "cursor" is used to indicate the last position of the logs.
// It's only used by backend, frontend just reads it and passes it back, it can be any type.
// Frontend knows nothing about its type, never uses its value.
// For example: backend can make cursor=null means the first time to fetch logs, cursor=1234 for a position, cursor=eof for no more logs, etc.
type LogCursor = {step: number, cursor: any, expanded: boolean};

async function fetchJobData(logCursors: LogCursor[], signal?: AbortSignal): Promise<JobData> {
const resp = await POST(props.actionsViewUrl, {signal, data: {logCursors}});
return await resp.json();
}

Expand All @@ -286,7 +300,8 @@ async function loadJob() {
const abortController = new AbortController();
loadingAbortController = abortController;
try {
const runJobResp = await fetchJobData(abortController);
const logCursors = currentJobStepsStates.value.map((it, idx) => ({step: idx, cursor: it.cursor, expanded: it.expanded}));
const runJobResp = await fetchJobData(logCursors, abortController.signal);
if (loadingAbortController !== abortController) return;

// FIXME: this logic is quite hacky and dirty, it should be refactored in a better way in the future
Expand Down Expand Up @@ -459,15 +474,24 @@ async function hashChangeListener() {
<SvgIcon
v-if="isDone(run.status) && currentJobStepsStates[stepIdx].expanded && currentJobStepsStates[stepIdx].cursor === null"
name="gitea-running"
class="tw-mr-2 rotate-clockwise"
class="rotate-clockwise"
/>
<SvgIcon
v-else
:name="currentJobStepsStates[stepIdx].expanded ? 'octicon-chevron-down' : 'octicon-chevron-right'"
:class="['tw-mr-2', !isExpandable(jobStep.status) && 'tw-invisible']"
:class="[!isExpandable(jobStep.status) && 'tw-invisible']"
/>
<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>
<button
v-if="isExpandable(jobStep.status)"
class="btn interact-fg step-copy-btn"
:aria-label="locale.copyOutput"
:data-tooltip-content="locale.copyOutput"
@click.stop="copyStepOutput($event, stepIdx)"
>
<SvgIcon name="octicon-copy" :size="14"/>
</button>
<span class="step-summary-duration">{{ jobStep.duration }}</span>
</div>
<!-- the log elements could be a lot, do not use v-if to destroy/reconstruct the DOM,
Expand Down Expand Up @@ -552,6 +576,7 @@ async function hashChangeListener() {
padding: 5px 10px;
display: flex;
align-items: center;
gap: 8px;
border-radius: var(--border-radius);
}

Expand All @@ -568,8 +593,20 @@ async function hashChangeListener() {
flex: 1;
}

.job-step-container .job-step-summary .step-summary-duration {
margin-left: 16px;
.job-step-container .job-step-summary .step-copy-btn {
visibility: hidden;
margin: 0 4px;
}

.job-step-container .job-step-summary:hover .step-copy-btn,
.job-step-container .job-step-summary.selected .step-copy-btn {
visibility: visible;
}

@media (hover: none) {
.job-step-container .job-step-summary:focus-within .step-copy-btn {
visibility: visible;
}
}

.job-step-container .job-step-summary.selected {
Expand Down
40 changes: 0 additions & 40 deletions web_src/js/features/clipboard.ts

This file was deleted.

57 changes: 14 additions & 43 deletions web_src/js/features/copycontent.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,25 @@
import {clippie} from 'clippie';
import {showTemporaryTooltip} from '../modules/tippy.ts';
import {copyToClipboardWithFeedback} from '../modules/clipboard.ts';
import {convertImage} from '../utils.ts';
import {GET} from '../modules/fetch.ts';
import {registerGlobalEventFunc} from '../modules/observer.ts';

const {i18n} = window.config;

export function initCopyContent() {
registerGlobalEventFunc('click', 'onCopyContentButtonClick', async (btn: HTMLElement) => {
if (btn.classList.contains('disabled') || btn.classList.contains('is-loading')) return;
const rawFileLink = btn.getAttribute('data-raw-file-link');

let content, isRasterImage = false;

// 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 contentType = res.headers.get('content-type')!;

if (contentType.startsWith('image/') && !contentType.startsWith('image/svg')) {
isRasterImage = true;
content = await res.blob();
} else {
content = await res.text();
}
} catch {
return showTemporaryTooltip(btn, i18n.copy_error);
} finally {
btn.classList.remove('is-loading', 'loading-icon-2px');
await copyToClipboardWithFeedback(btn, async () => {
const rawFileLink = btn.getAttribute('data-raw-file-link');
if (!rawFileLink) {
const lineEls = document.querySelectorAll('.file-view .lines-code');
return Array.from(lineEls, (el) => el.textContent).join('');
}
} 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);
const res = await GET(rawFileLink, {credentials: 'include', redirect: 'follow'});
const contentType = res.headers.get('content-type')!;
if (contentType.startsWith('image/') && !contentType.startsWith('image/svg')) {
// browsers only accept image/png in the clipboard, convert other raster formats
const blob = await res.blob();
return contentType === 'image/png' ? blob : convertImage(blob, 'image/png');
}
}
return await res.text();
});
});
}
10 changes: 3 additions & 7 deletions web_src/js/features/dropzone.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
import {svg} from '../svg.ts';
import {html} from '../utils/html.ts';
import {clippie} from 'clippie';
import {showTemporaryTooltip} from '../modules/tippy.ts';
import {copyToClipboardWithFeedback} from '../modules/clipboard.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';

const {i18n} = window.config;

type CustomDropzoneFile = Dropzone.DropzoneFile & {uuid: string};

// dropzone has its owner event dispatcher (emitter)
Expand Down Expand Up @@ -48,14 +45,13 @@ export function generateMarkdownLinkForAttachment(file: Partial<CustomDropzoneFi
function addCopyLink(file: Partial<CustomDropzoneFile>) {
// 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
const copyLinkEl = createElementFromHTML(`
const copyLinkEl = createElementFromHTML<HTMLDivElement>(`
<div class="tw-text-center">
<a href="#" class="tw-cursor-pointer">${svg('octicon-copy', 14)} Copy link</a>
</div>`);
copyLinkEl.addEventListener('click', async (e) => {
e.preventDefault();
const success = await clippie(generateMarkdownLinkForAttachment(file));
showTemporaryTooltip(e.target as Element, success ? i18n.copy_success : i18n.copy_error);
await copyToClipboardWithFeedback(copyLinkEl, generateMarkdownLinkForAttachment(file));
});
file.previewTemplate!.append(copyLinkEl);
}
Expand Down
1 change: 1 addition & 0 deletions web_src/js/features/repo-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export function initRepositoryActionView() {
showLogSeconds: el.getAttribute('data-locale-show-log-seconds'),
showFullScreen: el.getAttribute('data-locale-show-full-screen'),
downloadLogs: el.getAttribute('data-locale-download-logs'),
copyOutput: el.getAttribute('data-locale-copy-output'),
status: {
unknown: el.getAttribute('data-locale-status-unknown'),
waiting: el.getAttribute('data-locale-status-waiting'),
Expand Down
3 changes: 1 addition & 2 deletions web_src/js/features/repo-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,8 @@ function selectRange(range: string): Element | null {
const updateCopyPermalinkUrl = function (anchor: string) {
if (!copyPermalink) return;
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-type', 'url');
};

const rangeFields = range ? range.split('-') : [];
Expand Down
Loading
Loading