diff --git a/web/src/assets/locales/en.json b/web/src/assets/locales/en.json
index d63d76b0c..e0069db5d 100644
--- a/web/src/assets/locales/en.json
+++ b/web/src/assets/locales/en.json
@@ -236,7 +236,9 @@
"log_download": "Download",
"log_delete": "Delete",
"log_auto_scroll": "Enable automatic scrolling",
- "log_auto_scroll_off": "Disable automatic scrolling"
+ "log_auto_scroll_off": "Disable automatic scrolling",
+ "expand_all": "Expand all",
+ "collapse_all": "Collapse all"
},
"protected": {
"awaits": "This pipeline is awaiting approval from a maintainer!",
diff --git a/web/src/components/atomic/Icon.vue b/web/src/components/atomic/Icon.vue
index 9c3d68cd8..8662c02cd 100644
--- a/web/src/components/atomic/Icon.vue
+++ b/web/src/components/atomic/Icon.vue
@@ -83,6 +83,8 @@
+
+
@@ -187,6 +189,8 @@ import {
mdiToolboxOutline,
mdiTrashCanOutline,
mdiTrayFull,
+ mdiUnfoldLessHorizontal,
+ mdiUnfoldMoreHorizontal,
mdiWrenchCogOutline,
} from '@mdi/js';
import { siForgejo, siGitea } from 'simple-icons';
@@ -267,6 +271,8 @@ export type IconNames =
| 'forge'
| 'fullscreen'
| 'exit-fullscreen'
+ | 'expand-all'
+ | 'collapse-all'
| 'folder'
| 'folder-open'
| 'file';
diff --git a/web/src/components/repo/pipeline/PipelineLog.vue b/web/src/components/repo/pipeline/PipelineLog.vue
index c371e800e..0bb405ed8 100644
--- a/web/src/components/repo/pipeline/PipelineLog.vue
+++ b/web/src/components/repo/pipeline/PipelineLog.vue
@@ -53,50 +53,92 @@
:icon="autoScroll ? 'auto-scroll' : 'auto-scroll-off'"
@click="autoScroll = !autoScroll"
/>
-
+
+
+
+
+
+
@@ -130,23 +172,32 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, toRef, watch } fro
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
+import Icon from '~/components/atomic/Icon.vue';
import IconButton from '~/components/atomic/IconButton.vue';
import PipelineStatusIcon from '~/components/repo/pipeline/PipelineStatusIcon.vue';
import useApiClient from '~/compositions/useApiClient';
import useConfig from '~/compositions/useConfig';
import { requiredInject } from '~/compositions/useInjectProvide';
import useNotifications from '~/compositions/useNotifications';
-import type { Pipeline, PipelineStep, PipelineWorkflow } from '~/lib/api/types';
+import type { Pipeline, PipelineConfig, PipelineStep, PipelineWorkflow } from '~/lib/api/types';
import { debounce } from '~/lib/utils';
interface LogLine {
index: number;
number: number;
text?: string;
+ rawText?: string;
time?: number;
type: 'error' | 'warning' | null;
}
+interface LogBlock {
+ command: LogLine | null;
+ lines: LogLine[];
+ id: number;
+ isActualCommand: boolean;
+}
+
const props = defineProps<{
pipeline: Pipeline;
stepId: number;
@@ -162,6 +213,7 @@ const pipeline = toRef(props, 'pipeline');
const stepId = toRef(props, 'stepId');
const repo = requiredInject('repo');
const repoPermissions = requiredInject('repo-permissions');
+const pipelineConfigs = requiredInject('pipeline-configs');
const apiClient = useApiClient();
const route = useRoute();
@@ -191,6 +243,86 @@ const config = useConfig();
const maxLineCount = config.maxPipelineLogLineCount; // TODO(2653): implement lazy-loading support
const hasPushPermission = computed(() => repoPermissions?.value?.push);
+const collapsedCommands = ref(new Set());
+
+const commandRegex = /^\s*-\s(.+)$/gm;
+const specialCharsRegex = /[.*+?^${}()|[\]\\]/g;
+const matrixVariableRegex = /\\\$(\\\{\w+\\\})/g;
+
+const knownCommandMatchers = computed(() => {
+ if (!pipelineConfigs.value) return [];
+ const patterns: RegExp[] = [];
+ pipelineConfigs.value.forEach((config: PipelineConfig) => {
+ const decoded = decode(config.data);
+ const matches = decoded.matchAll(commandRegex);
+ for (const match of matches) {
+ const rawCommand = match[1].trim();
+ // Replace matrix variable ${VAR} with a wildcard match (non-greedy)
+ const patternString = rawCommand
+ .replace(specialCharsRegex, '\\$&') // escape all
+ .replace(matrixVariableRegex, '.*'); // match ${VAR}
+
+ patterns.push(new RegExp(`^${patternString}$`));
+ }
+ });
+ return patterns;
+});
+
+const groupedLogs = computed(() => {
+ if (!log.value) return [];
+
+ if (!pipelineConfigs.value || pipelineConfigs.value.length === 0) {
+ return [
+ {
+ id: 0,
+ command: null,
+ lines: log.value,
+ isActualCommand: false,
+ },
+ ];
+ }
+
+ const blocks: LogBlock[] = [];
+ let currentBlock: LogBlock | null = null;
+
+ log.value.forEach((line) => {
+ const trimmedText = (line.rawText || '').trim();
+
+ let isCommand = false;
+ if (trimmedText.startsWith('+ ')) {
+ const cmdPart = trimmedText.slice(2).trim();
+ isCommand = knownCommandMatchers.value.some((matcher) => matcher.test(cmdPart));
+ }
+
+ if (isCommand) {
+ currentBlock = {
+ command: line,
+ lines: [line],
+ id: line.number,
+ isActualCommand: true,
+ };
+ blocks.push(currentBlock);
+ } else {
+ if (!currentBlock) {
+ currentBlock = {
+ command: { number: 0, text: 'Initialization', type: null, index: -1 } as LogLine,
+ lines: [],
+ id: 0,
+ isActualCommand: false,
+ };
+ blocks.push(currentBlock);
+ }
+ currentBlock.lines.push(line);
+ }
+ });
+
+ return blocks;
+});
+
+const hasGroupedLogs = computed(() => {
+ return groupedLogs.value.find((g) => g.isActualCommand);
+});
+
const urlRegex = /https?:\/\/\S+/g;
function isScrolledToBottom(): boolean {
@@ -209,6 +341,28 @@ function formatTime(time?: number): string {
return time === undefined ? '' : `${time}s`;
}
+function toggleGroup(id: number) {
+ if (collapsedCommands.value.has(id)) {
+ collapsedCommands.value.delete(id);
+ } else {
+ collapsedCommands.value.add(id);
+ }
+}
+
+function expandAll() {
+ collapsedCommands.value.clear();
+}
+
+function collapseAll() {
+ const newSet = new Set();
+ groupedLogs.value.forEach((group) => {
+ if (group.isActualCommand) {
+ newSet.add(group.id);
+ }
+ });
+ collapsedCommands.value = newSet;
+}
+
function processText(text: string): string {
let txt = ansiUp.value.ansi_to_html(`${decode(text)}\n`);
txt = txt.replace(
@@ -219,10 +373,12 @@ function processText(text: string): string {
}
function writeLog(line: Partial) {
+ const rawText = decode(line.text ?? '');
logBuffer.value.push({
index: line.index ?? 0,
number: (line.index ?? 0) + 1,
text: processText(line.text ?? ''),
+ rawText,
time: line.time ?? 0,
type: null, // TODO: implement way to detect errors and warnings
});
@@ -405,4 +561,38 @@ watch(step, async (newStep, oldStep) => {
}
}
});
+
+const expandLogGroupWithPageHash = (hash: string) => {
+ if (hash.startsWith('#L')) {
+ const lineNum = Number.parseInt(hash.substring(2));
+ const parentGroup = groupedLogs.value.find((g) => lineNum === g.id || g.lines.some((l) => l.number === lineNum));
+ if (parentGroup && collapsedCommands.value.has(parentGroup.id)) {
+ collapsedCommands.value.delete(parentGroup.id);
+ }
+ }
+};
+
+// When user click on a step, if the step has already finished running, show user the
+// only the outline by collapse all log groups
+watch(loadedLogs, async (isLoaded, wasLoaded) => {
+ // Only trigger when transitioning from unloaded to loaded state
+ if (isLoaded && !wasLoaded) {
+ const isFinished = step.value && !['running', 'pending', 'started'].includes(step.value.state);
+ if (isFinished) {
+ // Wait for groupedLogs computed property to update
+ await nextTick();
+ collapseAll();
+ expandLogGroupWithPageHash(route.hash);
+ }
+ }
+});
+
+// If route hash contain line that is in a collapsed log group, expand it
+watch(
+ () => route.hash,
+ (newHash) => {
+ expandLogGroupWithPageHash(newHash);
+ },
+ { immediate: true },
+);