<script lang="ts">
import {SvgIcon} from '../svg.ts';
import ActionRunStatus from './ActionRunStatus.vue';
import {defineComponent, type PropType} from 'vue';
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';
import type {IntervalId} from '../types.ts';
import {toggleFullScreen} from '../utils.ts';

// see "models/actions/status.go", if it needs to be used somewhere else, move it to a shared file like "types/actions.ts"
type RunStatus = 'unknown' | 'waiting' | 'running' | 'success' | 'failure' | 'cancelled' | 'skipped' | 'blocked';

type LogLine = {
  index: number;
  timestamp: number;
  message: string;
};

const LogLinePrefixesGroup = ['::group::', '##[group]'];
const LogLinePrefixesEndGroup = ['::endgroup::', '##[endgroup]'];

type LogLineCommand = {
  name: 'group' | 'endgroup',
  prefix: string,
}

type Job = {
  id: number;
  name: string;
  status: RunStatus;
  canRerun: boolean;
  duration: string;
}

type Step = {
  summary: string,
  duration: string,
  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)) {
      return {name: 'group', prefix};
    }
  }
  for (const prefix of LogLinePrefixesEndGroup) {
    if (line.message.startsWith(prefix)) {
      return {name: 'endgroup', prefix};
    }
  }
  return null;
}

function isLogElementInViewport(el: Element, {extraViewPortHeight}={extraViewPortHeight: 0}): boolean {
  const rect = el.getBoundingClientRect();
  // 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 = {
  autoScroll: boolean;
  expandRunning: boolean;
};

function getLocaleStorageOptions(): LocaleStorageOptions {
  try {
    const optsJson = localStorage.getItem('actions-view-options');
    if (optsJson) return JSON.parse(optsJson);
  } catch {}
  // if no options in localStorage, or failed to parse, return default options
  return {autoScroll: true, expandRunning: false};
}

