/* 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= * -> { results: [{ vault, path, title, snippet }] } * Lien profond vérifié : {base}#file={vault}:{path} (Viewer.openFile). * - Flowdeck : GET {base}/api/search?q= * -> { pages: [{ title, excerpt, subtitle, url: "/pages/" }], * collections: [{ title, subtitle, url: "/db/" }] } * - TikTak : GET {base}/api/search?q= * -> { tasks: [{ id, title, description, status, due_at }], ... } * Lien profond vérifié : {base}search?q=&task= (page /search). * - Kanbante : GET {base}/api/search?q= * -> { groups: [{ full_name, rows: [{ issue, column }] }] } * Lien profond : {base}board/ (la carte n'a pas d'URL propre * adressable — même limite que la page /search native de Kanbante). * - Console Spark : AUCUN endpoint de recherche mergé au moment de * cette livraison (agent-commun : seule la PR #12 est ouverte, autre * programme — ce dépôt n'est pas touché ici). Le code tente quand * même l'appel conventionnel GET {base-console}/api/search?q= (même * convention que les autres hôtes ; chemin À CONFIRMER avec le * travailleur console dès que son endpoint atterrit) et, en cas * d'échec, de HTTP non-OK ou de format non reconnu, affiche * honnêtement « Console Spark : recherche indisponible pour * l'instant » — jamais de faux résultats, jamais de faux 0. La base * console vient, dans l'ordre, de data-base-console rendu par l'hôte, * de window.DRACO_URLS.agent (ObsiGate) / window.DRACO_AGENT_URL * (TikTak), de la dérivation -dev si l'hôte courant est -dev, du * href d'un onglet de barre data-app="console"/"spark" s'il existe, * sinon du défaut prod agent.dracodev.net. L'onglet console n'est * interrogé que lorsqu'il est affiché (ou quand une recherche a lieu * pendant qu'il est affiché) : « Tout » ne le contient pas, pour ne * pas polluer la vue groupée d'un message d'indisponibilité. * * Résolution des bases sœurs (même ordre que la barre v0.1.6) : * 1. data-base-* rendus par l'hôte (son propre résolveur : explicite > * hôte de la requête > dérivation > défaut officiel) ; * 2. window.DRACO_BAR.urls (valeur déjà résolue par la barre) ; * 3. dérivation -dev si l'hôte courant est un hôte -dev dracodev.net ; * 4. href des onglets .draco-apptab rendus ; * 5. défauts officiels prod. Aucune URL inventée (pas de spark-dev). * * Authentification cross-origin : les appels sœurs partent avec * credentials:"include". Le navigateur envoie alors les cookies de * chaque origine, dont le cookie SSO partagé draco_session * (Domain=.dracodev.net), accepté par les 4 API de recherche * (ObsiGate et TikTak nativement, Flowdeck et Kanbante via #68). * Chaque sœur n'autorise en CORS que les origines sœurs * (GET sur l'endpoint de recherche uniquement, pas de "*"). * Une sœur injoignable / non autorisée ne casse pas les autres : * sa section affiche l'erreur + un lien vers sa recherche native. * Le correctif CORS lui-même est chez un autre travailleur. */ (function (global) { "use strict"; /* globalThis : le fichier est chargé comme script classique dans les * navigateurs, requis en CJS par les tests TikTak/Kanbante/Flowdeck, * et importé en ESM par le test ObsiGate (frontend/js est "type": * "module") — où `this` de niveau supérieur vaut undefined. */ var PROD = { obsigate: "https://og.dracodev.net/", flowdeck: "https://flowdeck.dracodev.net/", tiktak: "https://tiktak.dracodev.net/", kanbante: "https://kanbante.dracodev.net/", }; var DEV = { obsigate: "https://og-dev.dracodev.net/", flowdeck: "https://flowdeck-dev.dracodev.net/", tiktak: "https://tiktak-dev.dracodev.net/", kanbante: "https://kanbante-dev.dracodev.net/", }; var PROD_AGENT = "https://agent.dracodev.net/"; var DEV_AGENT = "https://agent-dev.dracodev.net/"; var CONSOLE_SEARCH_PATH = "api/search"; var APPS = [ { key: "obsigate", label: "ObsiGate" }, { key: "flowdeck", label: "Flowdeck" }, { key: "tiktak", label: "TikTak" }, { key: "kanbante", label: "Kanbante" }, ]; var CONSOLE_APP = { key: "console", label: "Console Spark" }; var TABS = ["all", "obsigate", "flowdeck", "tiktak", "kanbante", "console"]; var COLUMN_LABELS = { backlog: "Backlog", in_progress: "En cours", qa: "QA", review: "Relecture", todo: "À faire", done: "Terminé", }; var TIMEOUT_MS = 8000; var DEBOUNCE_MS = 250; var PER_APP_LIMIT = 8; var FULL_LIMIT = 100; function esc(s) { return String(s == null ? "" : s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function cleanUrl(u) { if (!u) return null; u = String(u).trim(); if (!u || u.indexOf("{{") !== -1) return null; return u; } function withSlash(u) { return u.charAt(u.length - 1) === "/" ? u : u + "/"; } /* Même règle que la barre v0.1.6 : première étiquette en -dev ET * domaine dracodev.net, sinon rien (un hôte piège ne déclenche rien). */ function isDevHost(hostname) { if (!hostname) return false; var h = String(hostname).toLowerCase(); if (h.slice(-13) !== ".dracodev.net") return false; var first = h.split(".")[0]; return first.length > 4 && first.slice(-4) === "-dev"; } /* opts : { host, explicit:{}, barUrls:{}, tabHrefs:{} } */ function resolveBases(opts) { opts = opts || {}; var dev = isDevHost(opts.host); var out = {}; APPS.forEach(function (app) { var key = app.key; var u = cleanUrl(opts.explicit && opts.explicit[key]); if (!u) u = cleanUrl(opts.barUrls && opts.barUrls[key]); if (!u && dev) u = DEV[key]; if (!u) u = cleanUrl(opts.tabHrefs && opts.tabHrefs[key]); if (!u) u = PROD[key]; out[key] = withSlash(u); }); return out; } /* Base console : explicite > URL agent injectée par l'hôte > * dérivation -dev > href d'onglet de barre console/spark > prod. * opts : { host, explicit, agentUrl, tabHrefs:{} } */ function resolveConsoleBase(opts) { opts = opts || {}; var u = cleanUrl(opts.explicit); if (!u) u = cleanUrl(opts.agentUrl); if (!u && isDevHost(opts.host)) u = DEV_AGENT; if (!u) u = cleanUrl( opts.tabHrefs && (opts.tabHrefs.console || opts.tabHrefs.spark) ); if (!u) u = PROD_AGENT; return withSlash(u); } /* Onglet valide dans l'URL, sinon "all". */ function parseTab(v) { return TABS.indexOf(v) !== -1 ? v : "all"; } /* Sections visibles pour un onglet. « Tout » = les 4 sections * groupées (vue #68) ; la console n'y figure pas (voir en-tête). */ function visibleKeys(tab) { tab = parseTab(tab); if (tab === "all") return APPS.map(function (a) { return a.key; }); return [tab]; } /* Troncature d'affichage : liste complète dans l'onglet propre à * l'application, PER_APP_LIMIT partout ailleurs (vue « Tout »). */ function pageItems(items, full) { items = items || []; return full ? items : items.slice(0, PER_APP_LIMIT); } /* ?q=&tab= partageable. tab "all" omis (c'est le défaut). */ function buildSearchUrl(q, tab) { var parts = []; if (q) parts.push("q=" + encodeURIComponent(q)); tab = parseTab(tab); if (tab !== "all") parts.push("tab=" + encodeURIComponent(tab)); return parts.length ? "?" + parts.join("&") : ""; } /* Libellés des compteurs d'onglets à partir des états : * states : { key: { status, total } }. * Chargement -> "…", ok/vide -> total ("0" honnête), * erreur/indisponible/inactif -> "" (pas de faux 0). * Tout : "…" tant qu'une section charge, sinon somme des totaux * connus, "" si les 4 sections sont en erreur/inactives. */ function tabCounts(states) { states = states || {}; var out = {}; function labelFor(key) { var st = states[key] || {}; if (st.status === "loading") return "…"; if (st.status === "ok" || st.status === "empty") return String(st.total || 0); return ""; } var allLoading = false; var allTotal = 0; var allKnown = false; APPS.forEach(function (app) { var st = states[app.key] || {}; out[app.key] = labelFor(app.key); if (st.status === "loading") allLoading = true; if (st.status === "ok" || st.status === "empty") { allKnown = true; allTotal += st.total || 0; } }); out.console = labelFor("console"); out.all = allLoading ? "…" : allKnown ? String(allTotal) : ""; return out; } function searchPath(app, q, full) { if (app === "obsigate") { return ( "api/search?q=" + encodeURIComponent(q) + "&vault=all&limit=" + (full ? FULL_LIMIT : PER_APP_LIMIT) ); } return "api/search?q=" + encodeURIComponent(q); } function snippetOf(s, n) { s = String(s == null ? "" : s).replace(/\s+/g, " ").trim(); return s.length > (n || 160) ? s.slice(0, n || 160) + "…" : s; } /* Normalise la réponse d'une app en [{ title, snippet, context, url }]. * base : base résolue de l'app (avec / final). q : requête (liens TikTak). * full=true : aucune troncature (stockage complet) ; sinon PER_APP_LIMIT * (comportement historique, conservé par défaut pour les tests). */ function normalize(app, data, base, q, full) { data = data || {}; var limit = full ? null : PER_APP_LIMIT; function cut(arr) { return limit == null ? arr : arr.slice(0, limit); } if (app === "obsigate") { return cut(data.results || []).map(function (r) { return { title: r.title || r.path || "Sans titre", snippet: snippetOf(r.snippet || ""), context: (r.vault || "") + (r.path ? " · " + r.path : ""), url: base + "#file=" + encodeURIComponent(r.vault || "") + ":" + encodeURIComponent(r.path || ""), }; }); } if (app === "flowdeck") { var items = (data.pages || []).concat(data.collections || []); return cut(items).map(function (r) { var rel = String(r.url || "").replace(/^\//, ""); return { title: r.title || "Sans titre", snippet: snippetOf(r.excerpt || ""), context: r.subtitle || (r.type === "collection" ? "Base" : "Page"), url: base + rel, }; }); } if (app === "tiktak") { return cut(data.tasks || []).map(function (t) { var ctx = t.status === "done" ? "Terminée" : "Active"; if (t.due_at) ctx += " · échéance " + String(t.due_at).slice(0, 10); return { title: t.title || "Sans titre", snippet: snippetOf(t.description || ""), context: "Tâche · " + ctx, url: base + "search?q=" + encodeURIComponent(q || "") + "&task=" + t.id, }; }); } if (app === "kanbante") { var rows = []; (data.groups || []).forEach(function (g) { (g.rows || []).forEach(function (row) { rows.push({ group: g.full_name, row: row }); }); }); return cut(rows).map(function (x) { var issue = x.row.issue || {}; var col = COLUMN_LABELS[x.row.column] || x.row.column || ""; return { title: "#" + issue.number + " " + (issue.title || "Sans titre"), snippet: snippetOf(issue.body || ""), context: x.group + (col ? " · " + col : ""), url: base + "board/" + x.group, }; }); } return []; } /* Normalisation console, défensive : accepte results/discussions/ * items. Un élément sans URL exploitable (http(s), /relatif, #ancre) * garde url=null et sera rendu en , pas en faux lien profond. * Retourne { recognized, items } : recognized=false -> l'appelant * affiche « indisponible », jamais un vide mensonger. */ function normalizeConsole(data, base) { data = data || {}; var list = null; if (Array.isArray(data.results)) list = data.results; else if (Array.isArray(data.discussions)) list = data.discussions; else if (Array.isArray(data.items)) list = data.items; if (!list) return { recognized: false, items: [] }; var items = list.map(function (r) { r = r || {}; var raw = r.url || r.href || r.link || ""; var url = null; if (/^https?:\/\//.test(raw)) url = raw; else if (raw.charAt(0) === "/" || raw.charAt(0) === "#") url = base + String(raw).replace(/^\//, ""); return { title: r.title || r.name || r.subject || "Sans titre", snippet: snippetOf(r.snippet || r.excerpt || r.preview || r.body || ""), context: r.subtitle || r.source || r.type || "", url: url, }; }); return { recognized: true, items: items }; } /* Recherche native de l'app (repli quand la section est en erreur). */ function nativeSearchUrl(app, base, q) { if (app === "tiktak" || app === "kanbante") { return base + "search?q=" + encodeURIComponent(q || ""); } /* ObsiGate et Flowdeck n'ont pas de page de recherche adressable : * leur recherche native vit dans la palette (Ctrl+K) de l'app. */ return base; } function nativeSearchLabel(app) { if (app === "tiktak" || app === "kanbante") { return "Ouvrir la recherche dans " + appLabel(app); } return "Ouvrir " + appLabel(app) + " (recherche via la palette Ctrl+K)"; } function appLabel(key) { if (key === "console") return CONSOLE_APP.label; for (var i = 0; i < APPS.length; i++) { if (APPS[i].key === key) return APPS[i].label; } return key; } var api = { esc: esc, isDevHost: isDevHost, resolveBases: resolveBases, resolveConsoleBase: resolveConsoleBase, parseTab: parseTab, visibleKeys: visibleKeys, pageItems: pageItems, buildSearchUrl: buildSearchUrl, tabCounts: tabCounts, searchPath: searchPath, normalize: normalize, normalizeConsole: normalizeConsole, nativeSearchUrl: nativeSearchUrl, PROD: PROD, DEV: DEV, PROD_AGENT: PROD_AGENT, DEV_AGENT: DEV_AGENT, CONSOLE_SEARCH_PATH: CONSOLE_SEARCH_PATH, PER_APP_LIMIT: PER_APP_LIMIT, FULL_LIMIT: FULL_LIMIT, }; if (typeof module !== "undefined" && module.exports) module.exports = api; global.RecherchePartout = api; /* ---- Partie DOM : ne s'exécute que dans un navigateur. ---- */ if (typeof document === "undefined") return; function fetchSection(app, url, onDone) { var ctrl = null; var timer = null; try { ctrl = new AbortController(); timer = setTimeout(function () { ctrl.abort(); }, TIMEOUT_MS); } catch (e) { ctrl = null; } var opts = { headers: { Accept: "application/json" }, credentials: "include" }; if (ctrl) opts.signal = ctrl.signal; fetch(url, opts) .then(function (resp) { if (!resp.ok) { var err = new Error("HTTP " + resp.status); err.status = resp.status; throw err; } return resp.json(); }) .then(function (data) { if (timer) clearTimeout(timer); onDone(null, data); }) .catch(function (err) { if (timer) clearTimeout(timer); onDone(err, null); }); } function renderSection(section, state, full) { /* state : { status: "idle"|"loading"|"empty"|"error"|"ok"|"unavailable", * items (complets), message, q, base, app }. * full=true (onglet propre à l'app) : tout afficher ; sinon * PER_APP_LIMIT (vue « Tout »). Le compteur affiche le TOTAL. */ var body = section.querySelector(".rp-body"); var count = section.querySelector(".rp-count"); var toutVoir = section.querySelector(".rp-all"); if (toutVoir) { try { toutVoir.href = global.location.pathname + buildSearchUrl(state.q, state.app); } catch (e) {} toutVoir.textContent = "Tout voir"; toutVoir.title = "Afficher tous les résultats de " + appLabel(state.app); } if (state.status === "loading") { count.textContent = "…"; body.innerHTML = '

Recherche…

'; return; } if (state.status === "error") { count.textContent = ""; body.innerHTML = '

' + esc(state.message) + ' — ' + esc(nativeSearchLabel(state.app)) + "

"; return; } if (state.status === "unavailable") { count.textContent = ""; body.innerHTML = '

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 = shown .map(function (item) { var inner = '' + esc(item.title) + "" + (item.context ? '' + esc(item.context) + "" : "") + (item.snippet ? '' + esc(item.snippet) + "" : ""); if (item.url) { return ( '' + inner + "" ); } return '' + inner + ""; }) .join(""); } function init() { var root = document.getElementById("recherche"); if (!root) return; var input = document.getElementById("rp-q"); if (!input) return; var hostApp = root.getAttribute("data-host") || ""; var explicit = {}; APPS.forEach(function (app) { var v = root.getAttribute("data-base-" + app.key); if (v) explicit[app.key] = v; }); var barUrls = {}; try { if (global.DRACO_BAR && global.DRACO_BAR.urls) barUrls = global.DRACO_BAR.urls; } catch (e) {} var tabHrefs = {}; try { document.querySelectorAll(".draco-apptab[data-app]").forEach(function (tab) { var href = tab.getAttribute("href"); if (href) tabHrefs[tab.getAttribute("data-app")] = href; }); } catch (e) {} var bases = resolveBases({ host: global.location.hostname, explicit: explicit, 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 = {}; 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) { allKeys.forEach(function (key) { var sec = sections[key]; if (!sec) return; 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; 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, true); } else { url = bases[app.key] + searchPath(app.key, q, true); } fetchSection(app.key, 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 === 401 || err.status === 403) 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)"; states[app.key] = { status: "error", items: [], message: msg, q: q, 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, }; } 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) { if (debounceTimer) clearTimeout(debounceTimer); if (immediate) { runSearch(q); return; } debounceTimer = setTimeout(function () { runSearch(q); }, DEBOUNCE_MS); } function syncUrl(q) { try { 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); scheduleSearch(q, false); }); input.addEventListener("keydown", function (e) { if (e.key === "Enter") { var first = visibleLinks()[0]; if (first) { e.preventDefault(); first.click(); } } else if (e.key === "Escape") { input.value = ""; syncUrl(""); scheduleSearch("", true); input.focus(); } else if (e.key === "ArrowDown" || e.key === "ArrowUp") { moveFocus(e.key === "ArrowDown" ? 1 : -1, e); } }); document.addEventListener( "keydown", function (e) { var target = e.target; var typing = target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable); if ((e.key === "/" && !typing) || ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k")) { e.preventDefault(); e.stopPropagation(); input.focus(); 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); } }, 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 = visibleLinks(); if (!links.length) return; var active = document.activeElement; var idx = links.indexOf(active); var next; if (idx === -1) next = delta > 0 ? 0 : links.length - 1; else next = (idx + delta + links.length) % links.length; e.preventDefault(); links[next].focus(); } var params = new URLSearchParams(global.location.search); var initialQ = (params.get("q") || "").trim(); 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") { document.addEventListener("DOMContentLoaded", init); } else { init(); } })( typeof window !== "undefined" ? window : typeof globalThis !== "undefined" ? globalThis : this );