Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ The click, the hook that fired the request, the payload with secrets already gon
- 🗺️ **API map.** One click condenses the session into a normalized endpoint contract: methods, statuses, query keys, example shapes.
- 🎚️ **Token budget control.** S/M/L detail levels, a live token counter, per-endpoint overrides.
- 🧹 **Curation.** API-only, same-domain, flow-only and 1-per-endpoint filters, search, multi-select, `Del` to drop rows.
- 🔎 **Find in the preview.** `Ctrl+F` searches the payload itself — keys, values, initiators — with match count, `Enter` / `Shift+Enter` to walk the hits.

## Quick start

Expand Down
24 changes: 24 additions & 0 deletions entrypoints/devtools-panel/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,30 @@
</button>
<button id="ov-clear" class="btn mini" title="Remove the override for the selected endpoint(s)">reset</button>
</div>
<div class="search-wrap find-wrap">
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.3-4.3" />
</svg>
<input
id="find"
type="search"
placeholder="Find in the preview: any key or value… (Ctrl+F)"
autocomplete="off"
aria-label="Find in the preview"
/>
<span id="find-count" class="find-count" aria-live="polite"></span>
<button id="find-prev" class="btn mini find-nav" title="Previous match (Shift+Enter)" aria-label="Previous match">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m6 15 6-6 6 6" />
</svg>
</button>
<button id="find-next" class="btn mini find-nav" title="Next match (Enter)" aria-label="Next match">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m6 9 6 6 6-6" />
</svg>
</button>
</div>
<pre id="preview">Reload the target page, then select a request to see its compact version.</pre>
</section>
</main>
Expand Down
137 changes: 137 additions & 0 deletions entrypoints/devtools-panel/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ function loadSettings() {
flowOnlyEl.checked = s.flowOnly ?? false;
uniqueEl.checked = s.unique ?? false;
searchEl.value = s.search ?? '';
findEl.value = s.find ?? '';
if (s.detail in DETAIL_LEVELS) detail = s.detail;
if (typeof s.leftWidth === 'number') setLeftWidth(s.leftWidth);
} catch {
Expand All @@ -118,6 +119,7 @@ function saveSettings() {
flowOnly: flowOnlyEl.checked,
unique: uniqueEl.checked,
search: searchEl.value,
find: findEl.value,
detail,
leftWidth: leftEl.style.width ? Math.round(leftEl.getBoundingClientRect().width) : null,
}),
Expand Down Expand Up @@ -492,6 +494,7 @@ function render() {
);

previewEl.classList.toggle('placeholder', !sel.length && !apiMapView && !bridge?.error);
previewSearchable = !!(sel.length || apiMapView);
if (sel.length) {
const shown = exportList(sel.slice(0, MAX_PREVIEW));
const header = sel.length > 1 ? `# ${sel.length} selected requests: Copy/Download exports them all\n` : '';
Expand Down Expand Up @@ -523,6 +526,8 @@ function render() {
'Ctrl+click: add/remove • Shift+click: range • Esc: clear • Del: remove rows.\n' +
'Copy/Download TOON exports the selection, or everything filtered when nothing is selected.';
}

paintFind(); // the preview was just rebuilt: repaint the matches, without scrolling
}

function el(tag: string, className: string, text: string): HTMLElement {
Expand Down Expand Up @@ -596,6 +601,131 @@ function highlightToon(text: string): DocumentFragment {
return frag;
}

// ---- Find in the preview ----
// The list filter only sees method/status/URL: this one searches the rendered
// payload itself (keys, values, markers, initiators). Marks are painted onto the
// highlighted DOM after each render, and a match may straddle several syntax
// spans ("IDSOCIETE: 46"), so one match becomes one <mark> per text node crossed.

const findEl = document.getElementById('find') as HTMLInputElement;
const findCountEl = document.getElementById('find-count')!;
const findPrevBtn = document.getElementById('find-prev') as HTMLButtonElement;
const findNextBtn = document.getElementById('find-next') as HTMLButtonElement;

const MAX_HITS = 2000; // guard against a one-character search on a huge preview

let findHits: HTMLElement[][] = []; // one row per match, its <mark> pieces in reading order
let findIndex = 0;
let previewSearchable = false; // false while the preview shows a placeholder or an error

function clearFindMarks() {
const marks = previewEl.querySelectorAll('mark.find-hit');
for (const m of marks) m.replaceWith(m.textContent ?? '');
if (marks.length) previewEl.normalize(); // stitch the split text nodes back together
findHits = [];
}

function paintFind(scroll = false) {
clearFindMarks();
const needle = findEl.value.toLowerCase();
if (needle && previewSearchable) {
// flatten the preview into one string, remembering where each text node sits in it
const walker = document.createTreeWalker(previewEl, NodeFilter.SHOW_TEXT);
const nodes: Text[] = [];
const starts: number[] = [];
const ends: number[] = [];
let flat = '';
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
const text = n as Text;
nodes.push(text);
starts.push(flat.length);
flat += text.data;
ends.push(flat.length);
}
const hay = flat.toLowerCase();
const ranges: [number, number][] = [];
for (
let i = hay.indexOf(needle);
i !== -1 && ranges.length < MAX_HITS;
i = hay.indexOf(needle, i + needle.length)
) {
ranges.push([i, i + needle.length]);
}
// right to left: wrapping splits text nodes, which only moves what follows
let last = nodes.length - 1;
for (let r = ranges.length - 1; r >= 0; r--) {
const [from, to] = ranges[r];
while (last > 0 && starts[last] >= to) last--;
const pieces: HTMLElement[] = [];
for (let i = last; i >= 0 && ends[i] > from; i--) {
const node = nodes[i];
const lo = Math.max(0, from - starts[i]);
const hi = Math.min(node.data.length, to - starts[i]);
if (hi <= lo) continue;
if (hi < node.data.length) node.splitText(hi);
const target = lo > 0 ? node.splitText(lo) : node;
const mark = document.createElement('mark');
mark.className = 'find-hit';
target.replaceWith(mark);
mark.append(target);
pieces.push(mark);
}
pieces.reverse();
findHits.push(pieces);
}
findHits.reverse();
}
if (findIndex >= findHits.length) findIndex = 0;
syncFindBar(scroll);
}