export default defineComponent({
  name: 'RepoActionView',
  components: {
    SvgIcon,
    ActionRunStatus,
  },
  props: {
    runIndex: {
      type: String,
      default: '',
    },
    jobIndex: {
      type: String,
      default: '',
    },
    actionsURL: {
      type: String,
      default: '',
    },
    locale: {
      type: Object as PropType<Record<string, any>>,
      default: null,
    },
  },

  data() {
    const {autoScroll, expandRunning} = getLocaleStorageOptions();
    return {
      // internal state
      loadingAbortController: null as AbortController | null,
      intervalID: null as IntervalId | null,
      currentJobStepsStates: [] as Array<JobStepState>,
      artifacts: [] as Array<Record<string, any>>,
      menuVisible: false,
      isFullScreen: false,
      timeVisible: {
        'log-time-stamp': false,
        'log-time-seconds': false,
      },
      optionAlwaysAutoScroll: autoScroll ?? false,
      optionAlwaysExpandRunning: expandRunning ?? false,

      // provided by backend
      run: {
        link: '',
        title: '',
        titleHTML: '',
        status: '' as RunStatus, // do not show the status before initialized, otherwise it would show an incorrect "error" icon
        canCancel: false,
        canApprove: false,
        canRerun: false,
        canDeleteArtifact: false,
        done: false,
        workflowID: '',
        workflowLink: '',
        isSchedule: false,
        jobs: [
          // {
          //   id: 0,
          //   name: '',
          //   status: '',
          //   canRerun: false,
          //   duration: '',
          // },
        ] as Array<Job>,
        commit: {
          localeCommit: '',
          localePushedBy: '',
          shortSHA: '',
          link: '',
          pusher: {
            displayName: '',
            link: '',
          },
          branch: {
            name: '',
            link: '',
            isDeleted: false,
          },
        },
      },
      currentJob: {
        title: '',
        detail: '',
        steps: [
          // {
          //   summary: '',
          //   duration: '',
          //   status: '',
          // }
        ] as Array<Step>,
      },
    };
  },

  watch: {
    optionAlwaysAutoScroll() {
      this.saveLocaleStorageOptions();
    },
    optionAlwaysExpandRunning() {
      this.saveLocaleStorageOptions();
    },
  },

  async mounted() {
    // 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();
    window.addEventListener('hashchange', this.hashChangeListener);
  },

  beforeUnmount() {
    document.body.removeEventListener('click', this.closeDropdown);
    window.removeEventListener('hashchange', this.hashChangeListener);
  },

  unmounted() {
    // clear the interval timer when the component is unmounted
    // even our page is rendered once, not spa style
    if (this.intervalID) {
      clearInterval(this.intervalID);
      this.intervalID = null;
    }
  },

  methods: {
    saveLocaleStorageOptions() {
      const opts: LocaleStorageOptions = {autoScroll: this.optionAlwaysAutoScroll, expandRunning: this.optionAlwaysExpandRunning};
      localStorage.setItem('actions-view-options', JSON.stringify(opts));
    },

    // get the job step logs container ('.job-step-logs')
    getJobStepLogsContainer(stepIndex: number): HTMLElement {
      return (this.$refs.logs as any)[stepIndex];
    },

    // get the active logs container element, either the `job-step-logs` or the `job-log-list` in the `job-log-group`
    getActiveLogsContainer(stepIndex: number): HTMLElement {
      const el = this.getJobStepLogsContainer(stepIndex);
      // @ts-expect-error - _stepLogsActiveContainer is a custom property
      return el._stepLogsActiveContainer ?? el;
    },
    // begin a log group
    beginLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) {
      const el = (this.$refs.logs as any)[stepIndex];
      const elJobLogGroupSummary = createElementFromAttrs('summary', {class: 'job-log-group-summary'},
        this.createLogLine(stepIndex, startTime, {
          index: line.index,
          timestamp: line.timestamp,
          message: line.message.substring(cmd.prefix.length),
        }),
      );
      const elJobLogList = createElementFromAttrs('div', {class: 'job-log-list'});
      const elJobLogGroup = createElementFromAttrs('details', {class: 'job-log-group'},
        elJobLogGroupSummary,
        elJobLogList,
      );
      el.append(elJobLogGroup);
      el._stepLogsActiveContainer = elJobLogList;
    },
    // end a log group
    endLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) {
      const el = (this.$refs.logs as any)[stepIndex];
      el._stepLogsActiveContainer = null;
      el.append(this.createLogLine(stepIndex, startTime, {
        index: line.index,
        timestamp: line.timestamp,
        message: line.message.substring(cmd.prefix.length),
      }));
    },

    // show/hide the step logs for a step
    toggleStepLogs(idx: number) {
      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
    cancelRun() {
      POST(`${this.run.link}/cancel`);
    },
    // approve a run
    approveRun() {
      POST(`${this.run.link}/approve`);
    },

    createLogLine(stepIndex: number, startTime: number, line: LogLine) {
      const lineNum = createElementFromAttrs('a', {class: 'line-num muted', href: `#jobstep-${stepIndex}-${line.index}`},
        String(line.index),
      );

      const logTimeStamp = createElementFromAttrs('span', {class: 'log-time-stamp'},
        formatDatetime(new Date(line.timestamp * 1000)), // for "Show timestamps"
      );

      const logMsg = createElementFromAttrs('span', {class: 'log-msg'});
      logMsg.innerHTML = renderAnsi(line.message);

      const seconds = Math.floor(line.timestamp - startTime);
      const logTimeSeconds = createElementFromAttrs('span', {class: 'log-time-seconds'},
        `${seconds}s`, // for "Show seconds"
      );

      toggleElem(logTimeStamp, this.timeVisible['log-time-stamp']);
      toggleElem(logTimeSeconds, this.timeVisible['log-time-seconds']);

      return createElementFromAttrs('div', {id: `jobstep-${stepIndex}-${line.index}`, class: 'job-log-line'},
        lineNum, logTimeStamp, logMsg, logTimeSeconds,
      );
    },

    shouldAutoScroll(stepIndex: number): boolean {
      if (!this.optionAlwaysAutoScroll) return false;
      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;
      // 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[]) {
      for (const line of logLines) {
        const el = this.getActiveLogsContainer(stepIndex);
        const cmd = parseLineCommand(line);
        if (cmd?.name === 'group') {
          this.beginLogGroup(stepIndex, startTime, line, cmd);
          continue;
        } else if (cmd?.name === 'endgroup') {
          this.endLogGroup(stepIndex, startTime, line, cmd);
          continue;
        }
        el.append(this.createLogLine(stepIndex, startTime, line));
      }
    },

    async deleteArtifact(name: string) {
      if (!window.confirm(this.locale.confirmDeleteArtifact.replace('%s', name))) return;
      // TODO: should escape the "name"?
      await DELETE(`${this.run.link}/artifacts/${name}`);
      await this.loadJobForce();
    },

    async fetchJobData(abortController: AbortController) {
      const logCursors = this.currentJobStepsStates.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 and 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(`${this.actionsURL}/runs/${this.runIndex}/jobs/${this.jobIndex}`, {
        signal: abortController.signal,
        data: {logCursors},
      });
      return await resp.json();
    },

    async loadJobForce() {
      this.loadingAbortController?.abort();
      this.loadingAbortController = null;
      await this.loadJob();
    },

    async loadJob() {
      if (this.loadingAbortController) return;
      const abortController = new AbortController();
      this.loadingAbortController = abortController;
      try {
        const job = await this.fetchJobData(abortController);
        if (this.loadingAbortController !== abortController) return;

        this.artifacts = job.artifacts || [];
        this.run = job.state.run;
        this.currentJob = job.state.currentJob;

        // sync the currentJobStepsStates to store the job step states
        for (let i = 0; i < this.currentJob.steps.length; i++) {
          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: 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;
            }
          }
        }

        // find the step indexes that need to auto-scroll
        const autoScrollStepIndexes = new Map<number, boolean>();
        for (const logs of job.logs.stepsLog ?? []) {
          if (autoScrollStepIndexes.has(logs.step)) continue;
          autoScrollStepIndexes.set(logs.step, this.shouldAutoScroll(logs.step));
        }

        // append logs to the UI
        for (const logs of job.logs.stepsLog ?? []) {
          // save the cursor, it will be passed to backend next time
          this.currentJobStepsStates[logs.step].cursor = logs.cursor;
          this.appendLogs(logs.step, logs.started, logs.lines);
        }

        // auto-scroll to the last log line of the last step
        let autoScrollJobStepElement: HTMLElement;
        for (let stepIndex = 0; stepIndex < this.currentJob.steps.length; stepIndex++) {
          if (!autoScrollStepIndexes.get(stepIndex)) continue;
          autoScrollJobStepElement = this.getJobStepLogsContainer(stepIndex);
        }
        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) {
          clearInterval(this.intervalID);
          this.intervalID = null;
        }
      } catch (e) {
        // avoid network error while unloading page, and ignore "abort" error
        if (e instanceof TypeError || abortController.signal.aborted) return;
        throw e;
      } finally {
        if (this.loadingAbortController === abortController) this.loadingAbortController = null;
      }
    },

    isDone(status: RunStatus) {
      return ['success', 'skipped', 'failure', 'cancelled'].includes(status);
    },

    isExpandable(status: RunStatus) {
      return ['success', 'running', 'failure', 'cancelled'].includes(status);
    },

    closeDropdown() {
      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.elStepsContainer().querySelectorAll(`.log-time-${type}`)) {
        toggleElem(el, this.timeVisible[`log-time-${type}`]);
      }
    },

    toggleFullScreen() {
      this.isFullScreen = !this.isFullScreen;
      toggleFullScreen('.action-view-right', this.isFullScreen, '.action-view-body');
    },

    async hashChangeListener() {
      const selectedLogStep = window.location.hash;
      if (!selectedLogStep) return;
      const [_, step, _line] = selectedLogStep.split('-');
      const stepNum = Number(step);
      if (!this.currentJobStepsStates[stepNum]) return;
      if (!this.currentJobStepsStates[stepNum].expanded && this.currentJobStepsStates[stepNum].cursor === null) {
        this.currentJobStepsStates[stepNum].expanded = true;
        // need to await for load job if the step log is loaded for the first time
        // so logline can be selected by querySelector
        await this.loadJob();
      }
      const logLine = this.elStepsContainer().querySelector(selectedLogStep);
      if (!logLine) return;
      logLine.querySelector<HTMLAnchorElement>('.line-num').click();
    },
  },
});
</script>
<template>
  <!-- make the view container full width to make users easier to read logs -->
  <div class="ui fluid container">
    <div class="action-view-header">
      <div class="action-info-summary">
        <div class="action-info-summary-title">
          <ActionRunStatus :locale-status="locale.status[run.status]" :status="run.status" :size="20"/>
          <!-- eslint-disable-next-line vue/no-v-html -->
          <h2 class="action-info-summary-title-text" v-html="run.titleHTML"/>
        </div>
        <button class="ui basic small compact button primary" @click="approveRun()" v-if="run.canApprove">
          {{ locale.approve }}
        </button>
        <button class="ui basic small compact button red" @click="cancelRun()" v-else-if="run.canCancel">
          {{ locale.cancel }}
        </button>
        <button class="ui basic small compact button link-action" :data-url="`${run.link}/rerun`" v-else-if="run.canRerun">
          {{ locale.rerun_all }}
        </button>
      </div>
      <div class="action-commit-summary">
        <span><a class="muted" :href="run.workflowLink"><b>{{ run.workflowID }}</b></a>:</span>
        <template v-if="run.isSchedule">
          {{ locale.scheduled }}
        </template>
        <template v-else>
          {{ locale.commit }}
          <a class="muted" :href="run.commit.link">{{ run.commit.shortSHA }}</a>
          {{ locale.pushedBy }}
          <a class="muted" :href="run.commit.pusher.link">{{ run.commit.pusher.displayName }}</a>
        </template>
        <span class="ui label tw-max-w-full" v-if="run.commit.shortSHA">
          <span v-if="run.commit.branch.isDeleted" class="gt-ellipsis tw-line-through" :data-tooltip-content="run.commit.branch.name">{{ run.commit.branch.name }}</span>
          <a v-else class="gt-ellipsis" :href="run.commit.branch.link" :data-tooltip-content="run.commit.branch.name">{{ run.commit.branch.name }}</a>
        </span>
      </div>
    </div>
    <div class="action-view-body">
      <div class="action-view-left">
        <div class="job-group-section">
          <div class="job-brief-list">
            <a class="job-brief-item" :href="run.link+'/jobs/'+index" :class="parseInt(jobIndex) === index ? 'selected' : ''" v-for="(job, index) in run.jobs" :key="job.id">
              <div class="job-brief-item-left">
                <ActionRunStatus :locale-status="locale.status[job.status]" :status="job.status"/>
                <span class="job-brief-name tw-mx-2 gt-ellipsis">{{ job.name }}</span>
              </div>
              <span class="job-brief-item-right">
                <SvgIcon name="octicon-sync" role="button" :data-tooltip-content="locale.rerun" class="job-brief-rerun tw-mx-2 link-action" :data-url="`${run.link}/jobs/${index}/rerun`" v-if="job.canRerun"/>
                <span class="step-summary-duration">{{ job.duration }}</span>
              </span>
            </a>
          </div>
        </div>
        <div class="job-artifacts" v-if="artifacts.length > 0">
          <div class="job-artifacts-title">
            {{ locale.artifactsTitle }}
          </div>
          <ul class="job-artifacts-list">
            <template v-for="artifact in artifacts" :key="artifact.name">
              <li class="job-artifacts-item">
                <template v-if="artifact.status !== 'expired'">
                  <a class="flex-text-inline" target="_blank" :href="run.link+'/artifacts/'+artifact.name">
                    <SvgIcon name="octicon-file" class="text black"/>
                    <span class="gt-ellipsis">{{ artifact.name }}</span>
                  </a>
                  <a v-if="run.canDeleteArtifact" @click="deleteArtifact(artifact.name)">
                    <SvgIcon name="octicon-trash" class="text black"/>
                  </a>
                </template>
                <span v-else class="flex-text-inline text light grey">
                  <SvgIcon name="octicon-file"/>
                  <span class="gt-ellipsis">{{ artifact.name }}</span>
                  <span class="ui label text light grey tw-flex-shrink-0">{{ locale.artifactExpired }}</span>
                </span>
              </li>
            </template>
          </ul>
        </div>
      </div>

      <div class="action-view-right">
        <div class="job-info-header">
          <div class="job-info-header-left gt-ellipsis">
            <h3 class="job-info-header-title gt-ellipsis">
              {{ currentJob.title }}
            </h3>
            <p class="job-info-header-detail">
              {{ currentJob.detail }}
            </p>
          </div>
          <div class="job-info-header-right">
            <div class="ui top right pointing dropdown custom jump item" @click.stop="menuVisible = !menuVisible" @keyup.enter="menuVisible = !menuVisible">
              <button class="ui button tw-px-3">
                <SvgIcon name="octicon-gear" :size="18"/>
              </button>
              <div class="menu transition action-job-menu" :class="{visible: menuVisible}" v-if="menuVisible" v-cloak>
                <a class="item" @click="toggleTimeDisplay('seconds')">
                  <i class="icon"><SvgIcon :name="timeVisible['log-time-seconds'] ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
                  {{ locale.showLogSeconds }}
                </a>
                <a class="item" @click="toggleTimeDisplay('stamp')">
                  <i class="icon"><SvgIcon :name="timeVisible['log-time-stamp'] ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
                  {{ locale.showTimeStamps }}
                </a>
                <a class="item" @click="toggleFullScreen()">
                  <i class="icon"><SvgIcon :name="isFullScreen ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
                  {{ locale.showFullScreen }}
                </a>

                <div class="divider"/>
                <a class="item" @click="optionAlwaysAutoScroll = !optionAlwaysAutoScroll">
                  <i class="icon"><SvgIcon :name="optionAlwaysAutoScroll ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
                  {{ locale.logsAlwaysAutoScroll }}
                </a>
                <a class="item" @click="optionAlwaysExpandRunning = !optionAlwaysExpandRunning">
                  <i class="icon"><SvgIcon :name="optionAlwaysExpandRunning ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
                  {{ locale.logsAlwaysExpandRunning }}
                </a>

                <div class="divider"/>
                <a :class="['item', !currentJob.steps.length ? 'disabled' : '']" :href="run.link+'/jobs/'+jobIndex+'/logs'" target="_blank">
                  <i class="icon"><SvgIcon name="octicon-download"/></i>
                  {{ locale.downloadLogs }}
                </a>
              </div>
            </div>
          </div>
        </div>
        <!-- always create the node because we have our own event listeners on it, don't use "v-if" -->
        <div class="job-step-container" ref="stepsContainer" v-show="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
                currentJobStepsStates[i].cursor === null means the log is loaded for the first time
              -->
              <SvgIcon v-if="isDone(run.status) && currentJobStepsStates[i].expanded && currentJobStepsStates[i].cursor === null" name="octicon-sync" class="tw-mr-2 circular-spin"/>
              <SvgIcon v-else :name="currentJobStepsStates[i].expanded ? 'octicon-chevron-down': 'octicon-chevron-right'" :class="['tw-mr-2', !isExpandable(jobStep.status) && 'tw-invisible']"/>
              <ActionRunStatus :status="jobStep.status" class="tw-mr-2"/>

              <span class="step-summary-msg gt-ellipsis">{{ jobStep.summary }}</span>
              <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,
            use native DOM elements for "log line" to improve performance, Vue is not suitable for managing so many reactive elements. -->
            <div class="job-step-logs" ref="logs" v-show="currentJobStepsStates[i].expanded"/>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>
