mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-11-04 10:44:12 +01:00 
			
		
		
		
	Implements reliable auto-expand and auto-scroll behavior for the Actions logs view. * Expands running or unfinished steps automatically. * Smoothly scrolls to the latest log line during execution. * Controlled via existing “Always auto-scroll” and “Expand running steps” options. Fixes #35570. --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
		
							parent
							
								
									851d8f9f7c
								
							
						
					
					
						commit
						0a0baeb3b0
					
				@ -6,6 +6,7 @@ package devtest
 | 
			
		||||
import (
 | 
			
		||||
	mathRand "math/rand/v2"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"slices"
 | 
			
		||||
	"strconv"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"time"
 | 
			
		||||
@ -17,25 +18,29 @@ import (
 | 
			
		||||
	"code.gitea.io/gitea/services/context"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func generateMockStepsLog(logCur actions.LogCursor) (stepsLog []*actions.ViewStepLog) {
 | 
			
		||||
	mockedLogs := []string{
 | 
			
		||||
		"::group::test group for: step={step}, cursor={cursor}",
 | 
			
		||||
		"in group msg for: step={step}, cursor={cursor}",
 | 
			
		||||
		"in group msg for: step={step}, cursor={cursor}",
 | 
			
		||||
		"in group msg for: step={step}, cursor={cursor}",
 | 
			
		||||
		"::endgroup::",
 | 
			
		||||
type generateMockStepsLogOptions struct {
 | 
			
		||||
	mockCountFirst   int
 | 
			
		||||
	mockCountGeneral int
 | 
			
		||||
	groupRepeat      int
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func generateMockStepsLog(logCur actions.LogCursor, opts generateMockStepsLogOptions) (stepsLog []*actions.ViewStepLog) {
 | 
			
		||||
	var mockedLogs []string
 | 
			
		||||
	mockedLogs = append(mockedLogs, "::group::test group for: step={step}, cursor={cursor}")
 | 
			
		||||
	mockedLogs = append(mockedLogs, slices.Repeat([]string{"in group msg for: step={step}, cursor={cursor}"}, opts.groupRepeat)...)
 | 
			
		||||
	mockedLogs = append(mockedLogs, "::endgroup::")
 | 
			
		||||
	mockedLogs = append(mockedLogs,
 | 
			
		||||
		"message for: step={step}, cursor={cursor}",
 | 
			
		||||
		"message for: step={step}, cursor={cursor}",
 | 
			
		||||
		"##[group]test group for: step={step}, cursor={cursor}",
 | 
			
		||||
		"in group msg for: step={step}, cursor={cursor}",
 | 
			
		||||
		"##[endgroup]",
 | 
			
		||||
	}
 | 
			
		||||
	cur := logCur.Cursor // usually the cursor is the "file offset", but here we abuse it as "line number" to make the mock easier, intentionally
 | 
			
		||||
	mockCount := util.Iif(logCur.Step == 0, 3, 1)
 | 
			
		||||
	if logCur.Step == 1 && logCur.Cursor == 0 {
 | 
			
		||||
		mockCount = 30 // for the first batch, return as many as possible to test the auto-expand and auto-scroll
 | 
			
		||||
	}
 | 
			
		||||
	for i := 0; i < mockCount; i++ {
 | 
			
		||||
	)
 | 
			
		||||
	// usually the cursor is the "file offset", but here we abuse it as "line number" to make the mock easier, intentionally
 | 
			
		||||
	cur := logCur.Cursor
 | 
			
		||||
	// for the first batch, return as many as possible to test the auto-expand and auto-scroll
 | 
			
		||||
	mockCount := util.Iif(logCur.Cursor == 0, opts.mockCountFirst, opts.mockCountGeneral)
 | 
			
		||||
	for range mockCount {
 | 
			
		||||
		logStr := mockedLogs[int(cur)%len(mockedLogs)]
 | 
			
		||||
		cur++
 | 
			
		||||
		logStr = strings.ReplaceAll(logStr, "{step}", strconv.Itoa(logCur.Step))
 | 
			
		||||
@ -127,21 +132,28 @@ func MockActionsRunsJobs(ctx *context.Context) {
 | 
			
		||||
		Duration: "3h",
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	var mockLogOptions []generateMockStepsLogOptions
 | 
			
		||||
	resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{
 | 
			
		||||
		Summary:  "step 0 (mock slow)",
 | 
			
		||||
		Duration: time.Hour.String(),
 | 
			
		||||
		Status:   actions_model.StatusRunning.String(),
 | 
			
		||||
	})
 | 
			
		||||
	mockLogOptions = append(mockLogOptions, generateMockStepsLogOptions{mockCountFirst: 30, mockCountGeneral: 1, groupRepeat: 3})
 | 
			
		||||
 | 
			
		||||
	resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{
 | 
			
		||||
		Summary:  "step 1 (mock fast)",
 | 
			
		||||
		Duration: time.Hour.String(),
 | 
			
		||||
		Status:   actions_model.StatusRunning.String(),
 | 
			
		||||
	})
 | 
			
		||||
	mockLogOptions = append(mockLogOptions, generateMockStepsLogOptions{mockCountFirst: 30, mockCountGeneral: 3, groupRepeat: 20})
 | 
			
		||||
 | 
			
		||||
	resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{
 | 
			
		||||
		Summary:  "step 2 (mock error)",
 | 
			
		||||
		Duration: time.Hour.String(),
 | 
			
		||||
		Status:   actions_model.StatusRunning.String(),
 | 
			
		||||
	})
 | 
			
		||||
	mockLogOptions = append(mockLogOptions, generateMockStepsLogOptions{mockCountFirst: 30, mockCountGeneral: 3, groupRepeat: 3})
 | 
			
		||||
 | 
			
		||||
	if len(req.LogCursors) == 0 {
 | 
			
		||||
		ctx.JSON(http.StatusOK, resp)
 | 
			
		||||
		return
 | 
			
		||||
@ -156,7 +168,7 @@ func MockActionsRunsJobs(ctx *context.Context) {
 | 
			
		||||
		}
 | 
			
		||||
		doSlowResponse = doSlowResponse || logCur.Step == 0
 | 
			
		||||
		doErrorResponse = doErrorResponse || logCur.Step == 2
 | 
			
		||||
		resp.Logs.StepsLog = append(resp.Logs.StepsLog, generateMockStepsLog(logCur)...)
 | 
			
		||||
		resp.Logs.StepsLog = append(resp.Logs.StepsLog, generateMockStepsLog(logCur, mockLogOptions[logCur.Step])...)
 | 
			
		||||
	}
 | 
			
		||||
	if doErrorResponse {
 | 
			
		||||
		if mathRand.Float64() > 0.5 {
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,7 @@
 | 
			
		||||
import {SvgIcon} from '../svg.ts';
 | 
			
		||||
import ActionRunStatus from './ActionRunStatus.vue';
 | 
			
		||||
import {defineComponent, type PropType} from 'vue';
 | 
			
		||||
import {createElementFromAttrs, toggleElem} from '../utils/dom.ts';
 | 
			
		||||
import {addDelegatedEventListener, createElementFromAttrs, toggleElem} from '../utils/dom.ts';
 | 
			
		||||
import {formatDatetime} from '../utils/time.ts';
 | 
			
		||||
import {renderAnsi} from '../render/ansi.ts';
 | 
			
		||||
import {POST, DELETE} from '../modules/fetch.ts';
 | 
			
		||||
@ -40,6 +40,12 @@ type Step = {
 | 
			
		||||
  status: RunStatus,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type JobStepState = {
 | 
			
		||||
  cursor: string|null,
 | 
			
		||||
  expanded: boolean,
 | 
			
		||||
  manuallyCollapsed: boolean, // whether the user manually collapsed the step, used to avoid auto-expanding it again
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function parseLineCommand(line: LogLine): LogLineCommand | null {
 | 
			
		||||
  for (const prefix of LogLinePrefixesGroup) {
 | 
			
		||||
    if (line.message.startsWith(prefix)) {
 | 
			
		||||
@ -54,9 +60,10 @@ function parseLineCommand(line: LogLine): LogLineCommand | null {
 | 
			
		||||
  return null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function isLogElementInViewport(el: Element): boolean {
 | 
			
		||||
function isLogElementInViewport(el: Element, {extraViewPortHeight}={extraViewPortHeight: 0}): boolean {
 | 
			
		||||
  const rect = el.getBoundingClientRect();
 | 
			
		||||
  return rect.top >= 0 && rect.bottom <= window.innerHeight; // only check height but not width
 | 
			
		||||
  // only check whether bottom is in viewport, because the log element can be a log group which is usually tall
 | 
			
		||||
  return 0 <= rect.bottom && rect.bottom <= window.innerHeight + extraViewPortHeight;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type LocaleStorageOptions = {
 | 
			
		||||
@ -104,7 +111,7 @@ export default defineComponent({
 | 
			
		||||
      // internal state
 | 
			
		||||
      loadingAbortController: null as AbortController | null,
 | 
			
		||||
      intervalID: null as IntervalId | null,
 | 
			
		||||
      currentJobStepsStates: [] as Array<Record<string, any>>,
 | 
			
		||||
      currentJobStepsStates: [] as Array<JobStepState>,
 | 
			
		||||
      artifacts: [] as Array<Record<string, any>>,
 | 
			
		||||
      menuVisible: false,
 | 
			
		||||
      isFullScreen: false,
 | 
			
		||||
@ -181,6 +188,19 @@ export default defineComponent({
 | 
			
		||||
    // load job data and then auto-reload periodically
 | 
			
		||||
    // need to await first loadJob so this.currentJobStepsStates is initialized and can be used in hashChangeListener
 | 
			
		||||
    await this.loadJob();
 | 
			
		||||
 | 
			
		||||
    // auto-scroll to the bottom of the log group when it is opened
 | 
			
		||||
    // "toggle" event doesn't bubble, so we need to use 'click' event delegation to handle it
 | 
			
		||||
    addDelegatedEventListener(this.elStepsContainer(), 'click', 'summary.job-log-group-summary', (el, _) => {
 | 
			
		||||
      if (!this.optionAlwaysAutoScroll) return;
 | 
			
		||||
      const elJobLogGroup = el.closest('details.job-log-group') as HTMLDetailsElement;
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        if (elJobLogGroup.open && !isLogElementInViewport(elJobLogGroup)) {
 | 
			
		||||
          elJobLogGroup.scrollIntoView({behavior: 'smooth', block: 'end'});
 | 
			
		||||
        }
 | 
			
		||||
      }, 0);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    this.intervalID = setInterval(() => this.loadJob(), 1000);
 | 
			
		||||
    document.body.addEventListener('click', this.closeDropdown);
 | 
			
		||||
    this.hashChangeListener();
 | 
			
		||||
@ -252,6 +272,8 @@ export default defineComponent({
 | 
			
		||||
      this.currentJobStepsStates[idx].expanded = !this.currentJobStepsStates[idx].expanded;
 | 
			
		||||
      if (this.currentJobStepsStates[idx].expanded) {
 | 
			
		||||
        this.loadJobForce(); // try to load the data immediately instead of waiting for next timer interval
 | 
			
		||||
      } else if (this.currentJob.steps[idx].status === 'running') {
 | 
			
		||||
        this.currentJobStepsStates[idx].manuallyCollapsed = true;
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    // cancel a run
 | 
			
		||||
@ -293,7 +315,8 @@ export default defineComponent({
 | 
			
		||||
      const el = this.getJobStepLogsContainer(stepIndex);
 | 
			
		||||
      // if the logs container is empty, then auto-scroll if the step is expanded
 | 
			
		||||
      if (!el.lastChild) return this.currentJobStepsStates[stepIndex].expanded;
 | 
			
		||||
      return isLogElementInViewport(el.lastChild as Element);
 | 
			
		||||
      // use extraViewPortHeight to tolerate some extra "virtual view port" height (for example: the last line is partially visible)
 | 
			
		||||
      return isLogElementInViewport(el.lastChild as Element, {extraViewPortHeight: 5});
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    appendLogs(stepIndex: number, startTime: number, logLines: LogLine[]) {
 | 
			
		||||
@ -343,7 +366,6 @@ export default defineComponent({
 | 
			
		||||
      const abortController = new AbortController();
 | 
			
		||||
      this.loadingAbortController = abortController;
 | 
			
		||||
      try {
 | 
			
		||||
        const isFirstLoad = !this.run.status;
 | 
			
		||||
        const job = await this.fetchJobData(abortController);
 | 
			
		||||
        if (this.loadingAbortController !== abortController) return;
 | 
			
		||||
 | 
			
		||||
@ -353,10 +375,15 @@ export default defineComponent({
 | 
			
		||||
 | 
			
		||||
        // sync the currentJobStepsStates to store the job step states
 | 
			
		||||
        for (let i = 0; i < this.currentJob.steps.length; i++) {
 | 
			
		||||
          const expanded = isFirstLoad && this.optionAlwaysExpandRunning && this.currentJob.steps[i].status === 'running';
 | 
			
		||||
          const autoExpand = this.optionAlwaysExpandRunning && this.currentJob.steps[i].status === 'running';
 | 
			
		||||
          if (!this.currentJobStepsStates[i]) {
 | 
			
		||||
            // initial states for job steps
 | 
			
		||||
            this.currentJobStepsStates[i] = {cursor: null, expanded};
 | 
			
		||||
            this.currentJobStepsStates[i] = {cursor: null, expanded: autoExpand,  manuallyCollapsed: false};
 | 
			
		||||
          } else {
 | 
			
		||||
            // if the step is not manually collapsed by user, then auto-expand it if option is enabled
 | 
			
		||||
            if (autoExpand && !this.currentJobStepsStates[i].manuallyCollapsed) {
 | 
			
		||||
              this.currentJobStepsStates[i].expanded = true;
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@ -380,7 +407,10 @@ export default defineComponent({
 | 
			
		||||
          if (!autoScrollStepIndexes.get(stepIndex)) continue;
 | 
			
		||||
          autoScrollJobStepElement = this.getJobStepLogsContainer(stepIndex);
 | 
			
		||||
        }
 | 
			
		||||
        autoScrollJobStepElement?.lastElementChild.scrollIntoView({behavior: 'smooth', block: 'nearest'});
 | 
			
		||||
        const lastLogElem = autoScrollJobStepElement?.lastElementChild;
 | 
			
		||||
        if (lastLogElem && !isLogElementInViewport(lastLogElem)) {
 | 
			
		||||
          lastLogElem.scrollIntoView({behavior: 'smooth', block: 'end'});
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // clear the interval timer if the job is done
 | 
			
		||||
        if (this.run.done && this.intervalID) {
 | 
			
		||||
@ -408,9 +438,13 @@ export default defineComponent({
 | 
			
		||||
      if (this.menuVisible) this.menuVisible = false;
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    elStepsContainer(): HTMLElement {
 | 
			
		||||
      return this.$refs.stepsContainer as HTMLElement;
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    toggleTimeDisplay(type: 'seconds' | 'stamp') {
 | 
			
		||||
      this.timeVisible[`log-time-${type}`] = !this.timeVisible[`log-time-${type}`];
 | 
			
		||||
      for (const el of (this.$refs.steps as HTMLElement).querySelectorAll(`.log-time-${type}`)) {
 | 
			
		||||
      for (const el of this.elStepsContainer().querySelectorAll(`.log-time-${type}`)) {
 | 
			
		||||
        toggleElem(el, this.timeVisible[`log-time-${type}`]);
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
@ -419,6 +453,7 @@ export default defineComponent({
 | 
			
		||||
      this.isFullScreen = !this.isFullScreen;
 | 
			
		||||
      toggleFullScreen('.action-view-right', this.isFullScreen, '.action-view-body');
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    async hashChangeListener() {
 | 
			
		||||
      const selectedLogStep = window.location.hash;
 | 
			
		||||
      if (!selectedLogStep) return;
 | 
			
		||||
@ -431,7 +466,7 @@ export default defineComponent({
 | 
			
		||||
        // so logline can be selected by querySelector
 | 
			
		||||
        await this.loadJob();
 | 
			
		||||
      }
 | 
			
		||||
      const logLine = (this.$refs.steps as HTMLElement).querySelector(selectedLogStep);
 | 
			
		||||
      const logLine = this.elStepsContainer().querySelector(selectedLogStep);
 | 
			
		||||
      if (!logLine) return;
 | 
			
		||||
      logLine.querySelector<HTMLAnchorElement>('.line-num').click();
 | 
			
		||||
    },
 | 
			
		||||
@ -566,7 +601,7 @@ export default defineComponent({
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="job-step-container" ref="steps" v-if="currentJob.steps.length">
 | 
			
		||||
        <div class="job-step-container" ref="stepsContainer" v-if="currentJob.steps.length">
 | 
			
		||||
          <div class="job-step-section" v-for="(jobStep, i) in currentJob.steps" :key="i">
 | 
			
		||||
            <div class="job-step-summary" @click.stop="isExpandable(jobStep.status) && toggleStepLogs(i)" :class="[currentJobStepsStates[i].expanded ? 'selected' : '', isExpandable(jobStep.status) && 'step-expandable']">
 | 
			
		||||
              <!-- If the job is done and the job step log is loaded for the first time, show the loading icon
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user