From 4231f2e92979f3371f2380948e011d1f8458c54e Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Wed, 16 Sep 2026 20:16:18 -0400 Subject: [PATCH] feat(sidebar+assistant): filtre Recents/Sauvegardes (#99), pastille Deep Research (#100) et icone du bouton + (BUG-049) --- .gitea/workflows/ci.yml | 2 + CHANGELOG.md | 30 +++- README.fr.md | 6 +- README.md | 6 +- VERSION | 2 +- desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/ISSUES_TODOLIST.md | 1 + docs/ROADMAP.md | 6 +- docs/features/ai-assistant-history.md | 35 ++++- docs/features/sidebar-filters.md | 72 ++++++++++ frontend/js/bookslm.js | 31 ++-- frontend/js/config.js | 59 ++++++-- frontend/js/sidebar.js | 59 ++++---- frontend/js/viewer.js | 33 ++++- frontend/locales/en.json | 4 +- frontend/locales/fr.json | 4 +- frontend/style.css | 5 +- package.json | 2 +- tests/frontend/ai.test.mjs | 25 ++++ tests/frontend/sidebar-filters.test.mjs | 183 ++++++++++++++++++++++++ 22 files changed, 491 insertions(+), 80 deletions(-) create mode 100644 docs/features/sidebar-filters.md create mode 100644 tests/frontend/sidebar-filters.test.mjs diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 34beb10..e6ba982 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -47,6 +47,7 @@ jobs: node plugins.test.mjs node ai.test.mjs node ai-sidebar.test.mjs + node sidebar-filters.test.mjs node sw.test.mjs node collab.test.mjs node mobile-editor.test.mjs @@ -62,6 +63,7 @@ jobs: node plugins.test.mjs node ai.test.mjs node ai-sidebar.test.mjs + node sidebar-filters.test.mjs node sw.test.mjs node collab.test.mjs node mobile-editor.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 976c138..fd5d954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.5.2**. +> [Unreleased](#unreleased). La dernière version livrée est **2.6.0**. --- @@ -14,6 +14,34 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.6.0] — 2026-09-16 + +### Ajouté + +- **Barre de filtrage de la sidebar sur « Récents » et « Sauvegardes » (#99)** : la barre + de recherche de la sidebar agit désormais sur les onglets **Récents** + (`filterRecentFiles`, filtrage titre/chemin/vault/aperçu/tags) et **Sauvegardes** + (`filterSavedSearches`, cumulable avec les pills Tous/Recherches/Répertoires) — + insensible à la casse et aux accents, avec message d'absence de résultat et + placeholders dédiés (`sidebar.filter_recent`, `sidebar.filter_saved`). Le routage de + `initSidebarFilter` est unifié (`routeFilter`/`routeClear`) pour couvrir les cinq + onglets. Fiche : [docs/features/sidebar-filters.md](./docs/features/sidebar-filters.md). +- **Assistant IA — Deep Research en pastille (#100)** : « Deep Research » ajoute + désormais une **pastille** (comme les skills) au lieu d'écrire la directive dans la + zone de saisie ; le mode Agent est activé et la directive est injectée au moment de + l'envoi, sans polluer le message affiché. + +### Corrigé + +- **BUG-049 — icône du bouton « + » de l'assistant invisible** : la règle générique + `.bookslm-input-area button` écrasait `.bookslm-btn-plus` (`padding: 8px 16px` sur une + largeur de 32 px ⇒ largeur de contenu nulle ⇒ SVG à 0 px). Sélecteur porté à + `.bookslm-input-area button.bookslm-btn-plus`, l'icône `plus` est de nouveau visible + (vérifié en navigateur : SVG 0 px → 18 px). Tests : `tests/frontend/ai.test.mjs` (+1), + `tests/frontend/sidebar-filters.test.mjs` (nouveau, 8 tests). + +--- + ## [2.5.2] — 2026-09-16 --- diff --git a/README.fr.md b/README.fr.md index f84dc5d..a03a879 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.5.2-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.6.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -916,8 +916,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.5.2). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.6.0). --- -*Projet : ObsiGate | Version : 2.5.2 | Dernière mise à jour : Juin 2026* +*Projet : ObsiGate | Version : 2.6.0 | Dernière mise à jour : Juin 2026* diff --git a/README.md b/README.md index a6b62bc..f88554b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.5.2-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.6.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1085,8 +1085,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.5.2). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.6.0). --- -*Project: ObsiGate | Version: 2.5.2 | Last updated: May 2026* +*Project: ObsiGate | Version: 2.6.0 | Last updated: May 2026* diff --git a/VERSION b/VERSION index f225a78..e70b452 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.5.2 +2.6.0 diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 6f07ead..1ebe213 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.5.2" +version = "2.6.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 5e2f7a8..5f0481a 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.5.2" +version = "2.6.0" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 704c942..a289779 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.5.2", + "version": "2.6.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index 635dff9..41a24b9 100644 --- a/docs/ISSUES_TODOLIST.md +++ b/docs/ISSUES_TODOLIST.md @@ -205,6 +205,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | 2026-09-16 | #94, #95, #96, #97 | Feature | `backend/ai_history.py` (nouveau), `backend/bookslm_routes.py`, `frontend/js/bookslm.js`, `frontend/js/config.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_bookslm.py`, `tests/frontend/ai.test.mjs`, `docs/features/ai-assistant-history.md` (nouvelle), `docs/ROADMAP.md`, `CHANGELOG.md` | **Assistant IA** : #95 historique **permanent** (backend `data/ai_history/{user}.json`, cap 200, endpoints CRUD `/api/ai/bookslm/history[…]` résumés/full, sync frontend debounced 600 ms + repli localStorage + migration des clés legacy `bookslm-sessions-*`/`bookslm-history-*`, événement `bookslm:history-updated`) ; #96 onglet sidebar `#sidebar-tab-ai` (`messages-square`) + panneau `#sidebar-panel-ai` (liste chronologique, ouverture via `openWithSession`) ; #97 bouton **« + »** remplaçant « Attach an image » + panneau modulaire `.bookslm-ext-menu` (registre `_extensions` : Fichiers, Image, Contextes, Skills, Deep Research = mode agent + prompt, Recherche web & Canva en « Bientôt ») ; #94 bouton d'envoi circulaire + icône Lucide `arrow-up`. Vérifié : pytest 1088 passed / 6 skipped, ruff 0, mypy 0 (71 fichiers), tests frontend IA 84/84, unit 9/9, validate-imports 38 modules. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-16 | BUG-048, #98 | Correction + feature | `frontend/js/bookslm.js`, `frontend/js/config.js`, `frontend/js/sidebar.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `.gitea/workflows/ci.yml`, `tests/frontend/ai.test.mjs`, `tests/frontend/ai-sidebar.test.mjs` (nouveau), `docs/features/ai-assistant-history.md`, `docs/ROADMAP.md`, `CHANGELOG.md` | **BUG-048** : les entrées « Contextes » et « Skills » du menu « + » ouvraient bien leur menu (`@` / `/`), mais le clic remontait au gestionnaire du panneau qui annulait le rendu asynchrone → menu jamais affiché ; correction par `e.stopPropagation()` sur les entrées du panneau `.bookslm-ext-menu`. **#98** : la barre de filtrage de la sidebar agit désormais sur l'onglet « Historique IA » — `filterAIHistory()` (config.js) filtre par titre, aperçu, répertoire, contexte ou libellé de mode, insensible casse/accents (`_aiNorm`), cache sessions `_aiSessionsCache`, message « aucune correspondance » (`bookslm.history_no_match`) dans la liste et placeholder dédié (`sidebar.filter_ai`) ; `initSidebarFilter` (sidebar.js) route saisie/touche casse/bouton « × » vers `filterAIHistory` quand l'onglet IA est actif ; chaque entrée du panneau « + » porte l'icône Lucide `plus`. Vérifié : tests frontend IA 87/87 (+3), nouvelle suite `ai-sidebar` 6/6, unit 9/9, 9 suites JSDOM vertes, validate-imports 38 modules, pytest / ruff / mypy inchangés (aucune modification backend). | 🟢 corrigé (en attente vérif utilisateur) — CI Gitea verte (lint, test, security, build, e2e) pour v2.5.0 (run #1511) | +| 2026-09-16 | BUG-049, #99, #100 | Correction + feature | `frontend/style.css`, `frontend/js/config.js`, `frontend/js/viewer.js`, `frontend/js/sidebar.js`, `frontend/js/bookslm.js`, `frontend/locales/{fr,en}.json`, `.gitea/workflows/ci.yml`, `tests/frontend/ai.test.mjs`, `tests/frontend/sidebar-filters.test.mjs` (nouveau), `docs/features/sidebar-filters.md` (nouvelle), `docs/features/ai-assistant-history.md`, `docs/ROADMAP.md`, `CHANGELOG.md` | **BUG-049** : icône du bouton « + » de l'assistant invisible — la règle générique `.bookslm-input-area button` (spécificité supérieure) imposait `padding: 8px 16px` sur un bouton `width: 32px` ⇒ largeur de contenu nulle ⇒ SVG `width: 0px` ; sélecteur porté à `.bookslm-input-area button.bookslm-btn-plus` (+ `:hover`), vérifié en navigateur (Playwright : SVG 0 px → 18 px). **#99** : la barre de filtrage de la sidebar agit désormais sur les vues **Récents** (`filterRecentFiles`, titre/chemin/vault/aperçu/tags) et **Sauvegardes** (`filterSavedSearches`, cumulable avec les pills type), insensible casse/accents (`_sidebarNorm`/`_savedNorm`), message d'absence de résultat (`sidebar.no_results`) et placeholders dédiés (`sidebar.filter_recent`, `sidebar.filter_saved`) ; `initSidebarFilter` refactoré en `routeFilter`/`routeClear` couvrant les 5 onglets. **#100** : « Deep Research » ajoute une **pastille** `.bookslm-chip-deep-research` (au lieu d'injecter la directive dans le composeur), active le mode Agent et injecte la directive au moment de l'envoi. Vérifié : tests frontend IA 88/88 (+1), `sidebar-filters` 8/8 (nouveau), `ai-sidebar` 6/6, unit 9/9, 9 suites JSDOM vertes, validate-imports 38 modules, vérification navigateur du bouton « + » ; backend inchangé (pytest / ruff / mypy valides). | 🟢 corrigé (en attente vérif utilisateur) | --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7eaae0d..6e4f86b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.5.2 | **Dernière mise à jour :** 2026-09-16 +> **Version :** 2.6.0 | **Dernière mise à jour :** 2026-09-16 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** @@ -195,6 +195,8 @@ | 96 | Assistant IA — Accès rapide à l'historique depuis la sidebar de navigation | 2.4.0 | [features/ai-assistant-history.md](./features/ai-assistant-history.md) | | 97 | Assistant IA — Panneau « + » extensible (fichiers, Deep Research, contextes, skills…) | 2.4.0 | [features/ai-assistant-history.md](./features/ai-assistant-history.md) | | 98 | Assistant IA — Filtre de recherche dans la sidebar « Historique IA » | 2.5.0 | [features/ai-assistant-history.md](./features/ai-assistant-history.md) | +| 99 | Sidebar — Filtrage des vues « Récents » et « Sauvegardes » | 2.6.0 | [features/sidebar-filters.md](./features/sidebar-filters.md) | +| 100 | Assistant IA — Deep Research en pastille (au lieu du texte injecté) | 2.6.0 | [features/ai-assistant-history.md](./features/ai-assistant-history.md) | --- @@ -202,7 +204,7 @@ | Priorité | Items | Effort total estimé | |---|---|---| -| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #88–93, #94–98 | ~113 jours réalisés | +| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #88–93, #94–100 | ~114 jours réalisés | | 🔵 P2 restant | #77 Desktop : signature de code (non retenue), 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) | ~0,5-1 jour | | ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours | | ⚪ P2 restant | #92 Assistant IA — écosystème d'outils phase 2 (web étendu, sources connectées, documents) | 3-5 jours | diff --git a/docs/features/ai-assistant-history.md b/docs/features/ai-assistant-history.md index 9f690c8..f72e9dc 100644 --- a/docs/features/ai-assistant-history.md +++ b/docs/features/ai-assistant-history.md @@ -141,4 +141,37 @@ bien l'icône Lucide `plus` (vérifié par test JSDOM). - `MAX_SESSIONS = 200` : le comportement de purge est testé (`TestAIGatewayHistoryStore`) ; la rétention configurable (item #95 du backlog) pourra s'appuyer sur ce plafond. - Les modules web/Canva du panneau « + » sont volontairement désactivés tant que - l'écosystème d'outils phase 2 (#92) n'est pas livré. \ No newline at end of file + l'écosystème d'outils phase 2 (#92) n'est pas livré. + +## I. Complément — icône « + » et pastille Deep Research (BUG-049, #100) — ✅ livré + +*(1) **BUG-049 — l'icône du bouton « + » n'était pas visible.*** Le SVG Lucide était +bien rendu, mais la règle générique `.bookslm-input-area button { padding: 8px 16px; +background: var(--accent); color:#fff }` l'emportait en **spécificité** sur +`.bookslm-btn-plus` (une classe seule). Le bouton conservait `width:32px` avec +`padding: 8px 16px` → **largeur de contenu = 0 px**, donc SVG à `width: 0px` +(invisible). Correctif CSS : sélecteur porté à +`.bookslm-input-area button.bookslm-btn-plus` (et `:hover`), qui reprend la main +(`padding:0`, fond transparent, couleur `--text-secondary`). Vérifié en navigateur +(Playwright) : `svgWidth` passe de `0px` à `18px`. + +*(2) **#100 — Deep Research devient une pastille (comme les skills).*** Auparavant, +cliquer sur « Deep Research » injectait la directive de recherche dans la zone de +saisie. Désormais : + +- `_startDeepResearch()` active le **mode Agent** (si nécessaire), positionne le drapeau + `_activeDeepResearch` et rend une **pastille** `.bookslm-chip-deep-research` + (`_renderAttachments()`), sans rien écrire dans le composeur. +- La pastille se retire via son « × » (comme les chips skills/fichiers) et remet le + drapeau à `false`. +- La directive (`bookslm.deep_research_prompt`) est injectée **au moment de l'envoi** + dans le `message` du payload (`_sendMessage()`), sans polluer le message affiché à + l'utilisateur. +- Message d'information mis à jour (`bookslm.deep_research_started`) : « Deep Research + activé — ajoutez votre question puis envoyez. » + +### Tests du complément +- `tests/frontend/ai.test.mjs` (+1) : le clic sur « Deep Research » ajoute une pastille, + laisse le composeur vide, positionne le drapeau, et le retrait de la pastille remet + le drapeau à `false`. +- Vérification navigateur du bouton « + » (Playwright, instance de test). \ No newline at end of file diff --git a/docs/features/sidebar-filters.md b/docs/features/sidebar-filters.md new file mode 100644 index 0000000..ea019d9 --- /dev/null +++ b/docs/features/sidebar-filters.md @@ -0,0 +1,72 @@ +# #99 — Barre de filtrage de la sidebar sur « Récents » et « Sauvegardes » + +> **Statut :** ✅ livré · **Version :** 2.6.0 · **ID :** #99 +> **Lié :** BUG-049 (icône du bouton « + » de l'assistant), #100 (pastille Deep Research). + +## Contexte + +La barre de recherche/filtrage de la sidebar (`#sidebar-filter-input`) agissait +historiquement sur les onglets **Fichiers** (arborescence) et **Tags**. L'onglet +**Historique IA** a reçu son propre filtrage en #98. Les onglets **Récents** et +**Sauvegardes** (« saved searches ») n'étaient pas couverts : taper dans la barre +n'avait aucun effet dans ces deux vues. + +## A. Onglet « Récents » — ✅ livré + +- Nouveau cache module `_recentQuery` et fonction exportée `filterRecentFiles(query)` + (`frontend/js/config.js`). +- `_applyRecentFilter(files)` filtre le cache `_recentFilesCache` sur **titre, chemin, + vault, aperçu et tags**, via `_sidebarNorm()` (normalisation `NFD` + suppression des + diacritiques + minuscules) — donc insensible à la casse **et** aux accents. +- `loadRecentFiles()` applique désormais le filtre courant après chaque chargement. +- Aucun résultat → un message `sidebar.no_results` est inséré dans `#recent-list` + (classe `.sidebar-filter-empty`, style existant). +- `switchSidebarTab("recent")` applique la requête courante et pose le placeholder + `sidebar.filter_recent`. + +## B. Onglet « Sauvegardes » — ✅ livré + +- Nouveau `_savedQuery` et fonction exportée `filterSavedSearches(query)` + (`frontend/js/viewer.js`). +- `_applySavedFilter()` combine désormais **le filtre de type** (pills Tous / Recherches + / Répertoires) **et la requête texte** : un élément est visible si son `data-type` + correspond au pill **et** si son texte (requête, vault, chemins inclus/exclus) contient + la requête normalisée (`_savedNorm`). +- Aucun résultat avec une requête active → message `sidebar.no_results` ajouté à + `#saved-searches-list` (nettoyé à chaque ré-application pour ne pas s'accumuler). +- `switchSidebarTab("saved")` ré-applique la requête et pose le placeholder + `sidebar.filter_saved`. + +## C. Routage unifié de la barre de filtrage — ✅ livré + +`initSidebarFilter()` (`frontend/js/sidebar.js`) est refactorisé autour de deux +fonctions `routeFilter(q)` / `routeClear()` qui dirigent la saisie, la bascule +casse (`Aa`) et le bouton « × » vers le filtre de l'onglet actif : + +| Onglet | Filtre | +|---|---| +| `vaults` | `performTreeSearch` / `restoreSidebarTree` | +| `recent` | `filterRecentFiles` | +| `saved` | `filterSavedSearches` | +| `ai` | `filterAIHistory` | +| `tags` | `filterTagCloud` | + +## D. i18n — ✅ livré + +- `sidebar.filter_recent` : « Filtrer les fichiers récents... » / "Filter recent files..." +- `sidebar.filter_saved` : « Filtrer les recherches sauvegardées... » / "Filter saved searches..." +- Le message d'absence de résultat réutilise `sidebar.no_results`. + +## E. Tests — ✅ livré + +- `tests/frontend/sidebar-filters.test.mjs` (nouveau, 8 tests) : rendu des listes, + filtrage par titre/casse, accents/tags, vault/chemin, restauration, message + d'absence de résultat, et combinaison pill + requête sur les sauvegardes. +- Ajouté à la liste explicite du job `lint` de `.gitea/workflows/ci.yml`. + +## F. Points d'attention + +- Le filtrage est **client-side** (les listes sont déjà chargées) : aucune requête + réseau supplémentaire n'est déclenchée par la frappe. +- Sur « Sauvegardes », le filtre de type et la requête sont **cumulatifs** ; vider la + barre (ou « × ») ne réinitialise pas le pill actif. diff --git a/frontend/js/bookslm.js b/frontend/js/bookslm.js index e9c2971..0758ccb 100644 --- a/frontend/js/bookslm.js +++ b/frontend/js/bookslm.js @@ -114,6 +114,9 @@ class BooksLM { this._adhocDirs = []; this._images = []; this._activeSkill = null; + // Deep Research: a skill-like chip that enables agent tools and injects a + // research directive into the request (without polluting the composer). + this._activeDeepResearch = false; this._skills = []; this._menuItems = []; this._menuIndex = 0; @@ -214,6 +217,7 @@ class BooksLM { this._adhocDirs = []; this._images = []; this._activeSkill = null; + this._activeDeepResearch = false; this._isLoading = true; await this._loadHistory(preferredSessionId); @@ -1216,7 +1220,7 @@ class BooksLM { const host = this._panel.querySelector('.bookslm-attachments'); if (!host) return; host.innerHTML = ''; - const hasAny = this._adhocFiles.length || this._adhocDirs.length || this._images.length || this._activeSkill; + const hasAny = this._adhocFiles.length || this._adhocDirs.length || this._images.length || this._activeSkill || this._activeDeepResearch; host.classList.toggle('empty', !hasAny); if (!hasAny) return; @@ -1228,6 +1232,13 @@ class BooksLM { () => { this._activeSkill = null; this._renderAttachments(); }, )); } + if (this._activeDeepResearch) { + host.appendChild(this._chip( + `🔎 ${t('bookslm.ext_deep_research')}`, + 'deep-research', + () => { this._activeDeepResearch = false; this._renderAttachments(); }, + )); + } for (const file of this._adhocFiles) { host.appendChild(this._chip(`📄 ${file.path}`, 'file', () => this._removeAdhocFile(file.path))); } @@ -1449,21 +1460,13 @@ class BooksLM { if (typeof safeCreateIcons === 'function') safeCreateIcons(); } - /** #97 — Deep Research: enabled agent tools + a pre-filled directive. */ + /** #97/#99 — Deep Research: agent tools + a skill-like chip (no composer text). */ _startDeepResearch() { this._closeExtMenu(); if (!this._agentMode) this._toggleAgentMode(); - const textarea = this._panel && this._panel.querySelector('textarea'); - if (textarea) { - const directive = textarea.value.trim() - ? textarea.value.replace(/\s*$/, '') - : ''; - textarea.value = (directive ? directive + '\n\n' : '') + t('bookslm.deep_research_prompt'); - textarea.dispatchEvent(new Event('input', { bubbles: true })); - textarea.focus(); - } else { - showToast(t('bookslm.deep_research_started'), 'info'); - } + this._activeDeepResearch = true; + this._renderAttachments(); + showToast(t('bookslm.deep_research_started'), 'info'); } _onPaste(e) { @@ -2728,7 +2731,7 @@ class BooksLM { img.path ? { path: img.path } : { data: img.data, mime_type: img.mime_type } )), skill: this._activeSkill, - message: text, + message: this._activeDeepResearch ? `${t('bookslm.deep_research_prompt')}\n\n${text}` : text, conversation_history: history, provider, model, diff --git a/frontend/js/config.js b/frontend/js/config.js index 8e4f2d3..1497623 100644 --- a/frontend/js/config.js +++ b/frontend/js/config.js @@ -1,7 +1,7 @@ // config.js — extracted from app.js (3872-4865) import { api, AuthManager, initMfaSettings } from './auth.js'; import { state } from './state.js'; -import { el, icon, openFile } from './viewer.js'; +import { el, icon, openFile, filterSavedSearches } from './viewer.js'; import { syncVaultSelectors, setSelectedVaultContext, refreshSidebarForContext, loadVaults, loadVaultSettings, loadTags, TagFilterService, refreshSidebarTreePreservingState } from './sidebar.js'; import { escapeHtml, safeCreateIcons } from './utils.js'; import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js'; @@ -11,6 +11,7 @@ import { getModelCapabilities, renderCapabilityList, refreshAIPickers } from './ let _recentTimestampTimer = null; let _recentFilesCache = []; let _recentRefreshTimer = null; +let _recentQuery = ""; let _aiSessionsCache = []; let _aiQuery = ""; @@ -24,7 +25,7 @@ export async function loadRecentFiles(vaultFilter) { try { const data = await api(url); _recentFilesCache = data.files || []; - renderRecentList(_recentFilesCache); + renderRecentList(_applyRecentFilter(_recentFilesCache)); } catch (err) { console.error("Failed to load recent files:", err); listEl.innerHTML = ""; @@ -41,6 +42,9 @@ function renderRecentList(files) { listEl.innerHTML = ""; if (!files || files.length === 0) { + if (_recentFilesCache.length && _recentQuery) { + listEl.appendChild(el("div", { class: "sidebar-filter-empty" }, [document.createTextNode(t("sidebar.no_results"))])); + } if (emptyEl) { emptyEl.classList.remove("hidden"); safeCreateIcons(); @@ -162,25 +166,45 @@ function _formatAIDate(ts) { } } -/** Accent- and case-insensitive normalization for the AI history filter (#98). */ -function _aiNorm(value) { +/** Accent- and case-insensitive normalization for the sidebar filters (#98/#99). */ +function _sidebarNorm(value) { return String(value || "") .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .toLowerCase(); } +/** Apply the current sidebar query to the recent-files list (#99). */ +function _applyRecentFilter(files) { + const q = _sidebarNorm(_recentQuery); + if (!q) return files; + return (files || []).filter((f) => { + const tags = Array.isArray(f.tags) ? f.tags.join(" ") : ""; + return _sidebarNorm(f.title).includes(q) + || _sidebarNorm(f.path).includes(q) + || _sidebarNorm(f.vault).includes(q) + || _sidebarNorm(f.preview).includes(q) + || _sidebarNorm(tags).includes(q); + }); +} + +/** Filter the sidebar recent-files list by the sidebar filter input (#99). */ +export function filterRecentFiles(query) { + _recentQuery = (query || "").trim(); + renderRecentList(_applyRecentFilter(_recentFilesCache)); +} + /** Apply the current sidebar query to a session list, resolving the mode label. */ function _applyAIFilter(sessions) { - const q = _aiNorm(_aiQuery); + const q = _sidebarNorm(_aiQuery); if (!q) return sessions; return (sessions || []).filter((s) => { const modeLabel = t("bookslm.mode_" + (s.mode || "general")); - return _aiNorm(s.title).includes(q) - || _aiNorm(s.preview).includes(q) - || _aiNorm(s.directory).includes(q) - || _aiNorm(s.context).includes(q) - || _aiNorm(modeLabel).includes(q); + return _sidebarNorm(s.title).includes(q) + || _sidebarNorm(s.preview).includes(q) + || _sidebarNorm(s.directory).includes(q) + || _sidebarNorm(s.context).includes(q) + || _sidebarNorm(modeLabel).includes(q); }); } @@ -274,21 +298,34 @@ function switchSidebarTab(tab) { }); const filterInput = document.getElementById("sidebar-filter-input"); if (filterInput) { - const placeholders = { vaults: "Filtrer fichiers...", tags: "Filtrer tags...", recent: "", ai: t("sidebar.filter_ai") }; + const placeholders = { + vaults: "Filtrer fichiers...", + tags: "Filtrer tags...", + recent: t("sidebar.filter_recent"), + saved: t("sidebar.filter_saved"), + ai: t("sidebar.filter_ai"), + }; filterInput.placeholder = placeholders[tab] || ""; } const query = filterInput ? (state.sidebarFilterCaseSensitive ? filterInput.value.trim() : filterInput.value.trim().toLowerCase()) : ""; if (query) { if (tab === "vaults") performTreeSearch(query); else if (tab === "tags") filterTagCloud(query); + else if (tab === "recent") filterRecentFiles(query); + else if (tab === "saved") filterSavedSearches(query); else if (tab === "ai") filterAIHistory(query); } // Auto-load recent files when switching to the recent tab if (tab === "recent") { _populateRecentVaultFilter(); + if (filterInput) filterRecentFiles(filterInput.value.trim()); const vaultFilter = document.getElementById("recent-vault-filter"); loadRecentFiles(vaultFilter ? vaultFilter.value || null : null); } + // #99 — Re-apply the current sidebar query on the saved-searches tab. + if (tab === "saved" && filterInput) { + filterSavedSearches(filterInput.value.trim()); + } // #96/#98 — Auto-load the AI conversation history when entering its tab, // re-applying the current sidebar filter query. if (tab === "ai") { diff --git a/frontend/js/sidebar.js b/frontend/js/sidebar.js index 0209555..5f81028 100644 --- a/frontend/js/sidebar.js +++ b/frontend/js/sidebar.js @@ -2,8 +2,8 @@ import { state } from './state.js'; import { safeCreateIcons, getFileIcon, flushIcons } from './utils.js'; import { api } from './auth.js'; import { populateCustomDropdown, TabManager, closeMobileSidebar, ContextMenuManager } from './ui.js'; -import { _populateRecentVaultFilter, switchSidebarTab, filterAIHistory } from './config.js'; -import { el, icon, getVaultIcon, smallBadge, attachTreeItemActionButton, attachTreeItemLongPress, showWelcome, appendHighlightedText } from './viewer.js'; +import { _populateRecentVaultFilter, switchSidebarTab, filterAIHistory, filterRecentFiles } from './config.js'; +import { el, icon, getVaultIcon, smallBadge, attachTreeItemActionButton, attachTreeItemLongPress, showWelcome, appendHighlightedText, filterSavedSearches } from './viewer.js'; import { performAdvancedSearch } from './search.js'; import { t } from './i18n.js'; @@ -700,29 +700,32 @@ function initSidebarFilter() { const caseBtn = document.getElementById("sidebar-filter-case-btn"); const clearBtn = document.getElementById("sidebar-filter-clear-btn"); + // Route the query to the active tab's own filter (#98/#99). + const routeFilter = async (q) => { + const tab = state.activeSidebarTab; + if (tab === "vaults") await performTreeSearch(q); + else if (tab === "recent") filterRecentFiles(q); + else if (tab === "saved") filterSavedSearches(q); + else if (tab === "ai") filterAIHistory(q); + else filterTagCloud(q); + }; + const routeClear = async () => { + const tab = state.activeSidebarTab; + if (tab === "vaults") await restoreSidebarTree(); + else if (tab === "recent") filterRecentFiles(""); + else if (tab === "saved") filterSavedSearches(""); + else if (tab === "ai") filterAIHistory(""); + else filterTagCloud(""); + }; + input.addEventListener("input", () => { const hasText = input.value.length > 0; clearBtn.style.display = hasText ? "flex" : "none"; clearTimeout(state.filterDebounce); state.filterDebounce = setTimeout(async () => { const q = state.sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase(); - if (hasText) { - if (state.activeSidebarTab === "vaults") { - await performTreeSearch(q); - } else if (state.activeSidebarTab === "ai") { - filterAIHistory(q); - } else { - filterTagCloud(q); - } - } else { - if (state.activeSidebarTab === "vaults") { - await restoreSidebarTree(); - } else if (state.activeSidebarTab === "ai") { - filterAIHistory(""); - } else { - filterTagCloud(""); - } - } + if (hasText) await routeFilter(q); + else await routeClear(); }, 220); }); @@ -730,15 +733,7 @@ function initSidebarFilter() { state.sidebarFilterCaseSensitive = !state.sidebarFilterCaseSensitive; caseBtn.classList.toggle("active"); const q = state.sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase(); - if (input.value.trim()) { - if (state.activeSidebarTab === "vaults") { - await performTreeSearch(q); - } else if (state.activeSidebarTab === "ai") { - filterAIHistory(q); - } else { - filterTagCloud(q); - } - } + if (input.value.trim()) await routeFilter(q); }); clearBtn.addEventListener("click", async () => { @@ -747,13 +742,7 @@ function initSidebarFilter() { state.sidebarFilterCaseSensitive = false; caseBtn.classList.remove("active"); clearTimeout(state.filterDebounce); - if (state.activeSidebarTab === "vaults") { - await restoreSidebarTree(); - } else if (state.activeSidebarTab === "ai") { - filterAIHistory(""); - } else { - filterTagCloud(""); - } + await routeClear(); }); clearBtn.style.display = "none"; diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js index 0b9b37d..5efd7bf 100644 --- a/frontend/js/viewer.js +++ b/frontend/js/viewer.js @@ -1811,6 +1811,14 @@ function _renderSyncPanel(panel) { // ── Saved searches filter ── var _savedFilterType = 'all'; var _savedFilterInitDone = false; +var _savedQuery = ''; + +function _savedNorm(value) { + return String(value || '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase(); +} function _initSavedFilter() { if (_savedFilterInitDone) return; @@ -1827,18 +1835,41 @@ function _initSavedFilter() { _savedFilterInitDone = true; } +/** #99 — Filter the saved-searches list by the global sidebar filter input. */ +export function filterSavedSearches(query) { + _savedQuery = (query || '').trim(); + _applySavedFilter(); +} + function _applySavedFilter() { var items = document.querySelectorAll('.saved-search-item'); + var list = document.getElementById('saved-searches-list'); + var q = _savedNorm(_savedQuery); var hasVisible = false; items.forEach(function(item) { var type = item.dataset.type; - var show = _savedFilterType === 'all' || type === _savedFilterType; + var typeOk = _savedFilterType === 'all' || type === _savedFilterType; + var textOk = !q || _savedNorm(item.textContent).includes(q); + var show = typeOk && textOk; item.style.display = show ? '' : 'none'; if (show) hasVisible = true; }); + // Remove a previous "no match" hint before deciding whether to add one. + if (list) { + var prev = list.querySelector('.sidebar-filter-empty'); + if (prev) prev.remove(); + } // Show/hide empty state var empty = document.getElementById('saved-searches-empty'); if (empty) empty.style.display = hasVisible ? 'none' : ''; + // Explain an empty result caused by the global search rather than by an + // actually empty saved-searches list. + if (!hasVisible && q && list && items.length) { + var hint = document.createElement('div'); + hint.className = 'sidebar-filter-empty'; + hint.textContent = t('sidebar.no_results'); + list.appendChild(hint); + } } diff --git a/frontend/locales/en.json b/frontend/locales/en.json index c708b3c..66ee9dd 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1545,6 +1545,8 @@ "sidebar.file_icons": "Icons", "sidebar.filter_instant": "Instant search", "sidebar.filter_ai": "Filter AI history...", + "sidebar.filter_recent": "Filter recent files...", + "sidebar.filter_saved": "Filter saved searches...", "sidebar.filter_path": "Path filters", "sidebar.filter_placeholder": "Filter files...", "sidebar.filter_results_grouped": "Grouped results", @@ -1849,7 +1851,7 @@ "bookslm.soon": "Soon", "bookslm.ext_more_coming": "More options coming…", "bookslm.deep_research_prompt": "Perform an in-depth analysis of the request by breaking it into steps, searching for relevant information in the vault, cross-referencing them, and producing a structured summary with sources.", - "bookslm.deep_research_started": "Deep research started — enable Agent mode to use the tools.", + "bookslm.deep_research_started": "Deep Research enabled — add your question and send.", "bookslm.mode_general": "General", "bookslm.mode_directory": "Directory", "bookslm.mode_documents": "Documents", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index ec44ba9..caa50de 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1545,6 +1545,8 @@ "sidebar.file_icons": "Icônes", "sidebar.filter_instant": "Recherche instantanée", "sidebar.filter_ai": "Filtrer l'historique IA...", + "sidebar.filter_recent": "Filtrer les fichiers récents...", + "sidebar.filter_saved": "Filtrer les recherches sauvegardées...", "sidebar.filter_path": "Filtres de chemin", "sidebar.filter_placeholder": "Filtrer fichiers...", "sidebar.filter_results_grouped": "Résultats groupés", @@ -1849,7 +1851,7 @@ "bookslm.soon": "Bientôt", "bookslm.ext_more_coming": "D'autres options à venir…", "bookslm.deep_research_prompt": "Réalise une analyse approfondie de la demande en décomposant les étapes, en cherchant les informations pertinentes dans le vault, en les croisant, et en produisant une synthèse structurée avec sources.", - "bookslm.deep_research_started": "Deep Research lancé — activez le mode Agent pour utiliser les outils.", + "bookslm.deep_research_started": "Deep Research activé — ajoutez votre question puis envoyez.", "bookslm.mode_general": "Général", "bookslm.mode_directory": "Répertoire", "bookslm.mode_documents": "Documents", diff --git a/frontend/style.css b/frontend/style.css index a92cb5d..54cae1c 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -9676,6 +9676,7 @@ body.bookslm-resizing { cursor: ew-resize; user-select: none; } .bookslm-chip-remove:hover { color: #f87171; } .bookslm-chip-thumb { width: 18px; height: 18px; object-fit: cover; border-radius: 4px; } .bookslm-chip-skill { border-color: var(--accent); } +.bookslm-chip-deep-research { border-color: var(--accent); } /* Composer menus (`/` commands and `@` mentions). */ .bookslm-menu-layer { position: relative; } .bookslm-command-menu, .bookslm-mention-menu { position: absolute; left: 12px; right: 12px; bottom: 4px; @@ -9696,10 +9697,10 @@ body.bookslm-resizing { cursor: ew-resize; user-select: none; } white-space: nowrap; } .bookslm-menu-empty { padding: 10px; font-size: 12px; color: var(--text-secondary); text-align: center; } /* Image attach button. */ -.bookslm-btn-plus { display: flex; align-items: center; justify-content: center; flex-shrink: 0; +.bookslm-input-area button.bookslm-btn-plus { display: flex; align-items: center; justify-content: center; flex-shrink: 0; background: none; border: 1px solid var(--border); color: var(--text-secondary); cursor: pointer; border-radius: 50%; width: 32px; height: 32px; padding: 0; } -.bookslm-btn-plus:hover { color: var(--accent); border-color: var(--accent); } +.bookslm-input-area button.bookslm-btn-plus:hover { color: var(--accent); border-color: var(--accent); } .bookslm-file-input { display: none; } .bookslm-files-input { display: none; } /* Extensible "+" panel (#97). */ diff --git a/package.json b/package.json index 6075c07..055323c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.5.2", + "version": "2.6.0", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "main": "patch.js", "directories": { diff --git a/tests/frontend/ai.test.mjs b/tests/frontend/ai.test.mjs index 641217a..8fe4bc4 100644 --- a/tests/frontend/ai.test.mjs +++ b/tests/frontend/ai.test.mjs @@ -1908,6 +1908,31 @@ async function main() { b._panel.remove(); }); + await test("ext menu Deep Research adds a chip instead of composer text", async () => { + localStorage.clear(); + document.body.replaceChildren(); + const b = new BooksLM(); + b._panel = b._render(); + document.body.appendChild(b._panel); + const ta = b._panel.querySelector("textarea"); + ta.value = ""; + b._toggleExtMenu(); + const btn = b._panel.querySelector('.bookslm-ext-item[data-ext-id="deep_research"]'); + assert.ok(btn, "Deep Research entry present in the + panel"); + btn.click(); + await sleep(20); + const chip = b._panel.querySelector(".bookslm-chip-deep-research"); + assert.ok(chip, "a Deep Research chip is added"); + assert.equal(ta.value, "", "the composer stays empty (no injected directive)"); + assert.equal(b._activeDeepResearch, true, "the deep-research flag is set"); + // Removing the chip clears the flag. + chip.querySelector(".bookslm-chip-remove").click(); + assert.ok(!b._panel.querySelector(".bookslm-chip-deep-research"), "the chip is removed"); + assert.equal(b._activeDeepResearch, false, "the flag is cleared with the chip"); + b._panel.remove(); + localStorage.clear(); + }); + // ── Summary ── console.log(`\n${passCount}/${testCount} tests passed`); if (passCount !== testCount) { diff --git a/tests/frontend/sidebar-filters.test.mjs b/tests/frontend/sidebar-filters.test.mjs new file mode 100644 index 0000000..80d1d1a --- /dev/null +++ b/tests/frontend/sidebar-filters.test.mjs @@ -0,0 +1,183 @@ +#!/usr/bin/env node +/** + * ObsiGate - JSDOM tests for the sidebar search/filter bar on the + * "Recent" and "Saved searches" tabs (#99). + * + * Covers: + * - `filterRecentFiles()` (config.js): narrows the recent-files list by + * title / path / vault / preview / tags, case- and accent-insensitively + * - `filterSavedSearches()` (viewer.js): narrows the saved-searches list and + * combines with the type pills, with a "no match" hint when empty + * + * Usage: node tests/frontend/sidebar-filters.test.mjs + */ + +import { strict as assert } from "node:assert"; +import { JSDOM } from "jsdom"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import path from "node:path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const JS_DIR = path.resolve(__dirname, "..", "..", "frontend", "js"); + +const dom = new JSDOM( + ` + + +`, + { url: "http://localhost/", pretendToBeVisual: true }, +); + +const w = dom.window; +globalThis.window = w; +globalThis.document = w.document; +globalThis.HTMLElement = w.HTMLElement; +globalThis.Element = w.Element; +globalThis.Node = w.Node; +globalThis.Event = w.Event; +globalThis.CustomEvent = w.CustomEvent; +globalThis.KeyboardEvent = w.KeyboardEvent; +globalThis.localStorage = w.localStorage; +globalThis.sessionStorage = w.sessionStorage; +Object.defineProperty(globalThis, "navigator", { + value: w.navigator, + configurable: true, + writable: true, +}); +globalThis.MutationObserver = w.MutationObserver; +globalThis.getComputedStyle = w.getComputedStyle.bind(w); +globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 0); +globalThis.cancelAnimationFrame = (id) => clearTimeout(id); + +let testCount = 0; +let passCount = 0; + +async function test(name, fn) { + testCount++; + try { + await fn(); + passCount++; + console.log(` ✓ ${name}`); + } catch (err) { + console.error(` ✗ ${name}\n ${err.message}`); + process.exitCode = 1; + } +} + +const RECENT = [ + { vault: "V", path: "Recettes/Pizza.md", title: "Pizza maison", preview: "pâte", tags: ["cuisine"], mtime_human: "il y a 1 h" }, + { vault: "V", path: "Notes/café.md", title: "Café du matin", preview: "arôme", tags: ["journal"], mtime_human: "il y a 2 h" }, + { vault: "W", path: "Projets/plan.md", title: "Plan de projet", preview: "étapes", tags: [], mtime_human: "hier" }, +]; + +const SAVED = [ + { id: "s1", query: "pizza", vault: "V", include_paths: "Recettes", case_sensitive: false }, + { id: "s2", query: "", include_paths: "Recettes", vault: "V" }, + { id: "s3", query: "roadmap", vault: "all" }, +]; + +globalThis.fetch = async (url) => { + const u = String(url); + if (u.includes("/api/recent")) { + return { ok: true, status: 200, json: async () => ({ files: RECENT }) }; + } + if (u.includes("/api/saved-searches")) { + return { ok: true, status: 200, json: async () => SAVED }; + } + return { ok: true, status: 200, json: async () => ({}) }; +}; + +const configMod = await import(pathToFileURL(path.join(JS_DIR, "config.js")).href); +const viewerMod = await import(pathToFileURL(path.join(JS_DIR, "viewer.js")).href); +const { loadRecentFiles, filterRecentFiles } = configMod; +const { loadSavedSearches, filterSavedSearches } = viewerMod; + +const recentItems = () => Array.from(document.querySelectorAll("#recent-list .recent-item")); +const visibleRecent = () => recentItems().filter((el) => el.style.display !== "none").length; +const savedItems = () => Array.from(document.querySelectorAll(".saved-search-item")); +const visibleSaved = () => savedItems().filter((el) => el.style.display !== "none").length; + +console.log("Sidebar filters (#99) — Recent & Saved searches"); + +await test("loadRecentFiles renders every recent file", async () => { + await loadRecentFiles(null); + assert.equal(recentItems().length, 3, "three recent items rendered"); +}); + +await test("filterRecentFiles narrows by title, case-insensitively", async () => { + filterRecentFiles("pizza"); + assert.equal(visibleRecent(), 1, "one recent item matches 'pizza'"); + filterRecentFiles("PLAN"); + assert.equal(visibleRecent(), 1, "case-insensitive match on 'PLAN'"); +}); + +await test("filterRecentFiles matches accents and tags", async () => { + filterRecentFiles("cafe"); + assert.equal(visibleRecent(), 1, "'cafe' matches 'Café du matin'"); + filterRecentFiles("journal"); + assert.equal(visibleRecent(), 1, "tag match"); +}); + +await test("filterRecentFiles matches the vault and clears", async () => { + filterRecentFiles("Projets"); + assert.equal(visibleRecent(), 1, "path match"); + filterRecentFiles(""); + assert.equal(visibleRecent(), 3, "no query restores everything"); +}); + +await test("filterRecentFiles shows a no-match hint", async () => { + filterRecentFiles("zzz-inexistant"); + assert.equal(visibleRecent(), 0, "no item left"); + assert.ok(document.querySelector("#recent-list .sidebar-filter-empty"), + "a no-match hint is rendered in the recent list"); + filterRecentFiles(""); +}); + +await test("loadSavedSearches renders every saved search", async () => { + await loadSavedSearches(); + assert.equal(savedItems().length, 3, "three saved searches rendered"); +}); + +await test("filterSavedSearches narrows by query text", async () => { + filterSavedSearches("pizza"); + assert.equal(visibleSaved(), 1, "one saved search matches 'pizza'"); + filterSavedSearches("roadmap"); + assert.equal(visibleSaved(), 1, "match on the query field"); +}); + +await test("filterSavedSearches combines with the type pills", async () => { + filterSavedSearches(""); + assert.equal(visibleSaved(), 3, "all visible before the pill filter"); + document.querySelector('.saved-filter-pill[data-filter="directory"]').click(); + assert.equal(visibleSaved(), 1, "only the directory search stays visible"); + filterSavedSearches("roadmap"); + assert.equal(visibleSaved(), 0, "the query excludes the directory search"); + assert.ok(document.querySelector("#saved-searches-list .sidebar-filter-empty"), + "a no-match hint is rendered in the saved list"); + document.querySelector('.saved-filter-pill[data-filter="all"]').click(); + filterSavedSearches(""); + assert.equal(visibleSaved(), 3, "clearing both filters restores everything"); +}); + +// ── Summary ── +console.log(`\n${passCount}/${testCount} tests passed`); +if (passCount !== testCount) { + process.exit(1); +} \ No newline at end of file