<style scoped>
.action-view-body {
  padding-top: 12px;
  padding-bottom: 12px;
  display: flex;
  gap: 12px;
}

/* ================ */
/* action view header */

.action-view-header {
  margin-top: 8px;
}

.action-info-summary {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}

.action-info-summary-title {
  display: flex;
  align-items: center;
  gap: 0.5em;
}

.action-info-summary-title-text {
  font-size: 20px;
  margin: 0;
  flex: 1;
  overflow-wrap: anywhere;
}

.action-info-summary .ui.button {
  margin: 0;
  white-space: nowrap;
}

.action-commit-summary {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 5px;
  margin-left: 28px;
}

@media (max-width: 767.98px) {
  .action-commit-summary {
    margin-left: 0;
    margin-top: 8px;
  }
}

/* ================ */
/* action view left */

.action-view-left {
  width: 30%;
  max-width: 400px;
  position: sticky;
  top: 12px;
  max-height: 100vh;
  overflow-y: auto;
  background: var(--color-body);
  z-index: 2; /* above .job-info-header */
}

@media (max-width: 767.98px) {
  .action-view-left {
    position: static; /* can not sticky because multiple jobs would overlap into right view */
  }
}

.job-artifacts-title {
  font-size: 18px;
  margin-top: 16px;
  padding: 16px 10px 0 20px;
  border-top: 1px solid var(--color-secondary);
}

