diff --git a/README.md b/README.md index 4c8141f..ccb5dde 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/entrypoints/devtools-panel/index.html b/entrypoints/devtools-panel/index.html index 34a2960..48f2f50 100644 --- a/entrypoints/devtools-panel/index.html +++ b/entrypoints/devtools-panel/index.html @@ -113,6 +113,30 @@ +
+ + + + + +
Reload the target page, then select a request to see its compact version.
diff --git a/entrypoints/devtools-panel/main.ts b/entrypoints/devtools-panel/main.ts index 79e3c55..715e293 100644 --- a/entrypoints/devtools-panel/main.ts +++ b/entrypoints/devtools-panel/main.ts @@ -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 { @@ -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, }), @@ -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` : ''; @@ -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 { @@ -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 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 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) { @@ -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; diff --git a/entrypoints/devtools-panel/style.css b/entrypoints/devtools-panel/style.css index 1024dab..73f6065 100644 --- a/entrypoints/devtools-panel/style.css +++ b/entrypoints/devtools-panel/style.css @@ -31,6 +31,7 @@ --syn-num: #6d4ca8; --syn-kw: #1d7a72; --syn-fields: #7a4fa3; + --find-hit: rgba(224, 170, 40, 0.45); --radius: 6px; } @@ -65,6 +66,7 @@ --syn-num: #ab96dd; --syn-kw: #76b8b0; --syn-fields: #b495d8; + --find-hit: rgba(212, 175, 110, 0.33); } } @@ -98,6 +100,7 @@ --syn-num: #ab96dd; --syn-kw: #76b8b0; --syn-fields: #b495d8; + --find-hit: rgba(212, 175, 110, 0.33); } * { @@ -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)); @@ -431,7 +445,8 @@ body.resizing { pointer-events: none; } -#search { +#search, +#find { width: 100%; padding: 5px 8px 5px 27px; background: var(--bg-input); @@ -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 {