Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@

## What's new in this fork

### 2026-06-25 — Search in script output

The output panel now has a find bar (search icon in the "Output" header): type to
highlight all matches in the log, with a match counter and previous/next
navigation (Enter / Shift+Enter, Esc to close). Matches are highlighted via the
CSS Custom Highlight API, so the live-appending terminal and inline images are
left untouched. Available wherever logs are shown (execution view and history).

### 2026-06-24 — Dependency security scan in CI

A `Security scan` job audits dependencies on every push/PR: `pip-audit` for the
Expand Down
196 changes: 195 additions & 1 deletion web-src/src/common/components/log_panel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,29 @@
<div class="log-panel-header">
<v-icon :size="16">terminal</v-icon>
<span>Output</span>

<div class="log-search">
<template v-if="searchActive">
<input
ref="searchInput"
v-model="searchQuery"
class="log-search-input"
type="text"
placeholder="Find in output"
spellcheck="false"
autocomplete="off"
@keydown.enter.prevent="$event.shiftKey ? prevMatch() : nextMatch()"
@keydown.esc.prevent="closeSearch"
/>
<span class="log-search-count">{{ matchCountLabel }}</span>
<v-icon class="log-search-btn" size="16" title="Previous match (Shift+Enter)"
:class="{disabled: matchCount === 0}" @click="prevMatch">keyboard_arrow_up</v-icon>
<v-icon class="log-search-btn" size="16" title="Next match (Enter)"
:class="{disabled: matchCount === 0}" @click="nextMatch">keyboard_arrow_down</v-icon>
<v-icon class="log-search-btn" size="16" title="Close (Esc)" @click="closeSearch">close</v-icon>
</template>
<v-icon v-else class="log-search-btn" size="16" title="Search output" @click="openSearch">search</v-icon>
</div>
</div>
<div ref="shadow" class="log-panel-shadow"
v-bind:class="{
Expand All @@ -25,6 +48,7 @@ import {TerminalOutput} from '@/common/components/terminal/ansi/TerminalOutput'
import {TextOutput} from '@/common/components/terminal/text/TextOutput'
import {HtmlIFrameOutput} from '@/common/components/terminal/html/HtmlIFrameOutput'
import {HtmlOutput} from '@/common/components/terminal/html/HtmlOutput'
import {applyHighlights, clearHighlights, findTextMatches} from '@/common/components/terminal/logSearch'