.job-artifacts-item {
  margin: 5px 0;
  padding: 6px;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.job-artifacts-list {
  padding-left: 12px;
  list-style: none;
}

.job-brief-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.job-brief-item {
  padding: 10px;
  border-radius: var(--border-radius);
  text-decoration: none;
  display: flex;
  flex-wrap: nowrap;
  justify-content: space-between;
  align-items: center;
  color: var(--color-text);
}

.job-brief-item:hover {
  background-color: var(--color-hover);
}

.job-brief-item.selected {
  font-weight: var(--font-weight-bold);
  background-color: var(--color-active);
}

.job-brief-item:first-of-type {
  margin-top: 0;
}

.job-brief-item .job-brief-rerun {
  cursor: pointer;
}

.job-brief-item .job-brief-item-left {
  display: flex;
  width: 100%;
  min-width: 0;
}

.job-brief-item .job-brief-item-left span {
  display: flex;
  align-items: center;
}

.job-brief-item .job-brief-item-left .job-brief-name {
  display: block;
  width: 70%;
}

.job-brief-item .job-brief-item-right {
  display: flex;
  align-items: center;
}

/* ================ */
/* action view right */

.action-view-right {
  flex: 1;
  color: var(--color-console-fg-subtle);
  max-height: 100%;
  width: 70%;
  display: flex;
  flex-direction: column;
  border: 1px solid var(--color-console-border);
  border-radius: var(--border-radius);
  background: var(--color-console-bg);
  align-self: flex-start;
}

/* begin fomantic button overrides */

.action-view-right .ui.button,
.action-view-right .ui.button:focus {
  background: transparent;
  color: var(--color-console-fg-subtle);
}

.action-view-right .ui.button:hover {
  background: var(--color-console-hover-bg);
  color: var(--color-console-fg);
}

.action-view-right .ui.button:active {
  background: var(--color-console-active-bg);
  color: var(--color-console-fg);
}

/* end fomantic button overrides */

/* begin fomantic dropdown menu overrides */

.action-view-right .ui.dropdown .menu {
  background: var(--color-console-menu-bg);
  border-color: var(--color-console-menu-border);
}

.action-view-right .ui.dropdown .menu > .item {
  color: var(--color-console-fg);
}

.action-view-right .ui.dropdown .menu > .item:hover {
  color: var(--color-console-fg);
  background: var(--color-console-hover-bg);
}

.action-view-right .ui.dropdown .menu > .item:active {
  color: var(--color-console-fg);
  background: var(--color-console-active-bg);
}

.action-view-right .ui.dropdown .menu > .divider {
  border-top-color: var(--color-console-menu-border);
}

.action-view-right .ui.pointing.dropdown > .menu:not(.hidden)::after {
  background: var(--color-console-menu-bg);
  box-shadow: -1px -1px 0 0 var(--color-console-menu-border);
}

/* end fomantic dropdown menu overrides */

.job-info-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0 12px;
  position: sticky;
  top: 0;
  height: 60px;
  z-index: 1; /* above .job-step-container */
  background: var(--color-console-bg);
  border-radius: 3px;
}

