mirror of
https://github.com/go-gitea/gitea.git
synced 2026-05-25 16:08:46 +09: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:
@@ -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 {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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
@@ -9,8 +8,6 @@ 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)
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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('-') : [];
|
||||
|
||||
Reference in New Issue
Block a user