diff --git a/static/js/recherche.js b/static/js/recherche.js
index 3f396bd..6a6f442 100644
--- a/static/js/recherche.js
+++ b/static/js/recherche.js
@@ -1,11 +1,39 @@
-/* Recherche partout — page de résultats unifiée (fusion-trois-apps #68).
+/* Recherche partout — page de résultats unifiée (fusion-trois-apps #68,
+ * chantier 2+3 : onglets, « Tout voir », focus).
*
* Vanilla, sans dépendance. Un seul fichier vendangé tel quel par chaque
* hôte (ObsiGate, Flowdeck, TikTak, Kanbante) : la configuration vient
* des attributs data-* de #recherche, rendus côté serveur par l'hôte.
*
+ * Présentation en onglets : Tout, ObsiGate, Flowdeck, TikTak, Kanbante,
+ * Console Spark. « Tout » = les 4 sections groupées, chaque liste
+ * tronquée à PER_APP_LIMIT ; un onglet application = tous les résultats
+ * de cette application (aucune troncature côté client — la requête
+ * ObsiGate part avec limit=FULL_LIMIT, les autres API renvoient leur
+ * jeu complet). « Tout voir » d'une section ne quitte plus la page :
+ * il bascule vers l'onglet de l'application (son href reste une URL
+ * partageable ?q=&tab= au cas où le JS ne tournerait pas).
+ * L'onglet actif vit dans l'URL (?tab=, absent = Tout) : partageable,
+ * et le retour arrière navigateur le restaure (popstate).
+ *
+ * Compteurs : chaque onglet affiche le nombre TOTAL de résultats de
+ * son application (pas le nombre tronqué affiché dans « Tout »),
+ * « … » pendant le chargement, « 0 » pour un vide honnête, rien pour
+ * une section en erreur/indisponible (pas de faux 0). L'onglet Tout
+ * affiche « … » tant qu'une section charge, sinon la somme des totaux
+ * connus (une section en erreur compte pour 0) ; il est vide si les
+ * 4 sections sont en erreur ou si aucune recherche n'a encore eu lieu.
+ * Une section injoignable ne casse jamais les autres.
+ *
+ * Focus : l'attribut autofocus seul ne survit pas au chargement des
+ * surcouches (barre, palette, app) — la page appelle donc focus()
+ * explicitement en fin d'init (plus un rappel rAF/timeout et un
+ * rappel pageshow). Cliquer la barre « Rechercher partout » (bouton
+ * data-act="palette") sur cette même page replace aussi le curseur
+ * dans la boîte ; « / » et Ctrl+K / ⌘K font de même, comme avant.
+ *
* Contrat d'API (audité, rien d'inventé) :
- * - ObsiGate : GET {base}/api/search?q=&vault=all&limit=8
+ * - ObsiGate : GET {base}/api/search?q=&vault=all&limit=
* -> { results: [{ vault, path, title, snippet }] }
* Lien profond vérifié : {base}#file={vault}:{path} (Viewer.openFile).
* - Flowdeck : GET {base}/api/search?q=
@@ -14,10 +42,27 @@
* - TikTak : GET {base}/api/search?q=
* -> { tasks: [{ id, title, description, status, due_at }], ... }
* Lien profond vérifié : {base}search?q= Recherche…&task=
Console Spark : recherche indisponible pour l\'instant' + + (state.message ? " (" + esc(state.message) + ")" : "") + + ".
"; + return; + } + if (state.status === "idle") { + count.textContent = ""; + body.innerHTML = + 'Tapez une recherche ci-dessus.
'; + return; + } + if (state.status === "empty") { + count.textContent = "0"; body.innerHTML = 'Aucun résultat pour « ' + esc(state.q) + " ».
"; return; } + var shown = pageItems(state.items, full); count.textContent = String(state.items.length); - body.innerHTML = state.items + body.innerHTML = shown .map(function (item) { - return ( - '' + + var inner = '' + esc(item.title) + "" + @@ -314,9 +515,13 @@ : "") + (item.snippet ? '' + esc(item.snippet) + "" - : "") + - "" - ); + : ""); + if (item.url) { + return ( + '' + inner + "" + ); + } + return '' + inner + ""; }) .join(""); } @@ -349,40 +554,205 @@ barUrls: barUrls, tabHrefs: tabHrefs, }); + var consoleBase = resolveConsoleBase({ + host: global.location.hostname, + explicit: root.getAttribute("data-base-console"), + agentUrl: + (global.DRACO_URLS && global.DRACO_URLS.agent) || global.DRACO_AGENT_URL, + tabHrefs: tabHrefs, + }); + var allKeys = APPS.map(function (a) { return a.key; }).concat(["console"]); var sections = {}; - APPS.forEach(function (app) { - var el = document.querySelector('[data-rp-section="' + app.key + '"]'); - if (el) sections[app.key] = el; + allKeys.forEach(function (key) { + var el = document.querySelector('[data-rp-section="' + key + '"]'); + if (el) sections[key] = el; + }); + var tabButtons = {}; + document.querySelectorAll("[data-rp-tab]").forEach(function (btn) { + tabButtons[btn.getAttribute("data-rp-tab")] = btn; + }); + var tablist = document.querySelector(".rp-tabs"); + + /* États par application (+ console). total = items.length. */ + var states = {}; + allKeys.forEach(function (key) { + states[key] = { + status: "idle", + items: [], + message: "", + q: "", + base: key === "console" ? consoleBase : bases[key], + app: key, + }; }); + var activeTab = "all"; var currentQ = ""; var runId = 0; + var consoleFetchedQ = null; var debounceTimer = null; + function statesForCounts() { + var out = {}; + allKeys.forEach(function (key) { + out[key] = { status: states[key].status, total: states[key].items.length }; + }); + return out; + } + + function renderTabs() { + var counts = tabCounts(statesForCounts()); + Object.keys(tabButtons).forEach(function (key) { + var btn = tabButtons[key]; + var selected = key === activeTab; + btn.classList.toggle("active", selected); + btn.setAttribute("aria-selected", selected ? "true" : "false"); + btn.tabIndex = selected ? 0 : -1; + var span = btn.querySelector(".rp-tab-count"); + if (span) span.textContent = counts[key] || ""; + }); + } + + /* Bascule d'onglet : visibilité des sections, re-rendu (plein dans + * l'onglet propre à l'app, tronqué dans « Tout »), compteurs. + * opts : { push (historique), focusTab }. */ + function applyTab(tab, opts) { + opts = opts || {}; + activeTab = parseTab(tab); + try { + root.setAttribute("data-rp-tab", activeTab); + } catch (e) {} + var visible = visibleKeys(activeTab); + allKeys.forEach(function (key) { + var sec = sections[key]; + if (!sec) return; + var show = visible.indexOf(key) !== -1; + sec.hidden = !show; + if (show) renderSection(sec, states[key], activeTab === key); + var tv = sec.querySelector(".rp-all"); + if (tv) tv.hidden = activeTab === key; + }); + renderTabs(); + if (opts.push) { + try { + global.history.pushState( + null, + "", + global.location.pathname + buildSearchUrl(currentQ, activeTab) + ); + } catch (e) {} + } + if (opts.focusTab && tabButtons[activeTab]) { + try { + tabButtons[activeTab].focus(); + } catch (e) {} + } + } + + function setTab(tab, opts) { + applyTab(tab, opts); + /* La console n'est interrogée qu'une fois affichée. */ + if (parseTab(tab) === "console" && currentQ && consoleFetchedQ !== currentQ) { + fetchConsole(currentQ, runId); + } + } + + function fetchConsole(q, myRun) { + var sec = sections.console; + if (!sec) return; + consoleFetchedQ = q; + states.console = { + status: "loading", + items: [], + message: "", + q: q, + base: consoleBase, + app: "console", + }; + renderSection(sec, states.console, true); + renderTabs(); + var url = consoleBase + CONSOLE_SEARCH_PATH + "?q=" + encodeURIComponent(q); + fetchSection("console", url, function (err, data) { + if (myRun !== runId) return; + if (err) { + var msg; + if (err.name === "AbortError") msg = "délai dépassé (8 s)"; + else if (err.status) msg = "HTTP " + err.status; + else msg = "injoignable (CORS ou réseau)"; + states.console = { + status: "unavailable", + items: [], + message: msg, + q: q, + base: consoleBase, + app: "console", + }; + } else { + var norm = normalizeConsole(data, consoleBase); + if (!norm.recognized) { + states.console = { + status: "unavailable", + items: [], + message: "format inattendu", + q: q, + base: consoleBase, + app: "console", + }; + } else { + states.console = { + status: norm.items.length ? "ok" : "empty", + items: norm.items, + message: "", + q: q, + base: consoleBase, + app: "console", + }; + } + } + renderSection(sec, states.console, true); + renderTabs(); + }); + } + function runSearch(q) { currentQ = q; var myRun = ++runId; if (!q) { - APPS.forEach(function (app) { - var sec = sections[app.key]; + allKeys.forEach(function (key) { + var sec = sections[key]; if (!sec) return; - sec.querySelector(".rp-body").innerHTML = - 'Tapez une recherche ci-dessus.
'; - sec.querySelector(".rp-count").textContent = ""; - sec.querySelector(".rp-all").href = nativeSearchUrl(app.key, bases[app.key], ""); + states[key] = { + status: "idle", + items: [], + message: "", + q: "", + base: key === "console" ? consoleBase : bases[key], + app: key, + }; + renderSection(sec, states[key], activeTab === key); }); + consoleFetchedQ = null; + renderTabs(); return; } APPS.forEach(function (app) { var sec = sections[app.key]; if (!sec) return; - renderSection(sec, { status: "loading", app: app.key, base: bases[app.key], q: q }); + states[app.key] = { + status: "loading", + items: [], + message: "", + q: q, + base: bases[app.key], + app: app.key, + }; + renderSection(sec, states[app.key], activeTab === app.key); var url; if (app.key === hostApp) { /* Même origine : pas de CORS, pas de CSP cross-origin. */ - url = "/" + searchPath(app.key, q); + url = "/" + searchPath(app.key, q, true); } else { - url = bases[app.key] + searchPath(app.key, q); + url = bases[app.key] + searchPath(app.key, q, true); } fetchSection(app.key, url, function (err, data) { if (myRun !== runId) return; @@ -393,25 +763,45 @@ msg = "Connexion requise sur " + app.label; else if (err.status) msg = "Indisponible (HTTP " + err.status + ")"; else msg = "Injoignable depuis cet hôte (CORS ou réseau)"; - renderSection(sec, { + states[app.key] = { status: "error", + items: [], message: msg, - app: app.key, - base: bases[app.key], q: q, - }); - return; + base: bases[app.key], + app: app.key, + }; + } else { + var items = normalize(app.key, data, bases[app.key], q, true); + states[app.key] = { + status: items.length ? "ok" : "empty", + items: items, + message: "", + q: q, + base: bases[app.key], + app: app.key, + }; } - var items = normalize(app.key, data, bases[app.key], q); - renderSection(sec, { - status: items.length ? "ok" : "empty", - items: items, - app: app.key, - base: bases[app.key], - q: q, - }); + renderSection(sec, states[app.key], activeTab === app.key); + renderTabs(); }); }); + /* Console : seulement si son onglet est affiché ; sinon son + * compteur reste vide (pas de faux 0) jusqu'à son activation. */ + if (activeTab === "console") { + fetchConsole(q, myRun); + } else { + states.console = { + status: "idle", + items: [], + message: "", + q: q, + base: consoleBase, + app: "console", + }; + consoleFetchedQ = null; + renderTabs(); + } } function scheduleSearch(q, immediate) { @@ -427,13 +817,33 @@ function syncUrl(q) { try { - var url = new URL(global.location.href); - if (q) url.searchParams.set("q", q); - else url.searchParams.delete("q"); - global.history.replaceState(null, "", url.toString()); + global.history.replaceState( + null, + "", + global.location.pathname + buildSearchUrl(q, activeTab) + ); } catch (e) {} } + function focusSearch() { + try { + input.focus({ preventScroll: true }); + } catch (e) { + try { + input.focus(); + } catch (e2) {} + } + } + + function visibleLinks() { + return Array.prototype.slice + .call(document.querySelectorAll("[data-rp-link]")) + .filter(function (a) { + var s = a.closest ? a.closest("[data-rp-section]") : null; + return !s || !s.hidden; + }); + } + input.addEventListener("input", function () { var q = input.value.trim(); syncUrl(q); @@ -441,7 +851,7 @@ }); input.addEventListener("keydown", function (e) { if (e.key === "Enter") { - var first = document.querySelector("[data-rp-link]"); + var first = visibleLinks()[0]; if (first) { e.preventDefault(); first.click(); @@ -471,6 +881,9 @@ input.select(); return; } + /* Sur un onglet, ↑/↓ ne déplacent pas les résultats : les + * flèches ←/→ du tablist s'en chargent (voir ci-dessous). */ + if (target && target.closest && target.closest(".rp-tab")) return; if (!typing && (e.key === "ArrowDown" || e.key === "ArrowUp")) { moveFocus(e.key === "ArrowDown" ? 1 : -1, e); } @@ -478,8 +891,62 @@ true ); + /* « Tout voir » reste sur la page : bascule vers l'onglet. */ + root.addEventListener("click", function (e) { + var all = e.target && e.target.closest ? e.target.closest(".rp-all") : null; + if (!all) return; + var sec = all.closest ? all.closest("[data-rp-section]") : null; + if (!sec) return; + e.preventDefault(); + setTab(sec.getAttribute("data-rp-section"), { push: true, focusTab: true }); + }); + + /* Clic sur la barre « Rechercher partout » (bouton + * data-act="palette") pendant qu'on est déjà sur la page : + * replacer le curseur dans la boîte au lieu de recharger. */ + document.addEventListener("click", function (e) { + var p = e.target && e.target.closest ? e.target.closest('[data-act="palette"]') : null; + if (!p) return; + e.preventDefault(); + focusSearch(); + try { + input.select(); + } catch (err) {} + }); + + /* Onglets : clic + clavier (←/→, Début/Fin). */ + Object.keys(tabButtons).forEach(function (key) { + tabButtons[key].addEventListener("click", function () { + setTab(key, { push: true }); + }); + }); + if (tablist) { + tablist.addEventListener("keydown", function (e) { + var order = TABS; + var idx = order.indexOf(activeTab); + var next = null; + if (e.key === "ArrowRight") next = order[(idx + 1) % order.length]; + else if (e.key === "ArrowLeft") + next = order[(idx - 1 + order.length) % order.length]; + else if (e.key === "Home") next = order[0]; + else if (e.key === "End") next = order[order.length - 1]; + if (!next) return; + e.preventDefault(); + setTab(next, { push: true, focusTab: true }); + }); + } + + /* Retour arrière navigateur : restaurer q + onglet. */ + global.addEventListener("popstate", function () { + var params = new URLSearchParams(global.location.search); + var q = (params.get("q") || "").trim(); + input.value = q; + applyTab(params.get("tab")); + runSearch(q); + }); + function moveFocus(delta, e) { - var links = Array.prototype.slice.call(document.querySelectorAll("[data-rp-link]")); + var links = visibleLinks(); if (!links.length) return; var active = document.activeElement; var idx = links.indexOf(active); @@ -492,12 +959,23 @@ var params = new URLSearchParams(global.location.search); var initialQ = (params.get("q") || "").trim(); - if (initialQ) { - input.value = initialQ; - runSearch(initialQ); - } else { - runSearch(""); - } + var initialTab = parseTab(params.get("tab")); + if (initialQ) input.value = initialQ; + applyTab(initialTab); + runSearch(initialQ); + + /* Focus explicite (voir en-tête) : l'attribut autofocus seul ne + * survit pas au chargement des surcouches. */ + focusSearch(); + try { + if (global.requestAnimationFrame) global.requestAnimationFrame(focusSearch); + } catch (e) {} + try { + setTimeout(focusSearch, 0); + } catch (e) {} + global.addEventListener("pageshow", function (e) { + if (e.persisted) focusSearch(); + }); } if (document.readyState === "loading") {