.job-info-header:has(+ .job-step-container) {
  border-radius: var(--border-radius) var(--border-radius) 0 0;
}

.job-info-header .job-info-header-title {
  color: var(--color-console-fg);
  font-size: 16px;
  margin: 0;
}

.job-info-header .job-info-header-detail {
  color: var(--color-console-fg-subtle);
  font-size: 12px;
}

.job-info-header-left {
  flex: 1;
}

.job-step-container {
  max-height: 100%;
  border-radius: 0 0 var(--border-radius) var(--border-radius);
  border-top: 1px solid var(--color-console-border);
  z-index: 0;
}

.job-step-container .job-step-summary {
  padding: 5px 10px;
  display: flex;
  align-items: center;
  border-radius: var(--border-radius);
}

.job-step-container .job-step-summary.step-expandable {
  cursor: pointer;
}

.job-step-container .job-step-summary.step-expandable:hover {
  color: var(--color-console-fg);
  background: var(--color-console-hover-bg);
}

.job-step-container .job-step-summary .step-summary-msg {
  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);
  position: sticky;
  top: 60px;
}

@media (max-width: 767.98px) {
  .action-view-body {
    flex-direction: column;
  }
  .action-view-left, .action-view-right {
    width: 100%;
  }
  .action-view-left {
    max-width: none;
  }
}
</style>

