@@ -3,8 +3,9 @@ import {nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch} from 'vue';
33import {SvgIcon } from ' ../svg.ts' ;
44import ActionStatusIcon from ' ./ActionStatusIcon.vue' ;
55import {addDelegatedEventListener , createElementFromAttrs , toggleElem } from ' ../utils/dom.ts' ;
6- import {formatDatetime } from ' ../utils/time.ts' ;
6+ import {formatDatetime , formatDatetimeISO } from ' ../utils/time.ts' ;
77import {POST } from ' ../modules/fetch.ts' ;
8+ import {copyToClipboardWithFeedback } from ' ../modules/clipboard.ts' ;
89import type {IntervalId } from ' ../types.ts' ;
910import {toggleFullScreen } from ' ../utils.ts' ;
1011import {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
205222function 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 {
0 commit comments