Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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_step_output": "Copy step output",
"write": "Write",
"preview": "Preview",
"loading": "Loading…",
Expand Down
2 changes: 0 additions & 2 deletions templates/base/head_script.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@ 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"}},
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-step-output="{{ctx.Locale.Tr "copy_step_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
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
59 changes: 40 additions & 19 deletions web_src/js/components/ActionRunJobView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import ActionStatusIcon from './ActionStatusIcon.vue';
import {addDelegatedEventListener, createElementFromAttrs, toggleElem} from '../utils/dom.ts';
import {formatDatetime} from '../utils/time.ts';
import {POST} from '../modules/fetch.ts';
import {copyToClipboard} from '../features/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,26 @@ function endLogGroup(stepIndex: number) {
el._stepLogsActiveContainer = undefined;
}

// fetches logs on demand when the step is collapsed; otherwise reads the rendered DOM
async function copyStepOutput(event: MouseEvent, stepIndex: number) {
await copyToClipboard(event.currentTarget as HTMLElement, async () => {
const el = getJobStepLogsContainer(stepIndex);
const loaded = el.querySelectorAll('.log-msg');
if (loaded.length) {
return Array.from(loaded).map((n) => n.textContent ?? '').join('\n');
}
Comment thread
silverwind marked this conversation as resolved.
Outdated
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;
lines.push(createLogLineMessage(line, cmd).textContent ?? '');
Comment thread
wxiaoguang marked this conversation as resolved.
Outdated
}
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 Down Expand Up @@ -261,17 +282,11 @@ 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 indicates the last log position; the frontend treats it as opaque and passes it back to the backend
type LogCursor = {step: number, cursor: string | null, expanded: boolean};
Comment thread
wxiaoguang marked this conversation as resolved.
Outdated

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 +301,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 +475,23 @@ 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"
:data-tooltip-content="locale.copyStepOutput"
@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,10 +593,6 @@ async function hashChangeListener() {
flex: 1;
}

.job-step-container .job-step-summary .step-summary-duration {
margin-left: 16px;
}

.job-step-container .job-step-summary.selected {
color: var(--color-console-fg);
background-color: var(--color-console-active-bg);
Expand Down
59 changes: 55 additions & 4 deletions web_src/js/features/clipboard.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,60 @@
import {showTemporaryTooltip} from '../modules/tippy.ts';
import {toAbsoluteUrl} from '../utils.ts';
import {clippie} from 'clippie';
import {svg} from '../svg.ts';
import {createElementFromHTML} from '../utils/dom.ts';

const {copy_success, copy_error} = window.config.i18n;
const pendingFeedback = new WeakSet<Element>();

type CopyContent = string | Blob;

export async function copyToClipboard(target: Element, content: CopyContent | (() => Promise<CopyContent>)): Promise<boolean> {
if (pendingFeedback.has(target)) return false;
pendingFeedback.add(target);
let resolved: CopyContent = '';
try {
if (typeof content === 'function') {
const width = target.querySelector('svg')?.getAttribute('width') ?? '16';
(target as HTMLElement).style.setProperty('--loading-size', `${width}px`);
target.classList.add('is-loading', 'loading-icon-2px');
try {
resolved = await content();
} finally {
target.classList.remove('is-loading', 'loading-icon-2px');
(target as HTMLElement).style.removeProperty('--loading-size');
}
} else {
resolved = content;
}
} catch (err) {
console.error(err);
}
const success = Boolean(resolved) && await clippie(resolved);
showCopyFeedback(target, success);
return success;
}

function showCopyFeedback(target: Element, success: boolean) {
const origSvg = target.querySelector('svg');
if (!origSvg) {
pendingFeedback.delete(target);
return;
}
Comment thread
silverwind marked this conversation as resolved.
Outdated
const newSvg = replaceWithFeedbackSvg(origSvg, success);
Comment thread
silverwind marked this conversation as resolved.
Outdated
setTimeout(() => {
newSvg.replaceWith(origSvg);
pendingFeedback.delete(target);
}, 1000);
}

function replaceWithFeedbackSvg(origSvg: SVGElement, success: boolean): SVGElement {
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;
}

// Enable clipboard copy from HTML attributes. These properties are supported:
// - data-clipboard-text: Direct text to copy
Expand Down Expand Up @@ -33,8 +85,7 @@ export function initGlobalCopyToClipboardListener() {
}

if (text) {
const success = await clippie(text);
showTemporaryTooltip(target, success ? copy_success : copy_error);
await copyToClipboard(target, text);
}
});
}
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 {copyToClipboard} from './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 copyToClipboard(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();
});
});
}
9 changes: 3 additions & 6 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 {copyToClipboard} from './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 @@ -53,9 +50,9 @@ function addCopyLink(file: Partial<CustomDropzoneFile>) {
<a href="#" class="tw-cursor-pointer">${svg('octicon-copy', 14)} Copy link</a>
</div>`);
copyLinkEl.addEventListener('click', async (e) => {
const target = e.currentTarget as Element;
e.preventDefault();
const success = await clippie(generateMarkdownLinkForAttachment(file));
showTemporaryTooltip(e.target as Element, success ? i18n.copy_success : i18n.copy_error);
await copyToClipboard(target, generateMarkdownLinkForAttachment(file));
Comment thread
silverwind marked this conversation as resolved.
Outdated
});
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'),
copyStepOutput: el.getAttribute('data-locale-copy-step-output'),
status: {
unknown: el.getAttribute('data-locale-status-unknown'),
waiting: el.getAttribute('data-locale-status-waiting'),
Expand Down
Loading