export default {
props: {
Expand All @@ -44,7 +68,21 @@ export default {
mouseDown: false,
scrollUpdater: null,
needScrollUpdate: false,
text: ''
text: '',
searchActive: false,
searchQuery: '',
searchRanges: [],
matchCount: 0,
currentMatch: 0
}
},

computed: {
matchCountLabel: function () {
if (this.matchCount === 0) {
return this.searchQuery ? '0/0' : '';
}
return (this.currentMatch + 1) + '/' + this.matchCount;
}
},

Expand Down Expand Up @@ -117,6 +155,27 @@ export default {
this.output.write(text);

this.revalidateScroll();

if (this.searchActive && this.searchQuery) {
// New output may contain (or shift) matches — refresh, preserving the
// current position by index.
this.scheduleSearchRefresh();
}
},

scheduleSearchRefresh: function () {
if (this._searchRefreshScheduled) {
return;
}
this._searchRefreshScheduled = true;
this.$nextTick(() => {
this._searchRefreshScheduled = false;
const previous = this.currentMatch;
this.searchRanges = findTextMatches(this.output.element, this.searchQuery);
this.matchCount = this.searchRanges.length;
this.currentMatch = Math.min(previous, Math.max(0, this.matchCount - 1));
applyHighlights(this.searchRanges, this.matchCount ? this.currentMatch : -1);
});
},

removeInlineImage: function (output_path) {
Expand Down Expand Up @@ -144,6 +203,76 @@ export default {
URL.revokeObjectURL(url);
},

openSearch: function () {
this.searchActive = true;
this.$nextTick(() => {
if (this.$refs.searchInput) {
this.$refs.searchInput.focus();
}
});
},

closeSearch: function () {
this.searchActive = false;
this.searchQuery = '';
this.searchRanges = [];
this.matchCount = 0;
this.currentMatch = 0;
clearHighlights();
},

runSearch: function () {
if (!this.output || !this.output.element || !this.searchQuery) {
this.searchRanges = [];
this.matchCount = 0;
this.currentMatch = 0;
clearHighlights();
return;
}

this.searchRanges = findTextMatches(this.output.element, this.searchQuery);
this.matchCount = this.searchRanges.length;
if (this.matchCount === 0) {
this.currentMatch = 0;
} else if (this.currentMatch >= this.matchCount) {
this.currentMatch = this.matchCount - 1;
}

applyHighlights(this.searchRanges, this.matchCount ? this.currentMatch : -1);
this.scrollToCurrentMatch();
},

nextMatch: function () {
if (this.matchCount === 0) {
return;
}
this.currentMatch = (this.currentMatch + 1) % this.matchCount;
applyHighlights(this.searchRanges, this.currentMatch);
this.scrollToCurrentMatch();
},

prevMatch: function () {
if (this.matchCount === 0) {
return;
}
this.currentMatch = (this.currentMatch - 1 + this.matchCount) % this.matchCount;
applyHighlights(this.searchRanges, this.currentMatch);
this.scrollToCurrentMatch();
},

scrollToCurrentMatch: function () {
const range = this.searchRanges[this.currentMatch];
if (!range) {
return;
}
// Pause autoscroll-following while the user navigates matches.
this.atBottom = false;
const target = range.startContainer.parentElement;
if (target && target.scrollIntoView) {
target.scrollIntoView({block: 'center', inline: 'nearest'});
}
},

renderOutputElement: function () {
if (!this.output || !this.$el) {
return
Expand All @@ -167,9 +296,15 @@ export default {
beforeUnmount: function () {
window.removeEventListener('resize', this.revalidateScroll);
window.clearInterval(this.scrollUpdater);
clearHighlights();
},

watch: {
searchQuery: function () {
this.currentMatch = 0;
this.runSearch();
},

outputFormat: {
immediate: true,
handler: function () {
Expand Down Expand Up @@ -240,6 +375,52 @@ export default {
color: #768390;
}

.log-search {
margin-left: auto;
display: flex;
align-items: center;
gap: 4px;
}

.log-search-input {
width: 150px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 4px;
color: #adbac7;
font-family: var(--font-mono);
font-size: 12px;
padding: 2px 6px;
outline: none;
}

.log-search-input:focus {
border-color: var(--primary-color);
}

.log-search-count {
color: #768390;
font-size: 11px;
min-width: 34px;
text-align: right;
}

.log-search-btn {
cursor: pointer;
color: #768390;
border-radius: 4px;
}

.log-search-btn:hover {
color: #adbac7;
background: #30363d;
}

.log-search-btn.disabled {
opacity: 0.4;
pointer-events: none;
}

.log-panel-shadow {
position: absolute;

Expand Down Expand Up @@ -321,3 +502,16 @@ export default {


</style>

<!-- Global: ::highlight() applies to Ranges in the externally-inserted output
element, which doesn't carry the scoped-style attribute. -->
<style>
::highlight(log-search) {
background-color: rgba(255, 214, 0, 0.35);
}

::highlight(log-search-current) {
background-color: #f5b301;
color: #0d1117;
}
</style>
87 changes: 87 additions & 0 deletions web-src/src/common/components/terminal/logSearch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* Search helpers for the log panel.
*
* Matching is done over the rendered text nodes of the output element (whatever
* the output format produced) and exposed as DOM Ranges, so highlighting can use
* the CSS Custom Highlight API without mutating the output DOM — which keeps it
* compatible with the live-appending terminal and inline images.
*/

const HIGHLIGHT_NAME = 'log-search';
const CURRENT_HIGHLIGHT_NAME = 'log-search-current';

/**
* Find every (case-insensitive) occurrence of `query` in the text nodes under
* `root` and return them as an array of Ranges, in document order.
*
* @param {Node} root
* @param {string} query
* @returns {Range[]}
*/
export function findTextMatches(root, query) {
const ranges = [];
if (!root || !query) {
return ranges;
}

const needle = query.toLowerCase();
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);

let node;
while ((node = walker.nextNode())) {
const haystack = node.nodeValue.toLowerCase();
let from = 0;
let index;
while ((index = haystack.indexOf(needle, from)) !== -1) {
const range = document.createRange();
range.setStart(node, index);
range.setEnd(node, index + needle.length);
ranges.push(range);
from = index + needle.length;
}
}

return ranges;
}

/** Whether the CSS Custom Highlight API is available in this browser. */
export function highlightsSupported() {
return typeof CSS !== 'undefined'
&& CSS.highlights
&& typeof Highlight !== 'undefined';
}

/**
* Register the given ranges as highlights: all matches under one highlight name,
* the current match under another so it can be styled distinctly. No-op (safe)
* when the API is unavailable.
*
* @param {Range[]} ranges
* @param {number} currentIndex
*/
export function applyHighlights(ranges, currentIndex) {
if (!highlightsSupported()) {
return;
}

clearHighlights();

if (!ranges || ranges.length === 0) {
return;
}

CSS.highlights.set(HIGHLIGHT_NAME, new Highlight(...ranges));

if (currentIndex >= 0 && currentIndex < ranges.length) {
CSS.highlights.set(CURRENT_HIGHLIGHT_NAME, new Highlight(ranges[currentIndex]));
}
}

/** Remove all log-search highlights. Safe when the API is unavailable. */
export function clearHighlights() {
if (!highlightsSupported()) {
return;
}
CSS.highlights.delete(HIGHLIGHT_NAME);
CSS.highlights.delete(CURRENT_HIGHLIGHT_NAME);
}
Loading