<style> /* eslint-disable-line vue-scoped-css/enforce-style-type */
/* some elements are not managed by vue, so we need to use global style */
.job-step-section {
  margin: 10px;
}

.job-step-section .job-step-logs {
  font-family: var(--fonts-monospace);
  margin: 8px 0;
  font-size: 12px;
}

.job-step-section .job-step-logs .job-log-line {
  display: flex;
}

.job-log-line:hover,
.job-log-line:target {
  background-color: var(--color-console-hover-bg);
}

.job-log-line:target {
  scroll-margin-top: 95px;
}

/* class names 'log-time-seconds' and 'log-time-stamp' are used in the method toggleTimeDisplay */
.job-log-line .line-num, .log-time-seconds {
  width: 48px;
  color: var(--color-text-light-3);
  text-align: right;
  user-select: none;
}

.job-log-line:target > .line-num {
  color: var(--color-primary);
  text-decoration: underline;
}

.log-time-seconds {
  padding-right: 2px;
}

.job-log-line .log-time,
.log-time-stamp {
  color: var(--color-text-light-3);
  margin-left: 10px;
  white-space: nowrap;
}

.job-step-logs .job-log-line .log-msg {
  flex: 1;
  white-space: break-spaces;
  margin-left: 10px;
  overflow-wrap: anywhere;
}

/* selectors here are intentionally exact to only match fullscreen */

.full.height > .action-view-right {
  width: 100%;
  height: 100%;
  padding: 0;
  border-radius: 0;
}

.full.height > .action-view-right > .job-info-header {
  border-radius: 0;
}

.full.height > .action-view-right > .job-step-container {
  height: calc(100% - 60px);
  border-radius: 0;
}

.job-log-group .job-log-list .job-log-line .log-msg {
  margin-left: 2em;
}

.job-log-group-summary {
  position: relative;
}

.job-log-group-summary > .job-log-line {
  position: absolute;
  inset: 0;
  z-index: -1; /* to avoid hiding the triangle of the "details" element */
  overflow: hidden;
}
</style>