function syncFindBar(scroll: boolean) {
const total = findHits.length;
findHits.forEach((pieces, i) => {
for (const mark of pieces) mark.classList.toggle('current', i === findIndex);
});
findCountEl.textContent = !findEl.value
? ''
: total
? `${findIndex + 1}/${total}${total === MAX_HITS ? '+' : ''}`
: 'no match';
findCountEl.classList.toggle('none', !!findEl.value && !total);
findPrevBtn.disabled = !total;
findNextBtn.disabled = !total;
if (scroll && total) findHits[findIndex][0].scrollIntoView({ block: 'nearest' });
}

function stepFind(delta: number) {
if (!findHits.length) return;
findIndex = (findIndex + delta + findHits.length) % findHits.length;
syncFindBar(true);
}

findEl.addEventListener('input', () => {
findIndex = 0;
paintFind(true);
saveSettings();
});

findEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
stepFind(e.shiftKey ? -1 : 1);
return;
}
if (e.key === 'Escape' && findEl.value) {
// Esc empties the find first: clearing the selection would throw away what is being read
e.stopPropagation();
findEl.value = '';
findIndex = 0;
paintFind();
saveSettings();
}
});

findPrevBtn.addEventListener('click', () => stepFind(-1));
findNextBtn.addEventListener('click', () => stepFind(1));

// ---- Actions ----

async function copyText(text: string) {
Expand Down Expand Up @@ -664,6 +794,13 @@ document.getElementById('clear')!.addEventListener('click', () => {
});

window.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'f') {
// the browser find bar never reaches a DevTools panel: this one does
e.preventDefault();
findEl.focus();
findEl.select();
return;
}
if (e.key === 'Escape') {
selection.clear();
anchor = null;
Expand Down
68 changes: 64 additions & 4 deletions entrypoints/devtools-panel/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
--syn-num: #6d4ca8;
--syn-kw: #1d7a72;
--syn-fields: #7a4fa3;
--find-hit: rgba(224, 170, 40, 0.45);
--radius: 6px;
}

Expand Down Expand Up @@ -65,6 +66,7 @@
--syn-num: #ab96dd;
--syn-kw: #76b8b0;
--syn-fields: #b495d8;
--find-hit: rgba(212, 175, 110, 0.33);
}
}

Expand Down Expand Up @@ -98,6 +100,7 @@
--syn-num: #ab96dd;
--syn-kw: #76b8b0;
--syn-fields: #b495d8;
--find-hit: rgba(212, 175, 110, 0.33);
}

* {
Expand Down Expand Up @@ -230,6 +233,17 @@ header {
outline-offset: 1px;
}

.btn:disabled {
opacity: 0.4;
cursor: default;
box-shadow: none;
}

.btn:disabled:hover {
background: linear-gradient(180deg, color-mix(in srgb, var(--btn-bg) 90%, #fff), var(--btn-bg));
border-color: var(--border);
}

.btn.primary {
background: linear-gradient(180deg, var(--accent-hover), var(--accent));
border-color: color-mix(in srgb, #fff 16%, var(--accent));
Expand Down Expand Up @@ -431,7 +445,8 @@ body.resizing {
pointer-events: none;
}

#search {
#search,
#find {
width: 100%;
padding: 5px 8px 5px 27px;
background: var(--bg-input);
Expand All @@ -444,19 +459,64 @@ body.resizing {
transition: border-color 0.12s, box-shadow 0.12s;
}

#search:hover {
#search:hover,
#find:hover {
border-color: var(--border-strong);
}

#search:focus {
#search:focus,
#find:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}

#search::placeholder {
#search::placeholder,
#find::placeholder {
color: var(--faint);
}

/* ---- Recherche dans la prévisualisation ---- */

.find-wrap {
display: flex;
align-items: center;
gap: 5px;
}

.find-wrap #find {
flex: 1;
min-width: 0;
}

.find-count {
flex: none;
color: var(--muted);
font-size: 11px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}

.find-count.none {
color: var(--err);
}

.find-nav {
padding: 0 5px;
}

mark.find-hit {
background: var(--find-hit);
color: inherit;
border-radius: 2px;
}

mark.find-hit.current {
background: var(--accent);
color: #fff;
/* box-shadow plutôt que padding : la grille monospace ne doit pas bouger */
box-shadow: 0 0 0 1px var(--accent);
}

/* ---- Liste ---- */

#list {
Expand Down