Skip to content

Commit 7627e60

Browse files
simonwclaude
andcommitted
Add search feature to index.html pages
- Add search box to the right of "Claude Code transcript" header - Search box is hidden by default, shown only when JavaScript is available - Clicking search button or pressing Enter opens a modal dialog - Modal fetches all page-X.html files (3 at a time in parallel) - Uses DOMParser to parse HTML and search through visible text - Results are displayed immediately as each fetch completes - Entire message blocks are shown with matching text highlighted - Clicking a result links to that fragment on that page - Search term is persisted in URL as #search=XXX - Visiting index.html with #search=XXX opens modal and runs search 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 84de69d commit 7627e60

File tree

8 files changed

+875
-8
lines changed

8 files changed

+875
-8
lines changed

src/claude_code_transcripts/__init__.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -993,7 +993,27 @@ def render_message(log_type, message_json, timestamp):
993993
.index-item-long-text { margin-top: 8px; padding: 12px; background: var(--card-bg); border-radius: 8px; border-left: 3px solid var(--assistant-border); }
994994
.index-item-long-text .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, var(--card-bg)); }
995995
.index-item-long-text-content { color: var(--text-color); }
996-
@media (max-width: 600px) { body { padding: 8px; } .message, .index-item { border-radius: 8px; } .message-content, .index-item-content { padding: 12px; } pre { font-size: 0.8rem; padding: 8px; } }
996+
.header-row { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; margin-bottom: 0; }
997+
.header-row h1 { margin-bottom: 0; flex: 1; min-width: 200px; }
998+
#search-box { display: none; align-items: center; gap: 8px; }
999+
#search-box input { padding: 6px 12px; border: 1px solid var(--assistant-border); border-radius: 6px; font-size: 0.9rem; width: 180px; }
1000+
#search-box button, #modal-search-btn, #modal-close-btn { background: var(--user-border); color: white; border: none; border-radius: 6px; padding: 6px 10px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
1001+
#search-box button:hover, #modal-search-btn:hover { background: #1565c0; }
1002+
#modal-close-btn { background: var(--text-muted); margin-left: 8px; }
1003+
#modal-close-btn:hover { background: #616161; }
1004+
#search-modal { border: none; border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,0.2); padding: 0; width: 90vw; max-width: 900px; height: 80vh; max-height: 80vh; display: flex; flex-direction: column; }
1005+
#search-modal::backdrop { background: rgba(0,0,0,0.5); }
1006+
.search-modal-header { display: flex; align-items: center; gap: 8px; padding: 16px; border-bottom: 1px solid var(--assistant-border); background: var(--bg-color); border-radius: 12px 12px 0 0; }
1007+
.search-modal-header input { flex: 1; padding: 8px 12px; border: 1px solid var(--assistant-border); border-radius: 6px; font-size: 1rem; }
1008+
#search-status { padding: 8px 16px; font-size: 0.85rem; color: var(--text-muted); border-bottom: 1px solid rgba(0,0,0,0.06); }
1009+
#search-results { flex: 1; overflow-y: auto; padding: 16px; }
1010+
.search-result { margin-bottom: 16px; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
1011+
.search-result a { display: block; text-decoration: none; color: inherit; }
1012+
.search-result a:hover { background: rgba(25, 118, 210, 0.05); }
1013+
.search-result-page { padding: 6px 12px; background: rgba(0,0,0,0.03); font-size: 0.8rem; color: var(--text-muted); border-bottom: 1px solid rgba(0,0,0,0.06); }
1014+
.search-result-content { padding: 12px; }
1015+
.search-result mark { background: #fff59d; padding: 1px 2px; border-radius: 2px; }
1016+
@media (max-width: 600px) { body { padding: 8px; } .message, .index-item { border-radius: 8px; } .message-content, .index-item-content { padding: 12px; } pre { font-size: 0.8rem; padding: 8px; } #search-box input { width: 120px; } #search-modal { width: 95vw; height: 90vh; } }
9971017
"""
9981018

9991019
JS = """

src/claude_code_transcripts/templates/index.html

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,34 @@
33
{% block title %}Claude Code transcript - Index{% endblock %}
44

55
{% block content %}
6-
<h1>Claude Code transcript</h1>
6+
<div class="header-row">
7+
<h1>Claude Code transcript</h1>
8+
<div id="search-box">
9+
<input type="text" id="search-input" placeholder="Search..." aria-label="Search transcripts">
10+
<button id="search-btn" type="button" aria-label="Search">
11+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.35-4.35"></path></svg>
12+
</button>
13+
</div>
14+
</div>
715
{{ pagination_html|safe }}
816
<p style="color: var(--text-muted); margin-bottom: 24px;">{{ prompt_num }} prompts · {{ total_messages }} messages · {{ total_tool_calls }} tool calls · {{ total_commits }} commits · {{ total_pages }} pages</p>
917
{{ index_items_html|safe }}
1018
{{ pagination_html|safe }}
19+
20+
<dialog id="search-modal">
21+
<div class="search-modal-header">
22+
<input type="text" id="modal-search-input" placeholder="Search..." aria-label="Search transcripts">
23+
<button id="modal-search-btn" type="button" aria-label="Search">
24+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.35-4.35"></path></svg>
25+
</button>
26+
<button id="modal-close-btn" type="button" aria-label="Close">
27+
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg>
28+
</button>
29+
</div>
30+
<div id="search-status"></div>
31+
<div id="search-results"></div>
32+
</dialog>
33+
<script>
34+
{% include "search.js" %}
35+
</script>
1136
{%- endblock %}
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
(function() {
2+
var totalPages = {{ total_pages }};
3+
var searchBox = document.getElementById('search-box');
4+
var searchInput = document.getElementById('search-input');
5+
var searchBtn = document.getElementById('search-btn');
6+
var modal = document.getElementById('search-modal');
7+
var modalInput = document.getElementById('modal-search-input');
8+
var modalSearchBtn = document.getElementById('modal-search-btn');
9+
var modalCloseBtn = document.getElementById('modal-close-btn');
10+
var searchStatus = document.getElementById('search-status');
11+
var searchResults = document.getElementById('search-results');
12+
13+
if (!searchBox || !modal) return;
14+
15+
// Show search box (progressive enhancement)
16+
searchBox.style.display = 'flex';
17+
18+
function escapeHtml(text) {
19+
var div = document.createElement('div');
20+
div.textContent = text;
21+
return div.innerHTML;
22+
}
23+
24+
function escapeRegex(string) {
25+
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
26+
}
27+
28+
function openModal(query) {
29+
modalInput.value = query || '';
30+
searchResults.innerHTML = '';
31+
searchStatus.textContent = '';
32+
modal.showModal();
33+
modalInput.focus();
34+
if (query) {
35+
performSearch(query);
36+
}
37+
}
38+
39+
function closeModal() {
40+
modal.close();
41+
// Update URL to remove search fragment
42+
if (window.location.hash.startsWith('#search=')) {
43+
history.replaceState(null, '', window.location.pathname);
44+
}
45+
}
46+
47+
function updateUrlHash(query) {
48+
if (query) {
49+
history.replaceState(null, '', '#search=' + encodeURIComponent(query));
50+
}
51+
}
52+
53+
function highlightTextNodes(element, searchTerm) {
54+
var walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false);
55+
var nodesToReplace = [];
56+
57+
while (walker.nextNode()) {
58+
var node = walker.currentNode;
59+
if (node.nodeValue.toLowerCase().indexOf(searchTerm.toLowerCase()) !== -1) {
60+
nodesToReplace.push(node);
61+
}
62+
}
63+
64+
nodesToReplace.forEach(function(node) {
65+
var text = node.nodeValue;
66+
var regex = new RegExp('(' + escapeRegex(searchTerm) + ')', 'gi');
67+
var parts = text.split(regex);
68+
if (parts.length > 1) {
69+
var span = document.createElement('span');
70+
parts.forEach(function(part) {
71+
if (part.toLowerCase() === searchTerm.toLowerCase()) {
72+
var mark = document.createElement('mark');
73+
mark.textContent = part;
74+
span.appendChild(mark);
75+
} else {
76+
span.appendChild(document.createTextNode(part));
77+
}
78+
});
79+
node.parentNode.replaceChild(span, node);
80+
}
81+
});
82+
}
83+
84+
function processPage(pageFile, html, query) {
85+
var parser = new DOMParser();
86+
var doc = parser.parseFromString(html, 'text/html');
87+
var resultsFromPage = 0;
88+
89+
// Find all message blocks
90+
var messages = doc.querySelectorAll('.message');
91+
messages.forEach(function(msg) {
92+
var text = msg.textContent || '';
93+
if (text.toLowerCase().indexOf(query.toLowerCase()) !== -1) {
94+
resultsFromPage++;
95+
96+
// Get the message ID for linking
97+
var msgId = msg.id || '';
98+
var link = pageFile + (msgId ? '#' + msgId : '');
99+
100+
// Clone the message HTML and highlight matches
101+
var clone = msg.cloneNode(true);
102+
highlightTextNodes(clone, query);
103+
104+
var resultDiv = document.createElement('div');
105+
resultDiv.className = 'search-result';
106+
resultDiv.innerHTML = '<a href="' + link + '">' +
107+
'<div class="search-result-page">' + escapeHtml(pageFile) + '</div>' +
108+
'<div class="search-result-content">' + clone.innerHTML + '</div>' +
109+
'</a>';
110+
searchResults.appendChild(resultDiv);
111+
}
112+
});
113+
114+
return resultsFromPage;
115+
}
116+
117+
async function performSearch(query) {
118+
if (!query.trim()) {
119+
searchStatus.textContent = 'Enter a search term';
120+
return;
121+
}
122+
123+
updateUrlHash(query);
124+
searchResults.innerHTML = '';
125+
searchStatus.textContent = 'Searching...';
126+
127+
var resultsFound = 0;
128+
var pagesSearched = 0;
129+
130+
// Build list of pages to fetch
131+
var pagesToFetch = [];
132+
for (var i = 1; i <= totalPages; i++) {
133+
pagesToFetch.push('page-' + String(i).padStart(3, '0') + '.html');
134+
}
135+
136+
// Process pages in batches of 3, but show results immediately as each completes
137+
var batchSize = 3;
138+
for (var i = 0; i < pagesToFetch.length; i += batchSize) {
139+
var batch = pagesToFetch.slice(i, i + batchSize);
140+
141+
// Create promises that process results immediately when each fetch completes
142+
var promises = batch.map(function(pageFile) {
143+
return fetch(pageFile)
144+
.then(function(response) {
145+
if (!response.ok) throw new Error('Failed to fetch');
146+
return response.text();
147+
})
148+
.then(function(html) {
149+
// Process and display results immediately
150+
var count = processPage(pageFile, html, query);
151+
resultsFound += count;
152+
pagesSearched++;
153+
searchStatus.textContent = 'Found ' + resultsFound + ' result(s) in ' + pagesSearched + '/' + totalPages + ' pages...';
154+
})
155+
.catch(function() {
156+
pagesSearched++;
157+
searchStatus.textContent = 'Found ' + resultsFound + ' result(s) in ' + pagesSearched + '/' + totalPages + ' pages...';
158+
});
159+
});
160+
161+
// Wait for this batch to complete before starting the next
162+
await Promise.all(promises);
163+
}
164+
165+
searchStatus.textContent = 'Found ' + resultsFound + ' result(s) in ' + totalPages + ' pages';
166+
}
167+
168+
// Event listeners
169+
searchBtn.addEventListener('click', function() {
170+
openModal(searchInput.value);
171+
});
172+
173+
searchInput.addEventListener('keydown', function(e) {
174+
if (e.key === 'Enter') {
175+
openModal(searchInput.value);
176+
}
177+
});
178+
179+
modalSearchBtn.addEventListener('click', function() {
180+
performSearch(modalInput.value);
181+
});
182+
183+
modalInput.addEventListener('keydown', function(e) {
184+
if (e.key === 'Enter') {
185+
performSearch(modalInput.value);
186+
}
187+
});
188+
189+
modalCloseBtn.addEventListener('click', closeModal);
190+
191+
modal.addEventListener('click', function(e) {
192+
if (e.target === modal) {
193+
closeModal();
194+
}
195+
});
196+
197+
// Check for #search= in URL on page load
198+
if (window.location.hash.startsWith('#search=')) {
199+
var query = decodeURIComponent(window.location.hash.substring(8));
200+
if (query) {
201+
searchInput.value = query;
202+
openModal(query);
203+
}
204+
}
205+
})();

0 commit comments

Comments
 (0)