22import {SvgIcon } from ' ../svg.ts' ;
33import ActionRunStatus from ' ./ActionRunStatus.vue' ;
44import {defineComponent , type PropType } from ' vue' ;
5- import {createElementFromAttrs , toggleElem } from ' ../utils/dom.ts' ;
5+ import {addDelegatedEventListener , createElementFromAttrs , toggleElem } from ' ../utils/dom.ts' ;
66import {formatDatetime } from ' ../utils/time.ts' ;
77import {renderAnsi } from ' ../render/ansi.ts' ;
88import {POST , DELETE } from ' ../modules/fetch.ts' ;
@@ -40,6 +40,12 @@ type Step = {
4040 status: RunStatus ,
4141}
4242
43+ type JobStepState = {
44+ cursor: string | null ,
45+ expanded: boolean ,
46+ manuallyCollapsed: boolean , // whether the user manually collapsed the step, used to avoid auto-expanding it again
47+ }
48+
4349function parseLineCommand(line : LogLine ): LogLineCommand | null {
4450 for (const prefix of LogLinePrefixesGroup ) {
4551 if (line .message .startsWith (prefix )) {
@@ -54,9 +60,10 @@ function parseLineCommand(line: LogLine): LogLineCommand | null {
5460 return null ;
5561}
5662
57- function isLogElementInViewport(el : Element ): boolean {
63+ function isLogElementInViewport(el : Element , { extraViewPortHeight } = {extraViewPortHeight: 0 } ): boolean {
5864 const rect = el .getBoundingClientRect ();
59- return rect .top >= 0 && rect .bottom <= window .innerHeight ; // only check height but not width
65+ // only check whether bottom is in viewport, because the log element can be a log group which is usually tall
66+ return 0 <= rect .bottom && rect .bottom <= window .innerHeight + extraViewPortHeight ;
6067}
6168
6269type LocaleStorageOptions = {
@@ -104,7 +111,7 @@ export default defineComponent({
104111 // internal state
105112 loadingAbortController: null as AbortController | null ,
106113 intervalID: null as IntervalId | null ,
107- currentJobStepsStates: [] as Array <Record < string , any > >,
114+ currentJobStepsStates: [] as Array <JobStepState >,
108115 artifacts: [] as Array <Record <string , any >>,
109116 menuVisible: false ,
110117 isFullScreen: false ,
@@ -181,6 +188,19 @@ export default defineComponent({
181188 // load job data and then auto-reload periodically
182189 // need to await first loadJob so this.currentJobStepsStates is initialized and can be used in hashChangeListener
183190 await this .loadJob ();
191+
192+ // auto-scroll to the bottom of the log group when it is opened
193+ // "toggle" event doesn't bubble, so we need to use 'click' event delegation to handle it
194+ addDelegatedEventListener (this .elStepsContainer (), ' click' , ' summary.job-log-group-summary' , (el , _ ) => {
195+ if (! this .optionAlwaysAutoScroll ) return ;
196+ const elJobLogGroup = el .closest (' details.job-log-group' ) as HTMLDetailsElement ;
197+ setTimeout (() => {
198+ if (elJobLogGroup .open && ! isLogElementInViewport (elJobLogGroup )) {
199+ elJobLogGroup .scrollIntoView ({behavior: ' smooth' , block: ' end' });
200+ }
201+ }, 0 );
202+ });
203+
184204 this .intervalID = setInterval (() => this .loadJob (), 1000 );
185205 document .body .addEventListener (' click' , this .closeDropdown );
186206 this .hashChangeListener ();
@@ -252,6 +272,8 @@ export default defineComponent({
252272 this .currentJobStepsStates [idx ].expanded = ! this .currentJobStepsStates [idx ].expanded ;
253273 if (this .currentJobStepsStates [idx ].expanded ) {
254274 this .loadJobForce (); // try to load the data immediately instead of waiting for next timer interval
275+ } else if (this .currentJob .steps [idx ].status === ' running' ) {
276+ this .currentJobStepsStates [idx ].manuallyCollapsed = true ;
255277 }
256278 },
257279 // cancel a run
@@ -293,7 +315,8 @@ export default defineComponent({
293315 const el = this .getJobStepLogsContainer (stepIndex );
294316 // if the logs container is empty, then auto-scroll if the step is expanded
295317 if (! el .lastChild ) return this .currentJobStepsStates [stepIndex ].expanded ;
296- return isLogElementInViewport (el .lastChild as Element );
318+ // use extraViewPortHeight to tolerate some extra "virtual view port" height (for example: the last line is partially visible)
319+ return isLogElementInViewport (el .lastChild as Element , {extraViewPortHeight: 5 });
297320 },
298321
299322 appendLogs(stepIndex : number , startTime : number , logLines : LogLine []) {
@@ -343,7 +366,6 @@ export default defineComponent({
343366 const abortController = new AbortController ();
344367 this .loadingAbortController = abortController ;
345368 try {
346- const isFirstLoad = ! this .run .status ;
347369 const job = await this .fetchJobData (abortController );
348370 if (this .loadingAbortController !== abortController ) return ;
349371
@@ -353,10 +375,15 @@ export default defineComponent({
353375
354376 // sync the currentJobStepsStates to store the job step states
355377 for (let i = 0 ; i < this .currentJob .steps .length ; i ++ ) {
356- const expanded = isFirstLoad && this .optionAlwaysExpandRunning && this .currentJob .steps [i ].status === ' running' ;
378+ const autoExpand = this .optionAlwaysExpandRunning && this .currentJob .steps [i ].status === ' running' ;
357379 if (! this .currentJobStepsStates [i ]) {
358380 // initial states for job steps
359- this .currentJobStepsStates [i ] = {cursor: null , expanded };
381+ this .currentJobStepsStates [i ] = {cursor: null , expanded: autoExpand , manuallyCollapsed: false };
382+ } else {
383+ // if the step is not manually collapsed by user, then auto-expand it if option is enabled
384+ if (autoExpand && ! this .currentJobStepsStates [i ].manuallyCollapsed ) {
385+ this .currentJobStepsStates [i ].expanded = true ;
386+ }
360387 }
361388 }
362389
@@ -380,7 +407,10 @@ export default defineComponent({
380407 if (! autoScrollStepIndexes .get (stepIndex )) continue ;
381408 autoScrollJobStepElement = this .getJobStepLogsContainer (stepIndex );
382409 }
383- autoScrollJobStepElement ?.lastElementChild .scrollIntoView ({behavior: ' smooth' , block: ' nearest' });
410+ const lastLogElem = autoScrollJobStepElement ?.lastElementChild ;
411+ if (lastLogElem && ! isLogElementInViewport (lastLogElem )) {
412+ lastLogElem .scrollIntoView ({behavior: ' smooth' , block: ' end' });
413+ }
384414
385415 // clear the interval timer if the job is done
386416 if (this .run .done && this .intervalID ) {
@@ -408,9 +438,13 @@ export default defineComponent({
408438 if (this .menuVisible ) this .menuVisible = false ;
409439 },
410440
441+ elStepsContainer(): HTMLElement {
442+ return this .$refs .stepsContainer as HTMLElement ;
443+ },
444+
411445 toggleTimeDisplay(type : ' seconds' | ' stamp' ) {
412446 this .timeVisible [` log-time-${type } ` ] = ! this .timeVisible [` log-time-${type } ` ];
413- for (const el of ( this .$refs . steps as HTMLElement ).querySelectorAll (` .log-time-${type } ` )) {
447+ for (const el of this .elStepsContainer ( ).querySelectorAll (` .log-time-${type } ` )) {
414448 toggleElem (el , this .timeVisible [` log-time-${type } ` ]);
415449 }
416450 },
@@ -419,6 +453,7 @@ export default defineComponent({
419453 this .isFullScreen = ! this .isFullScreen ;
420454 toggleFullScreen (' .action-view-right' , this .isFullScreen , ' .action-view-body' );
421455 },
456+
422457 async hashChangeListener() {
423458 const selectedLogStep = window .location .hash ;
424459 if (! selectedLogStep ) return ;
@@ -431,7 +466,7 @@ export default defineComponent({
431466 // so logline can be selected by querySelector
432467 await this .loadJob ();
433468 }
434- const logLine = ( this .$refs . steps as HTMLElement ).querySelector (selectedLogStep );
469+ const logLine = this .elStepsContainer ( ).querySelector (selectedLogStep );
435470 if (! logLine ) return ;
436471 logLine .querySelector <HTMLAnchorElement >(' .line-num' ).click ();
437472 },
@@ -566,7 +601,7 @@ export default defineComponent({
566601 </div >
567602 </div >
568603 </div >
569- <div class =" job-step-container" ref =" steps " v-if =" currentJob.steps.length" >
604+ <div class =" job-step-container" ref =" stepsContainer " v-if =" currentJob.steps.length" >
570605 <div class =" job-step-section" v-for =" (jobStep, i) in currentJob.steps" :key =" i" >
571606 <div class =" job-step-summary" @click.stop =" isExpandable(jobStep.status) && toggleStepLogs(i)" :class =" [currentJobStepsStates[i].expanded ? 'selected' : '', isExpandable(jobStep.status) && 'step-expandable']" >
572607 <!-- If the job is done and the job step log is loaded for the first time, show the loading icon
0 commit comments