From c76c129e43349550be0b147029b051783c95f0e6 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 4 Feb 2026 16:35:50 +0000
Subject: [PATCH 1/3] Initial plan
From 484d820693835cfe828242fc260dca818837a078 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 4 Feb 2026 16:43:02 +0000
Subject: [PATCH 2/3] Add search functionality to clusters page for searching
within tabs
Co-authored-by: LukasWallrich <60155545+LukasWallrich@users.noreply.github.com>
---
layouts/partials/custom_js.html | 6 +
static/js/clusters-search.js | 359 ++++++++++++++++++++++++++++++++
2 files changed, 365 insertions(+)
create mode 100644 layouts/partials/custom_js.html
create mode 100644 static/js/clusters-search.js
diff --git a/layouts/partials/custom_js.html b/layouts/partials/custom_js.html
new file mode 100644
index 0000000000..6483386262
--- /dev/null
+++ b/layouts/partials/custom_js.html
@@ -0,0 +1,6 @@
+{{/* Custom JavaScript for FORRT site */}}
+
+{{/* Include clusters search functionality on clusters page */}}
+{{ if eq .RelPermalink "/clusters/" }}
+
+{{ end }}
diff --git a/static/js/clusters-search.js b/static/js/clusters-search.js
new file mode 100644
index 0000000000..205109829c
--- /dev/null
+++ b/static/js/clusters-search.js
@@ -0,0 +1,359 @@
+/**
+ * Clusters Page Search Functionality
+ * Enables searching within Bootstrap tab content that would otherwise be hidden from Ctrl-F
+ */
+(function() {
+ 'use strict';
+
+ // Wait for DOM to be ready
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+ } else {
+ init();
+ }
+
+ function init() {
+ // Only run on clusters page
+ if (!window.location.pathname.includes('/clusters')) {
+ return;
+ }
+
+ createSearchInterface();
+ setupSearchHandlers();
+ }
+
+ function createSearchInterface() {
+ // Find the intro section to insert search box after it
+ const introSection = document.querySelector('.wg-blank');
+ if (!introSection) return;
+
+ // Create search container
+ const searchContainer = document.createElement('div');
+ searchContainer.className = 'cluster-search-container';
+ searchContainer.innerHTML = `
+
+ `;
+
+ // Insert after intro section
+ introSection.parentNode.insertBefore(searchContainer, introSection.nextSibling);
+ }
+
+ function setupSearchHandlers() {
+ const searchInput = document.getElementById('clusterSearchInput');
+ const searchBtn = document.getElementById('clusterSearchBtn');
+ const clearBtn = document.getElementById('clusterClearBtn');
+ const resultsDiv = document.getElementById('clusterSearchResults');
+
+ if (!searchInput || !searchBtn || !clearBtn) return;
+
+ // Search on button click
+ searchBtn.addEventListener('click', performSearch);
+
+ // Search on Enter key
+ searchInput.addEventListener('keypress', function(e) {
+ if (e.key === 'Enter') {
+ performSearch();
+ }
+ });
+
+ // Clear search
+ clearBtn.addEventListener('click', function() {
+ searchInput.value = '';
+ resultsDiv.innerHTML = '';
+ clearBtn.style.display = 'none';
+ removeAllHighlights();
+ collapseAllTabs();
+ });
+ }
+
+ function performSearch() {
+ const searchInput = document.getElementById('clusterSearchInput');
+ const clearBtn = document.getElementById('clusterClearBtn');
+ const resultsDiv = document.getElementById('clusterSearchResults');
+ const query = searchInput.value.trim();
+
+ if (!query || query.length < 2) {
+ resultsDiv.innerHTML = 'Please enter at least 2 characters to search.
';
+ return;
+ }
+
+ // Remove previous highlights
+ removeAllHighlights();
+
+ // Search through all tab content
+ const results = searchAllTabs(query);
+
+ // Display results
+ displayResults(results, query);
+
+ // Show clear button
+ clearBtn.style.display = 'inline-block';
+ }
+
+ function searchAllTabs(query) {
+ const results = [];
+ const queryLower = query.toLowerCase();
+
+ // Find all cluster sections
+ const clusterSections = document.querySelectorAll('section[id^="cluster"]');
+
+ clusterSections.forEach(function(section) {
+ const clusterTitle = section.querySelector('h3, h2, .home-section-title');
+ const clusterName = clusterTitle ? clusterTitle.textContent.trim() : 'Unknown Cluster';
+
+ // Find all tab panes in this cluster
+ const tabPanes = section.querySelectorAll('.tab-pane');
+
+ tabPanes.forEach(function(tabPane) {
+ const tabId = tabPane.id;
+ const content = tabPane.textContent || tabPane.innerText;
+ const contentLower = content.toLowerCase();
+
+ // Check if query is in content
+ if (contentLower.includes(queryLower)) {
+ // Count occurrences
+ const matches = countMatches(contentLower, queryLower);
+
+ // Get tab label
+ const tabLink = section.querySelector(`a[href="#${tabId}"]`);
+ const tabLabel = tabLink ? tabLink.textContent.trim() : tabId;
+
+ // Get a snippet of context
+ const snippet = getContextSnippet(content, query);
+
+ results.push({
+ cluster: clusterName,
+ tab: tabLabel,
+ tabId: tabId,
+ matches: matches,
+ snippet: snippet,
+ section: section,
+ tabPane: tabPane,
+ tabLink: tabLink
+ });
+ }
+ });
+ });
+
+ return results;
+ }
+
+ function countMatches(text, query) {
+ const regex = new RegExp(query, 'gi');
+ const matches = text.match(regex);
+ return matches ? matches.length : 0;
+ }
+
+ function getContextSnippet(text, query) {
+ const queryLower = query.toLowerCase();
+ const textLower = text.toLowerCase();
+ const index = textLower.indexOf(queryLower);
+
+ if (index === -1) return '';
+
+ const start = Math.max(0, index - 50);
+ const end = Math.min(text.length, index + query.length + 100);
+ let snippet = text.substring(start, end);
+
+ if (start > 0) snippet = '...' + snippet;
+ if (end < text.length) snippet = snippet + '...';
+
+ // Highlight the query in snippet
+ const regex = new RegExp(`(${escapeRegExp(query)})`, 'gi');
+ snippet = snippet.replace(regex, '$1');
+
+ return snippet;
+ }
+
+ function escapeRegExp(string) {
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ }
+
+ function displayResults(results, query) {
+ const resultsDiv = document.getElementById('clusterSearchResults');
+
+ if (results.length === 0) {
+ resultsDiv.innerHTML = `No results found for "${escapeHtml(query)}".
`;
+ return;
+ }
+
+ let html = `Found ${results.length} tab(s) containing "${escapeHtml(query)}" (${results.reduce((sum, r) => sum + r.matches, 0)} total matches). Click on a result to view it:
`;
+ html += '';
+ resultsDiv.innerHTML = html;
+
+ // Add click handlers to results
+ const resultLinks = resultsDiv.querySelectorAll('.cluster-search-result');
+ resultLinks.forEach(function(link) {
+ link.addEventListener('click', function(e) {
+ e.preventDefault();
+ const tabId = this.getAttribute('data-tab-id');
+ const result = results.find(r => r.tabId === tabId);
+ if (result) {
+ activateTab(result);
+ highlightMatches(result.tabPane, query);
+ // Scroll to the section
+ result.section.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+ });
+ });
+
+ // Auto-expand first result
+ if (results.length > 0) {
+ activateTab(results[0]);
+ highlightMatches(results[0].tabPane, query);
+ }
+ }
+
+ function escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ function activateTab(result) {
+ // Collapse all tabs first
+ collapseAllTabs();
+
+ // Activate the target tab
+ if (result.tabLink) {
+ result.tabLink.click();
+ }
+ }
+
+ function collapseAllTabs() {
+ document.querySelectorAll('.tab-pane.show.active').forEach(function(pane) {
+ pane.classList.remove('show', 'active');
+ });
+ document.querySelectorAll('.nav-link.active').forEach(function(link) {
+ link.classList.remove('active');
+ });
+ }
+
+ function highlightMatches(tabPane, query) {
+ if (!tabPane) return;
+
+ // Remove existing highlights first
+ removeHighlightsInElement(tabPane);
+
+ // Use mark.js-like approach to highlight matches
+ const regex = new RegExp(`(${escapeRegExp(query)})`, 'gi');
+
+ // Get all text nodes
+ const walker = document.createTreeWalker(
+ tabPane,
+ NodeFilter.SHOW_TEXT,
+ null,
+ false
+ );
+
+ const textNodes = [];
+ let node;
+ while (node = walker.nextNode()) {
+ // Skip if parent is already a mark
+ if (node.parentElement.tagName !== 'MARK') {
+ textNodes.push(node);
+ }
+ }
+
+ textNodes.forEach(function(textNode) {
+ const text = textNode.textContent;
+ if (regex.test(text)) {
+ const span = document.createElement('span');
+ span.innerHTML = text.replace(regex, '$1');
+ textNode.parentNode.replaceChild(span, textNode);
+ }
+ });
+ }
+
+ function removeAllHighlights() {
+ document.querySelectorAll('.cluster-highlight').forEach(function(mark) {
+ const parent = mark.parentNode;
+ parent.replaceChild(document.createTextNode(mark.textContent), mark);
+ parent.normalize();
+ });
+
+ // Also remove wrapper spans
+ document.querySelectorAll('.tab-pane span').forEach(function(span) {
+ if (span.childNodes.length === 1 && span.childNodes[0].nodeType === Node.TEXT_NODE) {
+ const text = span.textContent;
+ if (span.parentNode && !span.classList.length) {
+ span.parentNode.replaceChild(document.createTextNode(text), span);
+ }
+ }
+ });
+ }
+
+ function removeHighlightsInElement(element) {
+ element.querySelectorAll('.cluster-highlight').forEach(function(mark) {
+ const parent = mark.parentNode;
+ parent.replaceChild(document.createTextNode(mark.textContent), mark);
+ parent.normalize();
+ });
+ }
+
+ // Add CSS for highlighting
+ const style = document.createElement('style');
+ style.textContent = `
+ .cluster-highlight {
+ background-color: #ffeb3b;
+ padding: 2px 0;
+ font-weight: bold;
+ }
+
+ .cluster-search-container {
+ position: sticky;
+ top: 70px;
+ background: white;
+ z-index: 100;
+ padding: 20px 0 10px 0;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+ }
+
+ .cluster-search-result:hover {
+ cursor: pointer;
+ }
+
+ .cluster-search-result mark {
+ background-color: #ffeb3b;
+ padding: 1px 2px;
+ }
+ `;
+ document.head.appendChild(style);
+
+})();
From 5abda06455983784fdb03693dfb7883ecdafccd8 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 4 Feb 2026 16:44:52 +0000
Subject: [PATCH 3/3] Fix code review issues: improve regex handling and DOM
manipulation
Co-authored-by: LukasWallrich <60155545+LukasWallrich@users.noreply.github.com>
---
static/js/clusters-search.js | 25 ++++++++++---------------
1 file changed, 10 insertions(+), 15 deletions(-)
diff --git a/static/js/clusters-search.js b/static/js/clusters-search.js
index 205109829c..291be7ab28 100644
--- a/static/js/clusters-search.js
+++ b/static/js/clusters-search.js
@@ -284,7 +284,7 @@
const textNodes = [];
let node;
- while (node = walker.nextNode()) {
+ while ((node = walker.nextNode())) {
// Skip if parent is already a mark
if (node.parentElement.tagName !== 'MARK') {
textNodes.push(node);
@@ -293,10 +293,15 @@
textNodes.forEach(function(textNode) {
const text = textNode.textContent;
- if (regex.test(text)) {
- const span = document.createElement('span');
- span.innerHTML = text.replace(regex, '$1');
- textNode.parentNode.replaceChild(span, textNode);
+ const testRegex = new RegExp(`(${escapeRegExp(query)})`, 'gi');
+ if (testRegex.test(text)) {
+ const fragment = document.createDocumentFragment();
+ const tempDiv = document.createElement('div');
+ tempDiv.innerHTML = text.replace(regex, '$1');
+ while (tempDiv.firstChild) {
+ fragment.appendChild(tempDiv.firstChild);
+ }
+ textNode.parentNode.replaceChild(fragment, textNode);
}
});
}
@@ -307,16 +312,6 @@
parent.replaceChild(document.createTextNode(mark.textContent), mark);
parent.normalize();
});
-
- // Also remove wrapper spans
- document.querySelectorAll('.tab-pane span').forEach(function(span) {
- if (span.childNodes.length === 1 && span.childNodes[0].nodeType === Node.TEXT_NODE) {
- const text = span.textContent;
- if (span.parentNode && !span.classList.length) {
- span.parentNode.replaceChild(document.createTextNode(text), span);
- }
- }
- });
}
function removeHighlightsInElement(element) {