From 8dcddfe34fe2c2fdbe5573b332259123ef181bc9 Mon Sep 17 00:00:00 2001
From: bruno
Date: Wed, 28 Oct 2026 13:50:15 -0400
Subject: [PATCH] Recherche partout (#68, chantier 2+3) : onglets, Tout voir
sur place, focus, mise en page
---
static/js/recherche.js | 608 ++++++++++++++++++++++++++++++++++++-----
1 file changed, 543 insertions(+), 65 deletions(-)
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=&task= (page /search).
- * - Kanbante : GET {base}/api/search?q= (NOUVEAU, cette livraison)
+ * - 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 >
@@ -31,11 +76,12 @@
* 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 cette
- * livraison). Chaque sœur n'autorise en CORS que les origines sœurs
+ * (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";
@@ -56,12 +102,17 @@
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",
@@ -73,6 +124,7 @@
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)
@@ -120,9 +172,91 @@
return out;
}
- function searchPath(app, q) {
+ /* 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=" + PER_APP_LIMIT;
+ return (
+ "api/search?q=" +
+ encodeURIComponent(q) +
+ "&vault=all&limit=" +
+ (full ? FULL_LIMIT : PER_APP_LIMIT)
+ );
}
return "api/search?q=" + encodeURIComponent(q);
}
@@ -133,11 +267,17 @@
}
/* 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). */
- function normalize(app, data, base, q) {
+ * 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 (data.results || []).slice(0, PER_APP_LIMIT).map(function (r) {
+ return cut(data.results || []).map(function (r) {
return {
title: r.title || r.path || "Sans titre",
snippet: snippetOf(r.snippet || ""),
@@ -153,7 +293,7 @@
}
if (app === "flowdeck") {
var items = (data.pages || []).concat(data.collections || []);
- return items.slice(0, PER_APP_LIMIT).map(function (r) {
+ return cut(items).map(function (r) {
var rel = String(r.url || "").replace(/^\//, "");
return {
title: r.title || "Sans titre",
@@ -164,7 +304,7 @@
});
}
if (app === "tiktak") {
- return (data.tasks || []).slice(0, PER_APP_LIMIT).map(function (t) {
+ 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 {
@@ -182,7 +322,7 @@
rows.push({ group: g.full_name, row: row });
});
});
- return rows.slice(0, PER_APP_LIMIT).map(function (x) {
+ return cut(rows).map(function (x) {
var issue = x.row.issue || {};
var col = COLUMN_LABELS[x.row.column] || x.row.column || "";
return {
@@ -196,6 +336,34 @@
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") {
@@ -214,6 +382,7 @@
}
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;
}
@@ -224,11 +393,23 @@
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;
@@ -268,16 +449,24 @@
});
}
- function renderSection(section, state) {
- /* state : { status: "loading"|"empty"|"error"|"ok", items, message, q, base, app } */
+ 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");
- toutVoir.href = nativeSearchUrl(state.app, state.base, state.q);
- toutVoir.textContent = "Tout voir";
- toutVoir.title = nativeSearchLabel(state.app);
+ 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 = "";
+ count.textContent = "…";
body.innerHTML = 'Recherche…
';
return;
}
@@ -293,19 +482,31 @@
"
";
return;
}
- if (state.status === "empty") {
+ 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 = 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") {