Compare commits

...
12 Commits
Author SHA1 Message Date
bruno bab272bb5c fix: desktop — commandes Tauri bloquées par l'ACL et crash heap à l'ouverture (BUG-104, BUG-105)
CI / lint (push) Successful in 2m45s
CI / security (push) Successful in 1m59s
CI / test (push) Successful in 4m44s
CI / build (push) Successful in 1m52s
CI / e2e (push) Successful in 16m16s
2026-10-02 20:17:20 -04:00
bruno 34428ea7b3 test: mobileBlockContaining ignore les commentaires CSS (CI #842)
CI / lint (push) Successful in 2m45s
CI / security (push) Successful in 1m58s
CI / test (push) Successful in 4m41s
CI / build (push) Successful in 1m52s
CI / e2e (push) Successful in 16m32s
- le helper choisissait le premier bloc media contenant la chaine 'mobile-toolbar' ; un commentaire citant ce selecteur (correctif BUG-103) le faisait retomber sur le mauvais bloc, sans la clearance .main-body (BUG-073)
- les commentaires sont maintenant retires avant la recherche de bloc
2026-10-02 15:46:05 -04:00
bruno 83e6a951dd fix: sidebar mobile passe au-dessus de la barre d outils du bas (BUG-103)
CI / lint (push) Failing after 2m11s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 1m58s
- en mobile le sidebar de navigation (z-index 200) passait sous .mobile-toolbar (900, 64 px) : les dernieres entrees de l'arbre etaient masquees
- regle mobile .sidebar : z-index 200 -> 950 ; test de non-regression 'mobile sidebar over toolbar' dans unit.test.mjs
2026-10-02 15:34:35 -04:00
bruno 3d618eb660 ci: relance du workflow apres annulation des runs 839 et 840
CI / lint (push) Successful in 2m44s
CI / security (push) Successful in 1m57s
CI / test (push) Successful in 4m37s
CI / build (push) Successful in 1m51s
CI / e2e (push) Successful in 15m58s
2026-10-02 14:42:18 -04:00
bruno 2add24a9c1 feat: onglet Accueil, menus contextuels de navigation et racine vault #158
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
- clic sur le titre: la page d'accueil s'ouvre dans un onglet Accueil (icone Maison) epingle en tete de barre, reactivable d'un clic (onglet propre au panneau actif en split)
- page de navigation: menus contextuels des repertoires et des fichiers identiques a ceux de la sidebar, clic sur la racine d'un vault qui ouvre la vue, icone dossier a la couleur de l'arbre de navigation
- BUG-101: bloc « Keyboard shortcuts for tabs » duplique dans ui.js (un seul Ctrl+W fermait deux onglets)
- BUG-102: le menu contextuel se refermait 10 ms apres son ouverture (scroll de reflow des icones lucide)
2026-10-02 14:05:01 -04:00
bruno a50227849d feat: onglet de navigation avec facettes, tris et retour Home complet #158
CI / lint (push) Successful in 2m46s
CI / security (push) Successful in 1m57s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
CI / test (push) Canceled after 1h50m30s
- clic sur un repertoire de l'arbre (modes focus vault et All) ouvre un onglet de navigation dedie qui coexiste avec les onglets de fichiers ouverts
- vue: sous-repertoires cliquables, facettes Vaults/Tags/Extensions (meme mecanisme que la recherche), tris Pertinence/Date, bouton Sauver; tags indexes exposes par GET /api/vault/{v}/files
- BUG-100: showWelcome re-injecte la section Raccourcis & Astuces et goHome efface la recherche globale + le filtre de la sidebar
2026-10-02 12:27:27 -04:00
bruno 5ffc9d851a fix: bouton de repli des facettes compact et icone seule qui pivote sur mobile #157
CI / lint (push) Successful in 2m44s
CI / security (push) Successful in 2m1s
CI / test (push) Successful in 4m36s
CI / build (push) Successful in 1m51s
CI / e2e (push) Successful in 15m47s
2026-10-02 08:14:48 -04:00
bruno 1e0e419210 feat: panneau de facettes repliable + facette Extensions exposee par l'API #157
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
CI / lint (push) Canceled after 9s
2026-10-01 23:07:16 -04:00
bruno 2813d546a7 feat: facette Extensions dans les facettes de recherche #157
CI / lint (push) Successful in 2m41s
CI / security (push) Successful in 2m0s
CI / test (push) Successful in 4m45s
CI / build (push) Successful in 3m4s
CI / e2e (push) Successful in 15m36s
2026-10-01 20:35:21 -04:00
bruno 19e94dfa51 chore: retire fichier test webhook
CI / lint (push) Successful in 2m40s
CI / security (push) Successful in 1m53s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
CI / test (push) Canceled after 1s
2026-10-01 14:36:39 -04:00
bruno 49f97fc26e test: webhook apres ALLOWED_HOST_LIST
CI / lint (push) Successful in 2m43s
CI / security (push) Successful in 1m53s
CI / test (push) Successful in 4m35s
CI / e2e (push) Canceled after 0s
CI / build (push) Canceled after 22s
2026-10-01 14:30:55 -04:00
bruno 562290d922 fix: plancher urllib3>=2.8.0 pour CVE-2026-97687/97688/97689 + garde-fou CI
CI / lint (push) Successful in 2m39s
CI / security (push) Successful in 2m5s
CI / test (push) Successful in 4m35s
CI / build (push) Successful in 2m43s
CI / e2e (push) Successful in 15m26s
- backend/requirements.txt : plancher urllib3>=2.8.0 (3 CVE corrigées)
- .gitea/workflows/ci.yml : documentation des nouveaux planchers
- tests/test_ci_workflow.py : garde-fou urllib3 ajouté dans TestDependencySecurityFloors

Corrige l'échec du job security (pip-audit bloquant sur urllib3 2.7.0)
2026-10-01 12:20:46 -04:00
50 changed files with 1771 additions and 354 deletions
+11 -6
View File
@@ -38,6 +38,7 @@ jobs:
- name: Frontend unit tests
run: |
node tests/frontend/unit.test.mjs
node tests/frontend/navfacets.test.mjs
node tests/frontend/image-viewer.test.mjs
node tests/frontend/pdf-viewer.test.mjs
node tests/frontend/forge-completion.test.mjs
@@ -58,6 +59,7 @@ jobs:
node ai.test.mjs
node ai-sidebar.test.mjs
node sidebar-filters.test.mjs
node search-facets.test.mjs
node sw.test.mjs
node collab.test.mjs
node mobile-editor.test.mjs
@@ -78,6 +80,7 @@ jobs:
node ai.test.mjs
node ai-sidebar.test.mjs
node sidebar-filters.test.mjs
node search-facets.test.mjs
node sw.test.mjs
node collab.test.mjs
node mobile-editor.test.mjs
@@ -174,12 +177,14 @@ jobs:
# PYSEC-2026-3910 / PYSEC-2026-3911 (pypdf, DoS de ressources sur
# l'extraction de texte et la lecture d'outlines — donc atteignables
# via backend/pdf_reader.py) sont corrigés par le plancher
# pypdf>=6.16.1 (BUG-093). Ces planchers doivent rester *au-dessus*
# des versions préinstallées dans la toolcache de l'image du runner :
# en dessous, pip répond « already satisfied » et n'aligne jamais
# (c'est exactement ce qui a fait échouer ce job). Le garde-fou
# tests/test_ci_workflow.py::TestDependencySecurityFloors verrouille
# ces planchers.
# pypdf>=6.16.1 (BUG-093).
# CVE-2026-97687 / CVE-2026-97688 / CVE-2026-97689 (urllib3 2.7.0)
# corrigés par le plancher urllib3>=2.8.0.
# Ces planchers doivent rester *au-dessus* des versions préinstallées
# dans la toolcache de l'image du runner : en dessous, pip répond
# « already satisfied » et n'aligne jamais (c'est exactement ce qui a
# fait échouer ce job). Le garde-fou tests/test_ci_workflow.py::
# TestDependencySecurityFloors verrouille ces planchers.
# NOTE runner Gitea Act (BUG-083) : aucun `#` dans le `run:`.
run: pip-audit --ignore-vuln PYSEC-2026-1325
+142 -1
View File
@@ -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.45.1**.
> [Unreleased](#unreleased). La dernière version livrée est **2.49.4**.
---
@@ -14,6 +14,147 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.49.4] — 2026-10-02
---
## [2.49.3] — 2026-10-02
### Corrigé
- **BUG-104 — commandes desktop bloquées par l'ACL Tauri** : la fenêtre desktop sert
sa page depuis `http://127.0.0.1` (origine *remote*) et aucune commande applicative
n'était déclarée — toutes étaient rejetées « not allowed by ACL », erreurs avalées
par `desktop.js:invoke()`. Nouveau manifeste `desktop/permissions/commands.toml`
(`allow-app-commands`, 19 commandes) référencé par la capability, avec un test
garde-fou (`test_frontend_invokes_are_acl_allowed`). Le bouton « Choisir mon
dossier » du wizard et toute la gestion vaults/dossiers desktop fonctionnent.
- **BUG-105 — crash du desktop à l'ouverture (heap corruption `c0000374`)** : le
setter de titre du menu contextuel des raccourcis (#77) confiait un `PROPVARIANT
VT_LPWSTR` sur un buffer heap Rust au property store du shell, qui le libérait avec
l'allocateur Windows — l'application se fermait 2 à 15 s après le lancement. Fonction
supprimée (libellé assuré par `SetDescription`).
---
## [2.49.2] — 2026-10-02
### Corrigé
- Tests : `mobileBlockContaining` ignore les commentaires CSS — un sélecteur cité dans
une annotation ne faisait plus trouver le bon bloc mobile (CI #842).
---
## [2.49.1] — 2026-10-02
### Corrigé
- **Mobile (BUG-103)** : le bas du sidebar de navigation n'est plus masqué par la barre
d'outils du bas — `z-index` du sidebar mobile `200` → `950` (au-dessus de
`.mobile-toolbar`, `900`).
---
## [2.49.0] — 2026-10-02
### Ajouté
- **#158 — onglet Accueil.** Le clic sur le titre ouvre désormais la page d'accueil **dans un
onglet** (icône Maison) systématiquement épinglé **en tête de barre**, réactivable d'un clic
comme un onglet normal (le mode split a son propre onglet Accueil dans le panneau actif).
- **#158 — menus contextuels dans la page de navigation.** Clic droit sur les répertoires et
les fichiers de la vue : même menu que celui de la sidebar (`ContextMenuManager`), et la
ligne d'un vault dans l'arbre ouvre à nouveau la page de navigation (racine du vault).
- **#158 — icône des répertoires alignée sur la sidebar.** La couleur du dossier dans la
section Répertoires est celle de l'arbre (`var(--accent)`).
### Corrigé
- **BUG-102 — menu contextuel refermé par son propre affichage.** L'auto-scroll déclenché par
la reflow des icônes (lucide) à l'ouverture du menu arrivait dans le listener `scroll`
(capture) et refermait le menu ~10 ms après son apparition : un décalage de 250 ms est
ignoré, les vrais défilements ferment toujours le menu.
- **BUG-101 — raccourcis d'onglets déclarés deux fois.** Le bloc « Keyboard shortcuts for
tabs » existed en double dans `ui.js` (fin de fichier) : un seul `Ctrl+W` fermait **deux**
onglets (le second refermait celui qui venait d'être ré-activé) et `Ctrl+Tab` sautait un
onglet. Bloc dupliqué supprimé.
- **#158 — clic titre ne repliait plus l'arborescence.** L'effacement du filtre de la sidebar
n'est déclenché que s'il contenait quelque chose (sinon `restoreSidebarTree()` repliait tout
l'arbre à chaque retour à l'accueil).
---
## [2.48.0] — 2026-10-02
### Ajouté
- **#158 — onglet de navigation.** Chaque clic sur un répertoire de l'arbre (mode focus vault
**et** mode All) ouvre un **onglet de navigation** dédié qui coexiste avec les onglets de
fichiers : la vue chemin + Récents + fichiers n'est plus écrasée par un document ouvert, et
change de répertoire en se mettant à jour dans son onglet. La ligne d'un vault reste un
simple expandeur ; la racine du vault s'atteint depuis la vue (fil d'Ariane, facette Vaults)
ou le sélecteur de vault.
- **#158 — contenu de la page de navigation.** La vue liste désormais les **sous-répertoires
cliquables** et embarque le mécanisme de la page de recherche : panneau de facettes
**Vaults · Tags · Extensions** (repli partagé, filtre local avec pastille active), tri
**Pertinence / Date** et bouton **Sauver** (enregistre le répertoire comme recherche
sauvegardée). `GET /api/vault/{vault}/files` renvoie les `tags` indexés de chaque fichier
(champ `tags` ajouté à `VaultFileEntry`) pour calculer les facettes.
### Corrigé
- **BUG-100 — retour Home incomplet.** Le clic sur le titre perdait la section
**Raccourcis & Astuces** (le template de reconstruction de `showWelcome()` ne la contenait
pas) et laissait la recherche globale ainsi que le filtre de la sidebar actifs : le bloc
`#quick-help` est désormais capturé puis réinjecté, et `goHome()` efface la recherche
(chips, barre de résultats) avant d'afficher l'accueil.
---
## [2.47.1] — 2026-10-02
---
## [2.47.0] — 2026-10-01
### Ajouté
- **#157 — panneau de facettes repliable.** Le bloc **Vaults · Tags · Extensions** des résultats
de recherche se masque/restaure d'un clic sur un bouton discret (chevron), état mémorisé
dans `localStorage` et conservé d'une recherche à l'autre.
### Corrigé
- **#157 — facette « Extensions » absente de l'API.** `SearchFacets` (`backend/schemas.py`)
ne déclarait que `tags` et `vaults` : la clé `extensions` ajoutée par `backend/search.py`
était supprimée par le `response_model` avant d'atteindre le navigateur. Champ ajouté,
plus les tests d'advanced-search pointaient la route inexistante `/api/advanced-search`
et sautaient en silence — ils ciblent désormais `/api/search/advanced`, avec une assertion
stricte sur `facets.extensions`.
- **#157 — bouton de repli des facettes maladroit sur mobile.** En dessous de 768 px, le
panneau passe en colonne et le bouton s'étirait en pleine largeur d'écran, pivotait
entièrement à la vertical au clic. Le bouton reste compact (`align-self: flex-start`) et
**seule l'icône chevron pivote** (transition 120 ms), sur mobile comme sur desktop.
---
## [2.46.0] — 2026-10-01
### Ajouté
- **#157 — facette « Extensions » dans les facettes des résultats de recherche.** Le panneau de
facettes de la page de résultats (Vaults · Tags) gagne un troisième groupe qui liste les
extensions de fichiers présents dans les résultats (`.md`, `.xlsx`, `.pdf`, …) avec leurs
compteurs ; cliquer une extension ajoute l'opérateur `ext:` à la requête (en remplaçant un
`ext:` déjà actif). Le backend calcule `facets.extensions` dans `backend/search.py`.
---
## [2.45.2] — 2026-10-01
---
## [2.45.1] — 2026-10-01
---
+4 -4
View File
@@ -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.45.1-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.49.4-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/)
@@ -75,7 +75,7 @@ Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/G
- **📱 Éditeur mobile natif** — Édition optimisée pour le tactile : barre d'outils Markdown flottante (gras/italique/code/liste/lien), bouton « Coller » persistant (contournement iOS), zoom par pincement et hauteur ajustable, raccourcis swipe (liens entrants / table des matières) et mode lecture plein écran avec navigation entre fichiers ([détail](docs/features/mobile-editor.md))
- **🗺️ Vue graphe interactive** — Canvas force-directed avec Barnes-Hut O(n log n), filtres (tag, type), profondeur, mode focus, historique de navigation ←→↑, export PNG, aperçu au survol (Ctrl+click)
- **🗂️ Multi-vault** : Visualisez plusieurs vaults Obsidian simultanément
- **🌳 Navigation arborescente** : Parcourez vos dossiers et fichiers dans la sidebar
- **🌳 Navigation arborescente** : Parcourez vos dossiers et fichiers dans la sidebar ; chaque clic sur un répertoire de l'arbre ouvre un **onglet de navigation** — chemin, récents, sous-répertoires cliquables, facettes Vaults · Tags · Extensions, tri Pertinence/Date et enregistrement du répertoire — qui coexiste avec vos fichiers ouverts
- **🔍 Recherche avancée** : Moteur TF-IDF avec stemming français, normalisation des accents, snippets surlignés, facettes, pagination et tri — plus une **recherche sémantique** optionnelle (embeddings `all-MiniLM-L6-v2`, fusion hybride TF-IDF + RRF) activable via le toggle `~` ([détail](docs/features/semantic-search.md))
- **💡 Autocomplétion intelligente** : Suggestions de fichiers, tags et historique avec navigation clavier
- **🧩 Syntaxe de requête** : Opérateurs `tag:`, `#`, `vault:`, `title:`, `path:`, `ext:` avec chips visuels
@@ -976,8 +976,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.45.1).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.49.4).
---
*Projet : ObsiGate | Version : 2.45.1 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.49.4 | Dernière mise à jour : Septembre 2026*
+4 -4
View File
@@ -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.45.1-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.49.4-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/)
@@ -74,7 +74,7 @@ Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/):
- **📱 Native Mobile Editor** — Touch-optimised editing: floating Markdown toolbar (bold/italic/code/list/link), persistent Paste button (iOS workaround), pinch-zoom font & adjustable height, swipe shortcuts (backlinks / table of contents) and a full-screen reading mode with page navigation ([details](docs/features/mobile-editor.md))
- **🗺️ Interactive Graph View** — Canvas force-directed with Barnes-Hut O(n log n), filters (tag, type), depth, focus mode, navigation history ←→↑, export PNG, preview on hover (Ctrl+click)
- **🗂️ Multi-vault** : View multiple Obsidian vaults simultaneously
- **🌳 Tree Navigation** : Browse your folders and files in the sidebar
- **🌳 Tree Navigation** : Browse your folders and files in the sidebar; clicking a folder in the tree opens a dedicated **navigation tab** — path, recents, clickable subfolders, Vaults · Tags · Extensions facets, Pertinence/Date sorting and save-as-search — coexisting with your open files
- **🔍 Advanced Search** : TF-IDF search engine with French stemming, accent normalization, highlighted snippets, facets, pagination, and sorting — plus an optional **semantic search** (embeddings via `all-MiniLM-L6-v2`, hybrid TF-IDF + RRF fusion) toggled with `~` ([details](docs/features/semantic-search.md))
- **💡 Smart Autocomplete** : Suggestions for files, tags, and history with keyboard navigation
- **🧩 Query Syntax** : Operators `tag:`, `#`, `vault:`, `title:`, `path:`, `ext:` with visual chips
@@ -1151,8 +1151,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.45.1).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.49.4).
---
*Project: ObsiGate | Version: 2.45.1 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.49.4 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.45.1
2.49.4
+2
View File
@@ -37,3 +37,5 @@ odfpy==1.4.1
python-docx>=1.1
reportlab>=4.0
pillow>=10.0
# Plancher urllib3 >= 2.8.0 (CVE-2026-97687, CVE-2026-97688, CVE-2026-97689)
urllib3>=2.8.0
+3 -1
View File
@@ -646,6 +646,7 @@ class SearchFacets(BaseModel):
tags: dict[str, int] = Field(default_factory=dict)
vaults: dict[str, int] = Field(default_factory=dict)
extensions: dict[str, int] = Field(default_factory=dict, description="Counts per file extension, dotless and lowercase (query-ready for the ext: operator)")
class AdvancedSearchResponse(BaseModel):
@@ -655,7 +656,7 @@ class AdvancedSearchResponse(BaseModel):
total: int = Field(description="Total number of matching results")
offset: int = Field(description="Current pagination offset")
limit: int = Field(description="Page size")
facets: SearchFacets = Field(description="Faceted counts by tag and vault")
facets: SearchFacets = Field(description="Faceted counts by tag, vault and file extension")
query_time_ms: float = Field(default=0, description="Server-side query time in milliseconds")
semantic_available: bool = Field(default=False, description="True when the semantic (embedding) index is ready")
@@ -862,6 +863,7 @@ class VaultFileEntry(BaseModel):
modified_iso: str | None = None
extension: str = ""
rel_dir: str | None = None
tags: list[str] = Field(default_factory=list, description="Tags de l'index (#158)")
class VaultFilesResponse(BaseModel):
+7
View File
@@ -1327,6 +1327,7 @@ def advanced_search(
scored_results: list[tuple[float, dict[str, Any]]] = []
facet_tags: dict[str, int] = defaultdict(int)
facet_vaults: dict[str, int] = defaultdict(int)
facet_extensions: dict[str, int] = defaultdict(int)
# Pre-compute prefix expansions once per term (avoid repeated binary search)
prefix_expansions: dict[str, list[str]] = {}
@@ -1426,6 +1427,11 @@ def advanced_search(
facet_vaults[result["vault"]] = facet_vaults.get(result["vault"], 0) + 1
for tag in result.get("tags", []):
facet_tags[tag] = facet_tags.get(tag, 0) + 1
# Extension normalised without leading dot ("md", not ".md") so it can be
# fed back directly as the `ext:` query operator.
ext = str(result.get("extension") or "").lower().lstrip(".")
if ext:
facet_extensions[ext] = facet_extensions.get(ext, 0) + 1
total = len(scored_results)
page = scored_results[offset: offset + limit]
@@ -1439,6 +1445,7 @@ def advanced_search(
"facets": {
"tags": dict(sorted(facet_tags.items(), key=lambda x: -x[1])[:20]),
"vaults": dict(sorted(facet_vaults.items(), key=lambda x: -x[1])),
"extensions": dict(sorted(facet_extensions.items(), key=lambda x: -x[1])),
},
"query_time_ms": elapsed_ms,
"semantic_available": semantic_available,
+24
View File
@@ -102,6 +102,29 @@ def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
return {"vault": vault_name, "path": path, "items": items}
def _indexed_tags(vault_name: str, rel_path: str) -> list[str]:
"""Tags of a file as stored in the search index (#158).
Empty list when the file is not indexed yet (binary formats, index still
building) — the listing itself comes from the filesystem, tags are a
decoration used by the navigation page facets/filters.
Args:
vault_name: Vault name.
rel_path: Path of the file relative to the vault root (``/`` separated).
Returns:
The file's tags, or an empty list when unknown.
"""
try:
from backend.search import get_inverted_index
info = get_inverted_index().doc_info.get(f"{vault_name}::{rel_path}")
except Exception:
return []
return list((info or {}).get("tags") or [])
def list_all_files(
vault_name: str,
dir: str = "",
@@ -189,6 +212,7 @@ def list_all_files(
"modified": stat.st_mtime,
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
"extension": ext.lstrip(".") if ext else "",
"tags": _indexed_tags(vault_name, rel_path),
}
if rel_to_dir and rel_to_dir != ".":
file_entry["rel_dir"] = rel_to_dir
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.45.1"
version = "2.49.4"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.45.1"
version = "2.49.4"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+2
View File
@@ -38,5 +38,7 @@ fn main() {
println!("cargo:rerun-if-changed=.git/refs/heads/main");
println!("cargo:rerun-if-changed=.git/refs/tags");
println!("cargo:rerun-if-changed=permissions");
tauri_build::build()
}
+1
View File
@@ -7,6 +7,7 @@
},
"permissions": [
"core:default",
"allow-app-commands",
"shell:allow-open",
"shell:allow-execute",
"dialog:default",
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for ObsiGate Desktop","remote":{"urls":["http://127.0.0.1:*","http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","shell:allow-execute","dialog:default","notification:default","fs:default","process:default","store:default"]}}
{"default":{"identifier":"default","description":"Default capabilities for ObsiGate Desktop","remote":{"urls":["http://127.0.0.1:*","http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","allow-app-commands","shell:allow-open","shell:allow-execute","dialog:default","notification:default","fs:default","process:default","store:default"]}}
+6
View File
@@ -2210,6 +2210,12 @@
"Identifier": {
"description": "Permission identifier",
"oneOf": [
{
"description": "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1).",
"type": "string",
"const": "allow-app-commands",
"markdownDescription": "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1)."
},
{
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
"type": "string",
+6
View File
@@ -2210,6 +2210,12 @@
"Identifier": {
"description": "Permission identifier",
"oneOf": [
{
"description": "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1).",
"type": "string",
"const": "allow-app-commands",
"markdownDescription": "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1)."
},
{
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
"type": "string",
+35
View File
@@ -0,0 +1,35 @@
# Commandes applicatives ObsiGate — autorisées depuis l'origine distante.
#
# La fenêtre Tauri redirige vers http://127.0.0.1:<port> (page servie par le
# backend Python). Pour Tauri v2 c'est une origine *remote* : sans entrée ACL
# explicite, TOUTE commande de l'app (invoke_handler) est rejetée avec
# "Command ... not allowed by ACL" — le bouton « Choisir mon dossier » et le
# wizard ne faisaient alors rien (erreurs avalées par desktop.js:invoke()).
#
# Auto-généré en… non : édité à la main. Liste = tauri::generate_handler![…]
# dans src/main.rs — ajouter toute nouvelle commande ici.
[[permission]]
identifier = "allow-app-commands"
description = "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1)."
commands.allow = [
"get_backend_url",
"get_version",
"get_config",
"save_vault_path",
"get_vault_path",
"get_wizard_state",
"complete_wizard",
"pick_vault_folder",
"get_system_theme",
"restart_backend",
"check_backend_health",
"save_window_state",
"get_window_state",
"add_vault",
"remove_vault",
"list_vaults",
"add_dir",
"remove_dir",
"list_dirs",
]
+19 -43
View File
@@ -10,48 +10,23 @@ use log::{info, warn};
#[cfg(target_os = "windows")]
mod imp {
use super::*;
use std::os::windows::ffi::OsStrExt;
use windows::core::{Interface, HSTRING, PCWSTR};
use windows::Win32::Foundation::PROPERTYKEY;
use windows::Win32::System::Com::{
CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED,
};
use windows::Win32::System::Com::StructuredStorage::PROPVARIANT;
use windows::Win32::System::Variant::VT_LPWSTR;
use windows::Win32::UI::Shell::{
ICustomDestinationList, IShellLinkW, SetCurrentProcessExplicitAppUserModelID,
DestinationList, EnumerableObjectCollection, ShellLink,
};
use windows::Win32::UI::Shell::Common::IObjectCollection;
use windows::Win32::UI::Shell::PropertiesSystem::IPropertyStore;
use super::*;
use std::os::windows::ffi::OsStrExt;
use windows::core::{Interface, HSTRING, PCWSTR};
use windows::Win32::System::Com::{
CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED,
};
use windows::Win32::UI::Shell::{
ICustomDestinationList, IShellLinkW, SetCurrentProcessExplicitAppUserModelID,
DestinationList, EnumerableObjectCollection, ShellLink,
};
use windows::Win32::UI::Shell::Common::IObjectCollection;
const APP_ID: &str = "com.obsigate.desktop";
const APP_ID: &str = "com.obsigate.desktop";
// PKEY_Title (System.Title): {F29F85E0-4FF9-1068-AB91-08002B27B3D9}, pid 2
const PKEY_TITLE: PROPERTYKEY = PROPERTYKEY {
fmtid: windows::core::GUID::from_u128(0xF29F85E0_4FF9_1068_AB91_08002B27B3D9),
pid: 2,
};
fn wide_null_terminated(s: &str) -> Vec<u16> {
std::ffi::OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect()
}
/// Set the jump-list display title via the shell link's property store.
fn set_link_title(link: &IShellLinkW, title: &str) -> windows::core::Result<()> {
let store: IPropertyStore = link.cast()?;
let mut wide = wide_null_terminated(title);
let mut pv: PROPVARIANT = unsafe { std::mem::zeroed() };
unsafe {
let inner = &mut *pv.Anonymous.Anonymous;
inner.vt = VT_LPWSTR;
inner.Anonymous.pwszVal = windows::core::PWSTR(wide.as_mut_ptr());
store.SetValue(&PKEY_TITLE, &pv)?;
store.Commit()?;
}
Ok(())
}
fn wide_null_terminated(s: &str) -> Vec<u16> {
std::ffi::OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect()
}
/// Build one shell link for a vault. `arg` is the command-line argument the
/// app expects to open that vault (see file associations / single-instance).
@@ -70,9 +45,10 @@ mod imp {
}
link.SetDescription(&HSTRING::from(name))?;
}
// Title (display name) is best-effort — a missing title only means the
// jump-list entry falls back to the executable name.
let _ = set_link_title(&link, name);
// Title (display name): the raw PROPVARIANT setter that used to live
// here corrupted the process heap (STATUS_HEAP_CORRUPTION c0000374 —
// the shell property store freed our Rust-allocated VT_LPWSTR buffer).
// SetDescription below already gives jump-list entries their label.
Ok(link)
}
+61
View File
@@ -1122,4 +1122,65 @@ mod tests {
let dbg = format!("{:?}", d);
assert!(dbg.contains("x"));
}
/// Guardrail ACL : toute commande Tauri invoquée par le frontend doit
/// figurer dans desktop/permissions/commands.toml. La page servie par le
/// backend (http://127.0.0.1) est une origine *remote* pour Tauri v2 :
/// sans cette entrée, la commande est rejetée « not allowed by ACL » et
/// desktop.js:invoke() avale l'erreur → bouton silencieusement mort
/// (bouton « Choisir mon dossier » du wizard, BUG-104).
#[test]
fn test_frontend_invokes_are_acl_allowed() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let perms = fs::read_to_string(dir.join("permissions/commands.toml"))
.expect("desktop/permissions/commands.toml manquant");
let main_src = fs::read_to_string(dir.join("src/main.rs")).unwrap();
// Commandes déclarées dans le manifeste de permissions.
let allow_list = perms
.split("commands.allow")
.nth(1)
.and_then(|s| s.split(']').next())
.unwrap_or("");
let allowed: Vec<String> = allow_list
.split('"')
.skip(1)
.step_by(2)
.map(|s| s.to_string())
.collect();
assert!(!allowed.is_empty(), "commands.allow vide dans commands.toml");
// Chaque permission doit correspondre à une commande réellement
// enregistrée (faute de frappe → permission morte).
for cmd in &allowed {
assert!(
main_src.contains(&format!("fn {cmd}(")),
"permission allow pour la commande inconnue {cmd}"
);
}
// Toute commande invoke('…') du frontend doit être autorisée.
let js_dir = dir.join("../frontend/js");
let mut seen = 0usize;
for entry in fs::read_dir(&js_dir).unwrap() {
let path = entry.unwrap().path();
if path.extension().and_then(|e| e.to_str()) != Some("js") { continue; }
let src = fs::read_to_string(&path).unwrap();
let mut rest = src.as_str();
while let Some(pos) = rest.find("invoke('") {
rest = &rest[pos + "invoke('".len()..];
let name: String = rest.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
if !name.is_empty() {
seen += 1;
assert!(
allowed.contains(&name),
"commande {name} invoquée par {} absente de permissions/commands.toml",
path.file_name().unwrap().to_string_lossy()
);
}
rest = &rest[rest.find('\'').map(|i| i + 1).unwrap_or(rest.len())..];
}
}
assert!(seen > 0, "aucun invoke('…') trouvé dans frontend/js");
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.45.1",
"version": "2.49.4",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+2 -1
View File
@@ -19,7 +19,8 @@ avec :
- **Normalisation des accents** — `resume` trouve `résumé`, `elephant` trouve `éléphant`.
- **Stemming français** — les variantes des mots sont rapprochées.
- **Snippets surlignés** — les termes trouvés sont mis en `<mark>` dans l'extrait.
- **Facettes** — compteurs par vault et par tag sur les résultats.
- **Facettes** — compteurs par vault, par tag et par extension sur les résultats, panneau
repliable d'un clic (chevron, état mémorisé).
- **Pagination** — 50 résultats par page.
- **Tri** — par pertinence (TF-IDF) ou par date de modification.
- **Chips de filtres** — les filtres actifs apparaissent sous forme de puces retirables.
+13
View File
@@ -207,6 +207,12 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-097* | [🟡 IMPORTANT] Feuille `.xlsm` tronquée : « Charger la suite » échoue en **415** (`GET …/xlsx/sheet` n'accepte que `.xlsx`) alors que le `.xlsm` est éditable | 🟢 corrigé | P1 | ⚙️ backend + 🔌 api | IA | `backend/routers/files_read.py` (`api_file_xlsx_sheet`), `frontend/js/viewer.js` (`wireLazyRows`) | Ouvrir un `.xlsm` de plus de 500 lignes puis cliquer le pied « Charger la suite » (ou approcher du bas du tableau) | `backend/routers/files_read.py` : `GET …/xlsx/sheet` accepte `.xlsx` **et** `.xlsm` (autres formats → 415 inchangé). Tests `TestXlsmEditable::test_sheet_window_is_served_for_xlsm` + `test_sheet_window_still_refuses_other_formats` | Défaut #156 A2. `.xlsm` est servi éditable (`files_read.py:491-512`, pas de `xlsx_readonly`) donc la visionneuse câble le chargement paresseux, mais `api_file_xlsx_sheet` refuse tout ce qui n'est pas `.xlsx` (`files_read.py:272`). Analyse et critères : `docs/features/xlsx-editor-completeness.md` |
| *BUG-098* | [🟡 IMPORTANT] Délimiteur CSV figé `,` : un `.csv` français (`;`) s'affiche en une seule colonne et se réécrit dans un autre format | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/xlsx_reader.py` (`render_csv_table`, `delimiter=","` par défaut), `backend/routers/files_read.py:551`, `backend/services/mutations.py` (`save_csv_cells`) | Ouvrir un CSV `;` (export Excel FR) dans ObsiGate : une seule colonne ; éditer une cellule puis enregistrer : le fichier est réécrit en `,` | `backend/xlsx_reader.py` (`sniff_csv_delimiter`) + `backend/services/mutations.py::save_csv_cells` : délimiteur détecté et **réutilisé**. Tests `TestCsvDelimiter` + lecture/écriture/guillemets point-virgule | Défaut #156 A3. `render_csv_table(raw)` est appelé sans délimiteur et `save_csv_cells()` re-parse en `,`, alors que l'export CSV de la visionneuse écrit en `;`. Traitement prévu : détection `;`/`,`/tab partagée lecture/écriture/export. Analyse : `docs/features/xlsx-editor-completeness.md` |
| *BUG-099* | [🔵 MINEUR] Sonde de perte plafonnée (8 Mo, budget global) : risque de perte **silencieuse** des valeurs calculées en cache au-delà du budget | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/xlsx_reader.py` (`_MAX_PROBE_BYTES`, `_has_cached_formulas`, `inspect_workbook`) | Constituer un classeur dont le XML de feuille dépasse 8 Mo **avant** la première formule cachée, puis l'éditer : la garde 409 `xlsx_lossy_content` ne se déclenche pas | `backend/xlsx_reader.py` : budget **par feuille** (4 Mo) + plafond global (32 Mo) ; `_scan_cached_formulas()` renvoie `(found, unverified)` et `inspect_workbook()` ajoute `cached_values_unverified` (libellé i18n FR/EN). Contre-preuve : budget épuisé → signalé au lieu de `[]`. Tests `TestXlsxCachedValueProbe` (3) | Défaut #156 A4, **analyse statique (non reproduit)**. Le budget est partagé entre toutes les feuilles : au-delà, `cached_values` n'est pas détecté et l'écriture détruit ces valeurs sans avertissement (risque n°1 de #153). Traitement prévu : budget par feuille + signal d'incertitude pour que la garde reste prudente. Analyse : `docs/features/xlsx-editor-completeness.md` |
| *BUG-100* | Retour à l'accueil incomplet : le clic sur le titre perd la section « Raccourcis & Astuces » et laisse la recherche globale ainsi que le filtre de la sidebar actifs | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/viewer.js` (`showWelcome`), `frontend/js/legacy.js` (`goHome`) | Ouvrir un fichier (le dashboard est détruit), puis cliquer le titre ObsiGate | `viewer.js` : `#quick-help` capturé au chargement du module et réinjecté dans le template reconstruit de `showWelcome()` ; `legacy.js::goHome` clique `#search-clear-btn` (chips + barre de résultats) et `#sidebar-filter-clear-btn` avant d'afficher l'accueil | **Cause racine :** `showWelcome()` reconstruit `#dashboard-home` depuis un template qui n'embarque pas le bloc `#quick-help` présent dans `index.html` — il ne disparaissait qu'après la première reconstruction (ouverture de fichier, puis retour). Livré avec #158 A3 |
| *BUG-101* | Raccourcis d'onglets déclarés deux fois : un seul `Ctrl+W` fermait deux onglets (le second fermait celui ré-activé par la fermeture précédente) et `Ctrl+Tab` sautait un onglet | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/ui.js` (bloc « Keyboard shortcuts for tabs » dupliqué en fin de fichier) | Ouvrir deux onglets puis `Ctrl+W` une fois | Suppression du second bloc `document.addEventListener("keydown", …)` dupliqué (fin de `ui.js`) — un seul listener reste | Exposé par #158 : le second onglet n'était plus jamais vide auparavant (la 2ᵉ fermeture tombait sur `_activeTabId === null` et devenait sans effet). Vérifié : repro Playwright (`closes: ["fichier", "nav"]` avant → `["fichier"]` après) + suite E2E |
| *BUG-102* | Menu contextuel qui se referme ~10 ms après son ouverture : la reflow des icônes (remplacement `<i>` → `<svg>` par lucide) déclenche un événement `scroll` capturé par `ContextMenuManager` qui referme le menu | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/ui.js` (`ContextMenuManager.init/show`) | Clic droit sur un fichier de la page de navigation (ou de l'arbre) juste après un rendu d'icônes | `show()` mémorise `_shownAt` ; le listener `scroll` (capture) ignore un déclenchement dans les 250 ms suivant l'ouverture | Exposé par #158 A5 (test E2E `nav-tab.spec.js` : menu résolu « hidden » alors que 6 items étaient construits). Vérifié : instrumentation Playwright (display block conservé après le scroll) + suite E2E |
| *BUG-103* | Mobile : le bas du sidebar de navigation est masqué par la barre d'outils du bas (`z-index` 200 < 900) | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/style.css` (règle `.sidebar` du bloc `@media (max-width: 768px)`) | Vue mobile (≤768 px), sidebar ouvert depuis « Explorateur » | Passé à `z-index: 950` (au-dessus de `.mobile-toolbar`, 900) | Exposé après #158 (retour utilisateur). Vérifié : `elementFromPoint` Playwright (393×851) + test source `mobile sidebar over toolbar` (`unit.test.mjs`) |
| *BUG-104* | Desktop : toutes les commandes Tauri invoquées depuis la page backend (`http://127.0.0.1`) rejetées « not allowed by ACL » — le bouton « Choisir mon dossier » du wizard ne fait rien | 🟢 corrigé | P1 | 🖥️ desktop | IA | `desktop/permissions/commands.toml` (nouveau), `desktop/capabilities/default.json`, `desktop/build.rs` | Installeur 2.49.2 : clic sur « Choisir mon dossier » → aucune fenêtre ; console `[desktop] invoke(pick_vault_folder) failed: Command … not allowed by ACL` (capturée via CDP, WebView2 `--remote-debugging-port`) | Manifeste de permissions applicatives créé (`allow-app-commands`, 19 commandes) et référencé par la capability : Tauri v2 ACL gate toute commande invoquée depuis une origine *remote*, y compris celles de l'`invoke_handler` | `cargo test` 25 passed (nouveau garde-fou `test_frontend_invokes_are_acl_allowed`) + vérif live CDP : `get_version`→2.49.2, clic → fenêtre native « Select Folder », parcours complet ajout de vault terminé |
| *BUG-105* | Desktop : crash 2-15 s après l'ouverture de la fenêtre (`APPCRASH c0000374`, heap corruption détectée dans ntdll) — absent de v2.0.0, apparu en 2.49.2 | 🟢 corrigé | P1 | 🖥️ desktop | IA | `desktop/src/jumplist.rs` (`set_link_title`) | Installeur 2.49.2 : fenêtre visible quelques secondes puis fermeture (3/3), Event Log Windows `APPCRASH obsigate-desktop.exe … c0000374`, rien dans stderr | `set_link_title` (#77) construisait un `PROPVARIANT VT_LPWSTR` pointant un buffer heap Rust et le confiait au property store du shell, qui le libère avec l'allocateur Windows → corruption du tas ; fonction supprimée (`SetDescription` fournit déjà le libellé) | Bisect 3 états (jumplist off = stable, titre off = stable, d'origine = crash) ; run 9 min sans crash + sortie propre `EXIT=0`, zéro événement WER (avant : 3 crashes ≤20 s) |
| | | | | | | | | | |
### TODOs techniques (améliorations / nouvelles tâches)
@@ -309,6 +315,13 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-09-29 | #156 A5-A7 (P1) | Fonctionnalité (sans nouveau défaut) | `frontend/js/viewer.js`, `frontend/js/xlsx/command-bar.js`, `frontend/locales/{fr,en}.json`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md` | **P1 livré** — (A5) presse-papiers de plage : copier/couper/coller un bloc TSV au clavier et au menu contextuel, presse-papiers interne + miroir système, remplissage multi-cellules, insertion **texte** échappée, débordement signalé, annulable ; (A6) clavier complet (`Ctrl+S`/`Ctrl+A`/`Suppr`/`F2`/`Ctrl+Home|End`/`Home|End`/`PgUp|PgDn`/`Ctrl+flèches`/`Maj+Entrée`) ; (A7) zone Nom éditable (« Atteindre ») + liste de fonctions. Vérifié : JSDOM `xlsx-viewer.test.mjs` 84/84 (20 nouveaux), E2E Playwright. | ✅ livré |
| 2026-09-30 | #156 A8-A14 (P2 + P3) — clôture du backlog | Fonctionnalité (sans nouveau défaut) | `backend/services/mutations.py`, `backend/routers/files_read.py`, `backend/routers/files_write.py`, `backend/schemas.py`, `backend/xlsx_reader.py`, `backend/openapi_docs.py`, `backend/tools/{spreadsheets,schemas,labels}.py`, `frontend/js/viewer.js`, `frontend/js/xlsx/command-bar.js`, `frontend/locales/{fr,en}.json`, `frontend/style.css`, `tests/test_xlsx_styles.py`, `tests/test_xlsx_viewer.py`, `tests/test_spreadsheet_tools.py`, `tests/test_xlsx_formats.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md`, `README.md`, `README.fr.md` | **P2 + P3 livrés, backlog #156 clôturé** — (A8) **mise en forme en écriture** : bouton Mise en forme (gras/italique/souligné, alignements, couleurs via sélecteur natif, formats de nombre, fusion/défusion, volets figés, largeur/hauteur) et nouvelle route `PUT …/xlsx/style` (`mutate_xlsx_style` : verrou, backup, swap atomique, garde de perte, `If-Match`) ; (A9) décision « pas de moteur de formule » **annoncée dans l'UI** ; (A10) undo/redo unifié avec piles par fichier conservées au re-rendu ; (A11) export de la sélection + Markdown + HTML + impression et recherche sur **toutes** les feuilles (`n/m · k feuilles`) ; (A12) concurrence optimiste `If-Match` → **409** `conflict`/`stale_revision`, retry qui relit ; (A13) cache de `read_workbook_meta()` par `(chemin, mtime_ns, taille)` (LRU 8) ; (A14) outils IA `.xlsm`/`.csv` + `search_workbook`, `analyze_range`, `edit_xlsx_structure` (confirmation conservée). Vérifié : suite **1525 passed / 6 skipped**, ruff 0, mypy 0 (102 fichiers), JSDOM `xlsx-viewer.test.mjs` 107/107, validate-imports + unit, i18n parity 2355/2355, E2E `xlsx` 10/10 + `Split View` 37 + `XSS` 2. | ✅ livré |
| 2026-09-29 | #156 (audit), BUG-096 → BUG-099 | Enregistrement (audit statique + 1 repro JSDOM) | `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md` (nouveau), `docs/ISSUES_TODOLIST.md`, `CHANGELOG.md` | **Audit de complétude de l'éditeur Excel → ouverture de l'item #156** (P0 défauts · P1 presse-papiers/clavier · P2 mise en forme/calcul/undo · P3 sortie/robustesse/perf) avec fiche dédiée. **4 défauts** enregistrés : BUG-096 (enregistrement `.csv` en `TypeError`, **reproduit en JSDOM** — `Cannot read properties of undefined (reading 'name')`, aucun `PUT …/csv/save`), BUG-097 (lazy-load `.xlsm` → 415), BUG-098 (délimiteur CSV `,` figé vs export `;`), BUG-099 (sonde de perte plafonnée à 8 Mo, risque théorique). Manques fonctionnels recensés : presse-papiers de plage, clavier complet, zone Nom éditable, mise en forme en écriture, calcul, undo/redo unifié, export/impression, concurrence optimiste, cache des métadonnées, outils IA `.xlsm`/`.csv`. **Aucun code modifié** (documentation seule). | 🔴 ouvert (à traiter) |
| 2026-10-02 | #158 (A1-A3), BUG-100 | Fonctionnalité + correction | `frontend/js/navfacets.js` (nouveau), `frontend/js/vaulthome.js`, `frontend/js/ui.js`, `frontend/js/sidebar.js`, `frontend/js/viewer.js`, `frontend/js/legacy.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `backend/services/vaults.py`, `backend/schemas.py`, `tests/test_nav_files.py` (nouveau), `tests/frontend/navfacets.test.mjs` (nouveau), `tests/frontend/unit.test.mjs`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **A1** : `TabManager.openNav(vault, dir)` — un onglet de navigation par vault, ouvert à chaque clic répertoire de l'arbre (mode focus et mode All) sans fermer les onglets de fichiers ; la ligne vault reste un expandeur. **A2** : la vue liste les sous-répertoires cliquables et embarque le panneau de facettes **Vaults · Tags · Extensions** (mêmes classes que la page de recherche), les tris **Pertinence / Date** et le bouton **Sauver** ; `GET /api/vault/{v}/files` expose désormais les `tags` indexés (facettes calculées côté client). **A3 / BUG-100** : `showWelcome()` réinjecte `#quick-help` et `goHome()` efface recherche globale + filtre sidebar. Tests : `test_nav_files.py` 3 passed, `navfacets.test.mjs` 10/10, `unit.test.mjs` 12/12, `validate-imports` 41 modules, ruff/mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-02 | #158 (A4-A6) | Fonctionnalité | `frontend/js/vaulthome.js`, `frontend/js/ui.js`, `frontend/js/pane-manager.js`, `frontend/js/legacy.js`, `frontend/js/sidebar.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/e2e/nav-tab.spec.js` (nouveau), `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/navigation-tab-158.md` | **A4** : `TabManager.openHome()` — le clic sur le titre ouvre la page d'accueil dans un **onglet Accueil** (icône Maison, `common.home` FR/EN) remonté en position 0 à chaque fois ; `deactivate()` conserve cet onglet actif, `pane-manager.js` a son propre `openHome` pour le split. **A5** : menus contextuels répertoires **et** fichiers de la page de navigation via `ContextMenuManager.show()` (avec `stopPropagation()`, le handler global referme sinon le menu) — listes vérifiées identiques à la sidebar ; la ligne d'un vault rouvre la page de navigation. **A6** : icône des répertoires à `var(--accent)` (couleur de l'arbre). **Correctif annexé** : l'effacement du filtre sidebar au retour à l'accueil n'est déclenché que s'il était actif (sinon `restoreSidebarTree()` repliait l'arbre). Vérifié : Playwright jetable 16/16, `nav-tab.spec.js` 4 tests, validate-imports/unit/i18n parity verts. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-02 | BUG-101 | Correction | `frontend/js/ui.js` | **BUG-101 — le bloc « Keyboard shortcuts for tabs » existait en double dans `ui.js`** (fin de fichier) : un seul `Ctrl+W` déclenchait **deux** `close()`, le second refermant l'onglet ré-activé par le premier — observé avec #158 quand un onglet navigation survit à la fermeture du fichier ; `Ctrl+Tab` sautait également un onglet. Bloc dupliqué supprimé (un seul listener). Vérifié : repro Playwright (`close()` appelé 2× puis 1×) et suite E2E. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-02 | BUG-102 | Correction | `frontend/js/ui.js` | **BUG-102 — menu contextuel refermé par sa propre ouverture** : la reflow des icônes lucide à l'affichage déclenche un `scroll` capturé qui refermait le menu ~10 ms après son apparition (constaté sur les fichiers de la page de navigation, #158 A5). `show()` mémorise `_shownAt` et le listener `scroll` ignore un déclenchement dans les 250 ms. Vérifié : instrumentation Playwright + suite E2E (`nav-tab.spec.js`). | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-02 | BUG-103 | Correction | `frontend/style.css` | **BUG-103 — sidebar mobile sous la barre d'outils du bas** : en mobile le `.sidebar` (`z-index: 200`) passait sous `.mobile-toolbar` (`z-index: 900`, 64 px de haut), masquant les dernières entrées de l'arbre ; passé à `950`. Vérifié : `elementFromPoint` Playwright (393×851) + test source `mobile sidebar over toolbar` (`unit.test.mjs`). | 🟢 corrigé |
| 2026-10-02 | BUG-104 | Correction | `desktop/permissions/commands.toml` (nouveau), `desktop/capabilities/default.json`, `desktop/build.rs` | **BUG-104 — commandes Tauri rejetées par l'ACL depuis la page backend** : la fenêtre redirige vers `http://127.0.0.1:<port>`, origine *remote* pour Tauri v2, et aucune commande applicative n'était déclarée → `Command … not allowed by ACL` sur `pick_vault_folder`, `get_wizard_state`, etc. ; les erreurs sont avalées par `desktop.js:invoke()` → bouton du wizard silencieusement inopérant depuis #77. Correctif : manifeste `desktop/permissions/commands.toml` (`allow-app-commands`, 19 commandes) référencé par la capability + `rerun-if-changed=permissions` dans `build.rs`. Vérifié : `cargo test` 25 passed (nouveau garde-fou ACL), CDP live : `get_version` OK, clic → fenêtre native « Select Folder », ajout de vault complété de bout en bout. | 🟢 corrigé |
| 2026-10-02 | BUG-105 | Correction | `desktop/src/jumplist.rs` | **BUG-105 — crash heap (c0000374) à l'ouverture du desktop** : `set_link_title` (#77) passait un `PROPVARIANT VT_LPWSTR` pointant un buffer heap Rust au property store du shell, qui le libère avec l'allocateur Windows → `STATUS_HEAP_CORRUPTION` détectée dans ntdll ≤20 s après le lancement (3/3, Event Log WER, même empreinte de pile). Fonction supprimée : le libellé des entrées vient de `SetDescription`. Vérifié : bisect sur 3 états de build, run 9 min `EXIT=0`, zéro événement WER. | 🟢 corrigé |
---
+37 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.45.1 | **Dernière mise à jour :** 2026-10-01
> **Version :** 2.49.4 | **Dernière mise à jour :** 2026-10-02
> **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)**
@@ -137,6 +137,40 @@
---
## ✅ Terminé — Navigation
### 158. Navigation — vue répertoire en onglet, filtres & tris, retour Home complet
- **Effort :** 1-2 jours | **Impact :** 🟡
- **Statut :** ✅ **livré** — 2026-10-02 (A1 onglet, A2 contenu/filtres/tris, A3 retour Home +
**BUG-100**)
- **Détail de conception :** [features/navigation-tab-158.md](./features/navigation-tab-158.md)
- **Description :** la vue « chemin + Récents + fichiers du répertoire » (vault home) n'apparaît
qu'en mode focus vault, disparaît dès qu'un fichier est ouvert, n'expose ni sous-répertoires ni
filtres, et le clic sur le titre ne redonne pas la vraie page d'accueil (section
**Raccourcis & Astuces** manquante, recherche/filtres non effacés).
- **Sous-tâches :**
- [x] **A1 — onglet de navigation.** Tout clic sur un **répertoire** de l'arbre (en mode focus
vault **et** en mode All) ouvre/actualise un **onglet de navigation** dédié, qui
coexiste avec les onglets de fichiers ouverts. La ligne d'un vault dans l'arbre garde son
rôle d'expansion ; la racine du vault s'atteint depuis la vue (fil d'Ariane, facette
Vaults) ou le sélecteur de vault.
- [x] **A2 — contenu de la vue.** Liste des **sous-répertoires cliquables**, panneau de
**facettes Vaults · Tags · Extensions** (même mécanisme/visuel que la page de recherche),
boutons de tri **Pertinence / Date** et bouton **Sauver**.
- [x] **A3 — retour Home complet.** Clic sur le titre = page d'accueil d'un refresh
(section **Raccourcis & Astuces** restaurée) + effacement de la recherche globale et de
la barre de filtre de la sidebar gauche.
- [x] **A4 — onglet Accueil.** Le clic sur le titre affiche la page d'accueil dans un onglet
(icône Maison) épinglé **en tête de barre**, réactivable à tout moment.
- [x] **A5 — menus contextuels de la page de navigation.** Clic droit identique à la sidebar
sur les répertoires **et** les fichiers de la vue ; la ligne d'un vault dans l'arbre
ouvre la page de navigation (racine du vault).
- [x] **A6 — icône des répertoires** de la section Répertoires à la couleur de l'arbre
(`var(--accent)`).
---
## ✅ Terminé — Tableur Excel (complétude)
### 156. Éditeur Excel — complétude fonctionnelle (presse-papiers, mise en forme, calcul, robustesse)
@@ -304,6 +338,8 @@
| 115 | Viewer — barre d'outils de lecture épinglée au défilement | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
| 117 | Configuration — avatars prédéfinis dans le profil utilisateur (12 images) | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
| 85 | Refonte architecturale — découpage du monolithe (14 routers, `main.py` 4 827 → ~750 lignes), stores JSON verrouillés, rate-limit SQLite optionnel | 2.27.2→2.27.13 | [features/archi-refonte-85.md](./features/archi-refonte-85.md) |
| 157 | Recherche — facette « Extensions » dans les résultats (3ᵉ filtre avec Vaults et Tags) + panneau repliable | 2.46.0→2.47.0 | [archive](./archive/COMPLETED_v1-v2.md) |
| 158 | Navigation — clic répertoire → **onglet de navigation** (sous-répertoires, facettes Vaults/Tags/Extensions, tri Pertinence/Date, Sauver), **onglet Accueil**, menus contextuels + retour Home complet (**BUG-100** → **BUG-102**) | 2.48.0→2.49.0 | [features/navigation-tab-158.md](./features/navigation-tab-158.md) |
---
+18
View File
@@ -419,6 +419,24 @@ première modification et téléchargement du fichier d'origine.
---
## #157 — Recherche : facette « Extensions » ✅ TERMINÉ
Le panneau de facettes de la page de résultats de recherche affiche désormais **trois** groupes :
**Vaults**, **Tags** et **Extensions**. Le troisième liste les extensions de fichiers présents
dans le résultat courant (`.md`, `.xlsx`, `.pdf`, …) avec leur compteur ; un clic ajoute
l'opérateur `ext:<type>` à la requête (un `ext:` déjà actif est remplacé, jamais dupliqué).
L'ensemble du panneau se replie/rouvre d'un clic sur un bouton discret (chevron), état
mémorisé dans `localStorage`.
| Aspect | Détail |
|---|---|
| Backend | `backend/search.py::advanced_search` — `facets.extensions` : compteurs par extension normalisée (sans point, en minuscules), triés par fréquence · **`backend/schemas.py::SearchFacets`** expose le champ (sinon le `response_model` le supprimait — corrigé après livraison de 2.46.0) |
| Frontend | `frontend/js/search.js::renderAdvancedSearchResults` — groupe `[data-facet="extensions"]`, items montés en `textContent` (les suffixes viennent de noms de fichiers non fiables) ; panneau repliable via `.search-facets__toggle` + `data-collapsed` (état `obsigate_facets_collapsed`) |
| i18n / aide | `help.desc_facet_extensions`, `search.facets_collapse`/`_expand` (FR/EN), guide `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md` |
| Tests | `tests/test_search.py::test_facets` (route réelle `/api/search/advanced`, assertion stricte `facets.extensions`), `tests/frontend/search-facets.test.mjs` (5 tests JSDOM, ajoutés au CI), e2e `obsigate.spec.ts` (facette visible + repli/rouverture) |
---
## Grosses fonctionnalités — fiches dédiées
| # | Feature | Version | Fiche |
+72
View File
@@ -0,0 +1,72 @@
# #158 — Navigation : vue répertoire en onglet, filtres & tris, retour Home complet
> **Statut :** livré le 2026-10-02 · **ID :** `#158` (correctif associé : `BUG-100`)
> **Fichiers :** `frontend/js/vaulthome.js`, `frontend/js/ui.js`, `frontend/js/sidebar.js`,
> `frontend/js/navfacets.js` (nouveau), `frontend/js/viewer.js`, `frontend/js/legacy.js`,
> `backend/services/vaults.py`, `backend/schemas.py`, `frontend/style.css`,
> `frontend/locales/{fr,en}.json`
## Problème
La vue « chemin + Récents + fichiers du répertoire » (vault home) ne s'affichait qu'en mode
focus vault, disparaissait dès qu'un fichier était ouvert (aucun onglet), ne listait pas les
sous-répertoires, n'avait ni filtre ni tri, et le clic sur le titre ne rendait pas la vraie
page d'accueil.
## Conception
**Un onglet de navigation par vault.** `TabManager.openNav(vault, dir)` (`frontend/js/ui.js`)
crée/rétarget l'onglet `nav::<vault>` (icône dossier) ; `TabManager.activate()` branche sur
`cache.nav` et rend `showVaultHome(vault, dir)` au lieu de fetcher un fichier. Les onglets de
fichiers ne sont jamais fermés. Le dashboard appelle `TabManager.deactivate()` : aucun onglet
ne reste « actif », donc un clic suivant ré-éffectue réellement l'onglet (avant : early-return
et vue figée).
Points d'entrée des clics (`frontend/js/sidebar.js`) : clic **répertoire** dans l'arbre (2 sites,
mode focus et mode All), clic sur la **racine d'un vault** (2 sites, expansion + navigation),
changement de contexte vault (sélecteur), facettes/filtres et fil d'Ariane de la vue
(`vaulthome.js` route tout par `openNav`, plus aucune délégation sur l'arbre).
**Menus contextuels** : les répertoires et les fichiers de la vue appellent le même
`ContextMenuManager.show()` que la sidebar, avec `stopPropagation()` — le handler global de
`ContextMenuManager` referme le menu sur tout clic droit hors `.tree-item`.
**Onglet Accueil** (`ui.js::openHome` + `pane-manager.js::openHome`) : le clic sur le titre
crée (ou remonte en position 0) l'onglet `home` — icône Maison, libellé i18n `common.home` —
et l'active ; `activate()` rend la page d'accueil via `showWelcome()` et `deactivate()`
conserve l'onglet Accueil actif (il *est* la page d'accueil). En mode split, l'onglet vit dans
le panneau actif (délégation `getActiveTabManager()`, libellé transmis par l'appelant pour ne
pas d'importer `t` dans `pane-manager.js`, qui déclare déjà des variables locales `t`).
**Contenu de la vue** (`vaulthome.js`) : fil d'Ariane · barre **Pertinence / Date** + **Sauver**
· panneau de facettes · **Répertoires** cliquables (`GET /api/browse`) · Récents (racine) ·
fichiers (`GET /api/vault/{v}/files?recursive=false`). Les facettes réutilisent les classes de
la page de recherche (`search-facets`, `search-sort`, `search-save-btn`) — même visuel, même
repli `obsigate_facets_collapsed`, filtrage **local** (pas de nouvelle requête).
**Facettes** (`frontend/js/navfacets.js`, pur, sans DOM) : `buildNavFacets`,
`filterNavFiles`, `sortNavFiles`. Le listing embarque les tags indexés :
`GET /api/vault/{v}/files` renvoie `tags` (`_indexed_tags()` dans
`backend/services/vaults.py`, lookup `vault::path` dans l'index inversé, `[]` si non indexé).
## Décisions / limites connues
- **Un seul onglet de navigation par vault** : cliquer un autre répertoire re-cible le même
onglet (pas d'empilement d'onglets). En mode split, la vue s'affiche dans la zone principale.
- **« Pertinence » = tri alphabétique** (`ponytail:` commenté) : un listing de répertoire n'a
aucun signal de pertinence tant que la vue n'a pas sa propre requête — voir
`navfacets.js::sortNavFiles`.
- Facettes calculées sur la liste courante (répertoire non récursif, 200 fichiers max) : les
sous-répertoires non listés n'apportent aucune compte.
## Tests
- `tests/test_nav_files.py` (3) : `tags` exposés + survivent au `response_model`, tags en
sous-répertoire, miss d'index → `[]`.
- `tests/frontend/navfacets.test.mjs` (10) : comptages, filtres tag/ext (insensible au point),
combinaison ET, tris, non-mutation.
- `tests/frontend/unit.test.mjs` : `navfacets.js` ajouté à la liste des modules purs.
- `tests/e2e/nav-tab.spec.js` (4) : racine vault → page de navigation, icône + menus
contextuels identiques à la sidebar, onglet Accueil en tête de barre (avec `#quick-help`).
- Vérification manuelle Playwright (jetable) : 22/22 puis 16/16 checks sur les lots A1-A3 et
A4-A6.
+4
View File
@@ -3694,6 +3694,10 @@
<strong>Facettes vaults</strong><span data-i18n="help.desc_facet_vaults">: Compteurs
par vault</span>
</li>
<li>
<strong>Facettes extensions</strong><span data-i18n="help.desc_facet_extensions">:
Compteurs par extension de fichier dans les résultats</span>
</li>
<li>
<strong>Tri par pertinence</strong><span data-i18n="help.desc_9fec424d"> : Score
TF-IDF (défaut)</span></li>
+14 -1
View File
@@ -288,6 +288,14 @@ function initSearch() {
}
function goHome() {
// #158 — a refresh-equivalent home: wipe the global search (chips, result
// bar, filters) and the sidebar filter bar before showing the dashboard.
const searchClear = document.getElementById("search-clear-btn");
if (searchClear) searchClear.click();
const sideInput = document.getElementById("sidebar-filter-input");
const sideClear = document.getElementById("sidebar-filter-clear-btn");
if (sideClear && sideInput && sideInput.value) sideClear.click();
const searchInput = document.getElementById("search-input");
if (searchInput) searchInput.value = "";
@@ -299,7 +307,12 @@ function goHome() {
state.cachedRawSource = null;
closeMobileSidebar();
showWelcome();
// #158 — la page d'accueil s'ouvre dans un onglet épinglé en tête de barre
if (window.TabManager && window.TabManager.openHome) {
window.TabManager.openHome();
} else {
showWelcome();
}
}
// =========================================================================
+77
View File
@@ -0,0 +1,77 @@
/**
* Pure helpers for the navigation page facets / filters (#158).
*
* No DOM, no imports — usable straight from Node tests.
* Mirrors the search page's facet mechanism (Vaults / Tags / Extensions)
* over a plain directory listing instead of a search result set.
*/
/** Strip the leading dot and lowercase a file extension. */
function normExt(extension) {
return String(extension || "").toLowerCase().replace(/^\./, "");
}
/** Display name used for labels and alphabetical sorting (.md removed). */
export function navDisplayName(file) {
const name = file.name || String(file.path || "").split("/").pop() || "";
return name.endsWith(".md") ? name.slice(0, -3) : name;
}
/**
* Count vaults / tags / extensions over a file listing.
* @param {Array<object>} files
* @returns {{vaults: object, tags: object, extensions: object}} counts sorted desc
*/
export function buildNavFacets(files) {
const facets = { vaults: {}, tags: {}, extensions: {} };
for (const f of files || []) {
if (f.vault) facets.vaults[f.vault] = (facets.vaults[f.vault] || 0) + 1;
const ext = normExt(f.extension);
if (ext) facets.extensions[ext] = (facets.extensions[ext] || 0) + 1;
for (const tag of f.tags || []) {
facets.tags[tag] = (facets.tags[tag] || 0) + 1;
}
}
for (const key of Object.keys(facets)) {
facets[key] = Object.fromEntries(
Object.entries(facets[key]).sort((a, b) => b[1] - a[1]),
);
}
return facets;
}
/**
* Keep only the files matching the active tag / extension filters.
* @param {Array<object>} files
* @param {{tag?: string, ext?: string}} filters
*/
export function filterNavFiles(files, filters) {
const tag = (filters && filters.tag) || "";
const ext = normExt((filters && filters.ext) || "");
if (!tag && !ext) return files || [];
return (files || []).filter((f) => {
if (ext && normExt(f.extension) !== ext) return false;
if (tag && !(f.tags || []).includes(tag)) return false;
return true;
});
}
/**
* Sort the listing: `modified` (newest first, server default) or
* `relevance` (A→Z).
*
* ponytail: a directory listing carries no relevance signal, `relevance` is an
* alphabetical stand-in — upgrade path: sort by scoped search score once the
* navigation page owns a query.
*/
export function sortNavFiles(files, mode) {
const out = (files || []).slice();
if (mode === "relevance") {
out.sort((a, b) =>
navDisplayName(a).localeCompare(navDisplayName(b), undefined, { sensitivity: "base" }),
);
} else {
out.sort((a, b) => (b.modified || 0) - (a.modified || 0));
}
return out;
}
+23
View File
@@ -105,6 +105,20 @@ function createPaneTabManager(paneId) {
this.activate(tabId);
},
async openHome(name = "Home") {
const tabId = "home";
const idx = this._tabs.findIndex((x) => x.id === tabId);
if (idx > 0) {
const [tab] = this._tabs.splice(idx, 1);
this._tabs.unshift(tab);
} else if (idx === -1) {
this._tabs.unshift({ id: tabId, vault: null, path: "", name, icon: "home", home: true });
this._tabCache[tabId] = { home: true, vault: null, path: "", title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon: "home" };
}
this._renderTabs();
this.activate(tabId);
},
async openPersistent(vault, path) {
const tabId = `${vault}::${path}`;
const previewTab = this._tabs.find(t => t.id === tabId && t.preview);
@@ -166,6 +180,15 @@ function createPaneTabManager(paneId) {
const cache = this._tabCache[tabId];
if (!cache) return;
// Onglet Accueil (#158) — la page d'accueil vit dans l'onglet
if (cache.home) {
state.currentVault = null;
state.currentPath = null;
this._showDashboard();
if (history.pushState) history.pushState(null, '', '#');
return;
}
state.currentVault = cache.vault;
state.currentPath = cache.path;
// syncActiveFileTreeItem is imported below to avoid circular deps
+50 -4
View File
@@ -1034,8 +1034,27 @@ export function renderAdvancedSearchResults(data, query, tagFilter) {
}
// Facets panel
if (data.facets && (Object.keys(data.facets.tags || {}).length > 0 || Object.keys(data.facets.vaults || {}).length > 0)) {
const facetsDiv = el("div", { class: "search-facets" });
if (data.facets && (Object.keys(data.facets.tags || {}).length > 0 || Object.keys(data.facets.vaults || {}).length > 0 || Object.keys(data.facets.extensions || {}).length > 0)) {
const collapsed = localStorage.getItem("obsigate_facets_collapsed") === "1";
const facetsDiv = el("div", { class: "search-facets", "data-collapsed": collapsed ? "true" : "false" });
// Discreet collapse toggle for the whole panel (vaults + tags + extensions)
const toggleBtn = el("button", {
class: "search-facets__toggle",
type: "button",
"aria-expanded": String(!collapsed),
title: collapsed ? t("search.facets_expand") : t("search.facets_collapse"),
});
toggleBtn.innerHTML = '<i data-lucide="chevron-down" style="width:12px;height:12px"></i>';
toggleBtn.addEventListener("click", () => {
const nowCollapsed = facetsDiv.getAttribute("data-collapsed") !== "true";
facetsDiv.setAttribute("data-collapsed", nowCollapsed ? "true" : "false");
toggleBtn.setAttribute("aria-expanded", String(!nowCollapsed));
toggleBtn.title = nowCollapsed ? t("search.facets_expand") : t("search.facets_collapse");
localStorage.setItem("obsigate_facets_collapsed", nowCollapsed ? "1" : "0");
});
facetsDiv.appendChild(toggleBtn);
const facetsBody = el("div", { class: "search-facets__body" });
// Vault facets
const vaultFacets = data.facets.vaults || {};
@@ -1056,7 +1075,7 @@ export function renderAdvancedSearchResults(data, query, tagFilter) {
});
group.appendChild(item);
}
facetsDiv.appendChild(group);
facetsBody.appendChild(group);
}
// Tag facets
@@ -1075,9 +1094,36 @@ export function renderAdvancedSearchResults(data, query, tagFilter) {
});
group.appendChild(item);
}
facetsDiv.appendChild(group);
facetsBody.appendChild(group);
}
// Extension facets
const extFacets = data.facets.extensions || {};
if (Object.keys(extFacets).length > 0) {
const group = el("div", { class: "search-facets__group", "data-facet": "extensions" });
const label = el("span", { class: "search-facets__label" });
label.textContent = "Extensions";
group.appendChild(label);
for (const [extName, count] of Object.entries(extFacets)) {
const item = el("span", { class: "search-facets__item" });
// textContent: extension suffixes come from filenames (untrusted), never innerHTML
item.appendChild(document.createTextNode(`.${extName} `));
const facetCount = el("span", { class: "facet-count" });
facetCount.textContent = String(count);
item.appendChild(facetCount);
item.addEventListener("click", () => {
const input = document.getElementById("search-input");
// Replace any existing ext: operator, then trigger a new search
const current = input.value.replace(/ext:\S+\s*/gi, "").trim();
input.value = current + " ext:" + extName;
_triggerAdvancedSearch(input.value);
});
group.appendChild(item);
}
facetsBody.appendChild(group);
}
facetsDiv.appendChild(facetsBody);
area.appendChild(facetsDiv);
}
+9 -5
View File
@@ -43,10 +43,12 @@ async function setSelectedVaultContext(vaultName, options) {
loadRecentFiles(vaultName === "all" ? null : vaultName);
}
showWelcome();
// If a specific vault is selected, switch to vault home page
// Onglet de navigation (#158) si un vault précis est sélectionné,
// sinon la page d'accueil. Les onglets de fichiers restent ouverts.
if (vaultName !== "all") {
import('./vaulthome.js').then(m => m.showVaultHome(vaultName, ''));
TabManager.openNav(vaultName, "");
} else {
showWelcome();
}
if (options && options.focusVault && vaultName !== "all") {
await focusVaultInSidebar(vaultName);
@@ -142,7 +144,7 @@ async function refreshSidebarForContext() {
vaultsToShow.forEach((v) => {
const vaultItem = el("div", { class: "tree-item vault-item", "data-vault": v.name }, [icon("chevron-right", 14), getVaultIcon(v.name, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(v.name)]), smallBadge(v.file_count)]);
vaultItem.addEventListener("click", () => toggleVault(vaultItem, v.name));
vaultItem.addEventListener("click", () => { toggleVault(vaultItem, v.name); TabManager.openNav(v.name, ""); });
vaultItem.addEventListener("contextmenu", (e) => {
e.preventDefault();
@@ -239,7 +241,7 @@ async function loadVaults() {
vaults.forEach((v) => {
// Sidebar tree entry
const vaultItem = el("div", { class: "tree-item vault-item", "data-vault": v.name }, [icon("chevron-right", 14), getVaultIcon(v.name, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(v.name)]), smallBadge(v.file_count)]);
vaultItem.addEventListener("click", () => toggleVault(vaultItem, v.name));
vaultItem.addEventListener("click", () => { toggleVault(vaultItem, v.name); TabManager.openNav(v.name, ""); });
vaultItem.addEventListener("contextmenu", (e) => {
e.preventDefault();
@@ -341,6 +343,7 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) {
fragment.appendChild(subContainer);
dirItem.addEventListener("click", async () => {
TabManager.openNav(vaultName, item.path);
scrollTreeItemIntoView(dirItem, false);
if (subContainer.classList.contains("collapsed")) {
if (subContainer.children.length === 0) {
@@ -635,6 +638,7 @@ async function loadDirectory(vaultName, dirPath, container) {
fragment.appendChild(subContainer);
dirItem.addEventListener("click", async () => {
TabManager.openNav(vaultName, item.path);
scrollTreeItemIntoView(dirItem, false);
if (subContainer.classList.contains("collapsed")) {
if (subContainer.children.length === 0) {
+94 -29
View File
@@ -1776,7 +1776,12 @@ export const ContextMenuManager = {
if (e.key === 'Escape') this.hide();
});
document.addEventListener('scroll', () => this.hide(), true);
document.addEventListener('scroll', () => {
// Un scroll déclenché juste après l'ouverture vient de la reflow des
// icônes (lucide) : on ne referme pas le menu qui vient d'apparaître.
if (performance.now() - (this._shownAt || 0) < 250) return;
this.hide();
}, true);
},
show(x, y, vault, path, type, isReadonly) {
@@ -1822,6 +1827,7 @@ export const ContextMenuManager = {
}
this._menu.classList.add('active');
this._shownAt = performance.now();
const rect = this._menu.getBoundingClientRect();
const viewportWidth = window.innerWidth;
@@ -2105,6 +2111,70 @@ export const TabManager = {
this.activate(tabId);
},
/** Open (or retarget) the navigation tab for a vault directory (#158).
* One navigation tab per vault: clicking another directory reuses it and
* updates its directory, so open file tabs are never displaced. */
async openNav(vault, dir = "") {
if (!vault || vault === "all") return;
dir = dir || "";
const tabId = `nav::${vault}`;
const name = dir ? dir.split("/").pop() : vault;
const existing = this._tabs.find(t => t.id === tabId);
if (existing) {
existing.path = dir;
existing.name = name;
this._tabCache[tabId].path = dir;
this._tabCache[tabId].title = name;
// Force a re-render when it is already the active tab
if (this._activeTabId === tabId) this._activeTabId = null;
this._renderTabs();
this.activate(tabId);
return;
}
this._tabs.push({ id: tabId, vault, path: dir, name, icon: "folder", nav: true });
this._tabCache[tabId] = { vault, path: dir, title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon: "folder", nav: true };
this._renderTabs();
this.activate(tabId);
},
/** Onglet Accueil (#158) : la page d'accueil vit dans un onglet épinglé tout
* à gauche de la barre. Recréé (et remonté en position 0) à chaque clic sur
* le titre. */
async openHome() {
// En split, l'onglet appartient au panneau actif
if (window.getActiveTabManager && window.PaneManager && window.PaneManager.isSplit()) {
return window.getActiveTabManager().openHome(t("common.home"));
}
const tabId = "home";
const idx = this._tabs.findIndex(t => t.id === tabId);
if (idx > 0) {
const [tab] = this._tabs.splice(idx, 1);
this._tabs.unshift(tab); // toujours en tête de barre
} else if (idx === -1) {
const name = t("common.home");
this._tabs.unshift({ id: tabId, vault: null, path: "", name, icon: "home", home: true });
this._tabCache[tabId] = { home: true, vault: null, path: "", title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon: "home" };
}
this._renderTabs();
this.activate(tabId);
},
/** Drop the active-tab marker — the dashboard/home page is showing.
* Keeps the tabs open; clicking one re-activates (and re-renders) it.
* L'onglet Accueil, lui, reste l'onglet actif quand la page d'accueil
* s'affiche (il EST la page d'accueil). */
deactivate() {
const active = this._activeTabId && this._tabCache[this._activeTabId];
if (active && active.home) {
this._renderTabs();
return;
}
this._activeTabId = null;
this._renderTabs();
},
/** Activate a specific tab */
async activate(tabId) {
if (this._activeTabId === tabId && this._tabs.length > 0) return;
@@ -2125,6 +2195,29 @@ export const TabManager = {
const cache = this._tabCache[tabId];
if (!cache) return;
// Onglet Accueil (#158) — la page d'accueil vit dans l'onglet
if (cache.home) {
state.currentVault = null;
state.currentPath = null;
showWelcome(); // conserve l'onglet Accueil actif (deactivate())
if (history.pushState) {
history.pushState(null, "", "#");
}
return;
}
// Navigation tab — render the directory view instead of fetching a file
if (cache.nav) {
state.currentVault = cache.vault;
state.currentPath = cache.path || null;
const navMod = await import("./vaulthome.js");
await navMod.showVaultHome(cache.vault, cache.path);
if (history.pushState) {
history.pushState(null, "", `#/nav/${encodeURIComponent(cache.vault)}/${encodeURIComponent(cache.path)}`);
}
return;
}
// Update global state
state.currentVault = cache.vault;
state.currentPath = cache.path;
@@ -2509,32 +2602,4 @@ export const TabManager = {
// ---- Keyboard shortcuts for tabs ----
document.addEventListener("keydown", (e) => {
if (e.ctrlKey || e.metaKey) {
if (e.key === "w" || e.key === "W") {
e.preventDefault();
if (TabManager._activeTabId) {
TabManager.close(TabManager._activeTabId);
}
} else if (e.key === "Tab" && !e.shiftKey) {
e.preventDefault();
const tabs = TabManager._tabs;
const currentIdx = tabs.findIndex(t => t.id === TabManager._activeTabId);
if (currentIdx >= 0 && tabs.length > 1) {
const nextIdx = (currentIdx + 1) % tabs.length;
TabManager.activate(tabs[nextIdx].id);
}
} else if (e.key === "Tab" && e.shiftKey) {
e.preventDefault();
const tabs = TabManager._tabs;
const currentIdx = tabs.findIndex(t => t.id === TabManager._activeTabId);
if (currentIdx >= 0 && tabs.length > 1) {
const prevIdx = (currentIdx - 1 + tabs.length) % tabs.length;
TabManager.activate(tabs[prevIdx].id);
}
}
}
});
// ---- Modify init to include TabManager ----
+430 -215
View File
@@ -1,24 +1,27 @@
/**
* Vault Home Page module for ObsiGate.
* Vault Home / navigation page module for ObsiGate (#158).
*
* When a specific vault is selected (not "All"), replaces the dashboard
* with a non-recursive file listing sorted by modification time (newest first).
* Only files from the selected directory are shown (not subdirectories)
* to keep the listing fast and lightweight.
* The listing updates automatically when browsing into subdirectories.
* The view is mounted by `TabManager.openNav(vault, dir)` as a dedicated
* **navigation tab**: it opens on every directory click (vault root included,
* vault focus mode *and* "All" mode), coexists with open file tabs, and
* re-renders when the user drills into a subdirectory or the breadcrumb.
*
* The visual style mirrors the search results (.search-result-item) for
* consistency.
* Content: breadcrumb, sort/save toolbar, facets panel (Vaults · Tags ·
* Extensions — same mechanism and look as the search page), subdirectory list,
* recent files (root only) and the non-recursive file listing of the current
* directory.
*
* Usage:
* import { showVaultHome, updateVaultHomeDir, isVaultHomeActive } from './vaulthome.js';
* import { showVaultHome, isVaultHomeActive } from './vaulthome.js';
* // …but in practice: TabManager.openNav(vault, dirPath)
*/
import { api } from './auth.js';
import { el } from './viewer.js';
import { getFileIcon, safeCreateIcons } from './utils.js';
import { TabManager } from './ui.js';
import { TabManager, ContextMenuManager } from './ui.js';
import { t, getLocale } from './i18n.js';
import { buildNavFacets, filterNavFiles, sortNavFiles, navDisplayName } from './navfacets.js';
// ---------------------------------------------------------------------------
// State
@@ -27,25 +30,35 @@ import { t, getLocale } from './i18n.js';
const state = {
active: false,
currentVault: null,
currentDir: '', // empty = vault root
currentDir: '', // empty = vault root
sort: 'modified', // 'modified' (Date) | 'relevance' (Pertinence)
filterTag: '',
filterExt: '',
files: [], // last listing, unfiltered
dirs: [], // subdirectories of the current directory
};
// Handles of the mounted view (only one navigation view exists at a time).
let _view = null;
let _renderSeq = 0;
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/** Check if vault home is currently displayed. */
/** Check if the navigation view is currently displayed. */
export function isVaultHomeActive() {
return state.active;
}
/**
* Display the vault home page for a vault directory.
* Display the navigation view for a vault directory.
* Called by TabManager when the navigation tab is activated.
* @param {string} vaultName - Vault name
* @param {string} dirPath - Directory path (empty for root)
*/
export async function showVaultHome(vaultName, dirPath = '') {
if (vaultName === 'all' || !vaultName) {
if (!vaultName || vaultName === 'all') {
state.active = false;
return;
}
@@ -53,6 +66,9 @@ export async function showVaultHome(vaultName, dirPath = '') {
state.active = true;
state.currentVault = vaultName;
state.currentDir = dirPath || '';
state.filterTag = '';
state.filterExt = '';
state.sort = 'modified';
const area = document.getElementById('content-area');
if (!area) return;
@@ -61,7 +77,7 @@ export async function showVaultHome(vaultName, dirPath = '') {
const home = document.getElementById('dashboard-home');
if (home) home.remove();
// Build vault home container
const seq = ++_renderSeq;
area.innerHTML = '';
const container = el('div', { class: 'vault-home', id: 'vault-home' });
@@ -70,117 +86,412 @@ export async function showVaultHome(vaultName, dirPath = '') {
// Breadcrumb
renderBreadcrumb(container, vaultName, state.currentDir);
// Sort + save toolbar (same classes as the search results header)
container.appendChild(buildToolbar());
// Facets host (filled once the listing is loaded)
const facetsHost = el('div', { id: 'vault-home-facets' });
container.appendChild(facetsHost);
// Results container
const resultsArea = el('div', { class: 'vault-home-results', id: 'vault-home-results' });
container.appendChild(resultsArea);
// Show loading
resultsArea.innerHTML = `
<div class="tree-loading" style="padding:2rem;text-align:center">
<div class="loading-spinner" style="width:24px;height:24px;border-width:3px;margin:0 auto"></div>
<p data-i18n="vaulthome.loading" style="margin-top:0.75rem;color:var(--text-muted);font-size:0.85rem">${t('vaulthome.loading')}</p>
</div>`;
safeCreateIcons();
await loadAndRender(vaultName, state.currentDir, resultsArea);
_view = { container, facetsHost, resultsArea, vault: vaultName, dirs: [] };
// Also load recent files for this vault (only at root level)
if (!state.currentDir) {
loadRecentSection(vaultName, container);
}
}
/**
* Update the vault home directory without rebuilding the whole page.
* Called when navigating into a subdirectory.
* @param {string} vaultName
* @param {string} dirPath
*/
export async function updateVaultHomeDir(vaultName, dirPath) {
if (!state.active || state.currentVault !== vaultName) return;
state.currentDir = dirPath || '';
const container = document.getElementById('vault-home');
if (!container) return;
// Update breadcrumb
const bc = container.querySelector('.vault-home-breadcrumb');
if (bc) {
bc.innerHTML = '';
buildBreadcrumbHTML(bc, vaultName, state.currentDir);
safeCreateIcons();
}
// Remove recent section when navigating into subdirectory
const recentSection = container.querySelector('.vault-home-recent');
if (recentSection) recentSection.remove();
// Reload results
const resultsArea = document.getElementById('vault-home-results');
if (resultsArea) {
// Listing + subdirectories
let files;
try {
const data = await api(
`/api/vault/${encodeURIComponent(vaultName)}/files?dir=${encodeURIComponent(state.currentDir)}&limit=200&recursive=false`,
);
files = data.files || [];
} catch (err) {
if (seq !== _renderSeq) return;
const errMsg = escapeHtml(
t('vaulthome.error_loading') + ' ' + (err?.detail || err?.message || t('common.unknown')),
);
resultsArea.innerHTML = `
<div class="tree-loading" style="padding:2rem;text-align:center">
<div class="loading-spinner" style="width:20px;height:20px;border-width:2px;margin:0 auto"></div>
<div class="vault-home-error">
<p data-i18n="vaulthome.error_loading">${errMsg}</p>
</div>`;
await loadAndRender(vaultName, state.currentDir, resultsArea);
}
}
// ---------------------------------------------------------------------------
// Tree click delegation — update vault home on directory clicks
// ---------------------------------------------------------------------------
let _treeDelegationInstalled = false;
function _initTreeDelegation() {
if (_treeDelegationInstalled) return;
const tree = document.getElementById('vault-tree');
if (!tree) {
// Retry once when DOM might not be ready — use MutationObserver as fallback
const observer = new MutationObserver(() => {
const t = document.getElementById('vault-tree');
if (t) {
observer.disconnect();
_attachTreeListener(t);
}
});
observer.observe(document.body, { childList: true, subtree: true });
return;
}
_attachTreeListener(tree);
try {
const browse = await api(
`/api/browse/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(state.currentDir)}`,
);
state.dirs = (browse.items || []).filter((i) => i.type === 'directory');
} catch {
state.dirs = []; // no subdirectory list — the file listing still renders
}
if (seq !== _renderSeq) return;
state.files = files;
_view.dirs = state.dirs;
const facets = buildFacets();
if (facets) facetsHost.appendChild(facets);
renderResults();
// Recent files for this vault (root level only)
if (!state.currentDir) {
loadRecentSection(vaultName, container, resultsArea);
}
}
function _attachTreeListener(tree) {
if (_treeDelegationInstalled) return;
_treeDelegationInstalled = true;
tree.addEventListener('click', (e) => {
if (!state.active) return;
// Find the closest tree-item that is a directory
const item = e.target.closest('.tree-item');
if (!item || !item.dataset.vault || item.dataset.path === undefined) return;
const vault = item.dataset.vault;
const path = item.dataset.path;
// Only react to directory clicks (items that have a child container)
const hasChildContainer = item.nextElementSibling
&& item.nextElementSibling.classList.contains('tree-children');
if (!hasChildContainer) return;
// Update vault home dir
updateVaultHomeDir(vault, path);
});
}
// Initialize delegation when module loads
_initTreeDelegation();
// ---------------------------------------------------------------------------
// Rendering
// Sort + save toolbar
// ---------------------------------------------------------------------------
function buildToolbar() {
const header = el('div', { class: 'search-results-header' });
const sortDiv = el('div', { class: 'search-sort' });
const btnRelevance = el('button', {
class: 'search-sort__btn' + (state.sort === 'relevance' ? ' active' : ''),
type: 'button',
});
btnRelevance.textContent = 'Pertinence';
btnRelevance.addEventListener('click', () => {
state.sort = 'relevance';
btnRelevance.classList.add('active');
btnDate.classList.remove('active');
renderResults();
});
const btnDate = el('button', {
class: 'search-sort__btn' + (state.sort === 'modified' ? ' active' : ''),
type: 'button',
});
btnDate.textContent = 'Date';
btnDate.addEventListener('click', () => {
state.sort = 'modified';
btnDate.classList.add('active');
btnRelevance.classList.remove('active');
renderResults();
});
sortDiv.appendChild(btnRelevance);
sortDiv.appendChild(btnDate);
header.appendChild(sortDiv);
// Save the current directory as a saved search — same payload as the search
// page's "save directory" action (query vide + include_paths).
const saveBtn = el('button', { class: 'search-save-btn', type: 'button', title: t('search.save_search') });
saveBtn.innerHTML = '<i data-lucide="bookmark-plus" style="width:14px;height:14px"></i> Sauver';
saveBtn.addEventListener('click', async () => {
try {
await api('/api/saved-searches', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: '',
vault: state.currentVault,
case_sensitive: false,
whole_word: false,
regex: false,
include_paths: state.currentDir ? state.currentDir + '/**' : '**',
exclude_paths: '',
}),
});
flashSaveBtn(saveBtn, false);
import('./viewer.js').then((m) => m.loadSavedSearches());
} catch (err) {
flashSaveBtn(saveBtn, true, err);
}
});
header.appendChild(saveBtn);
safeCreateIcons();
return header;
}
/** Tiny inline feedback on the save button (no toast module dependency). */
function flashSaveBtn(btn, isError, err) {
const original = btn.innerHTML;
if (isError) {
btn.title = String(err?.detail || err?.message || err);
}
btn.textContent = isError ? '✕' : '✓';
setTimeout(() => {
btn.innerHTML = original;
safeCreateIcons();
}, 1200);
}
// ---------------------------------------------------------------------------
// Facets panel — same mechanism and visuals as the search page (#157/#158)
// ---------------------------------------------------------------------------
function buildFacets() {
const facets = buildNavFacets(state.files);
// Parité avec la page de recherche : pas de panneau vide (listing sans fichier).
if (!Object.keys(facets.vaults).length && !Object.keys(facets.tags).length
&& !Object.keys(facets.extensions).length) {
return null;
}
const collapsed = localStorage.getItem('obsigate_facets_collapsed') === '1';
const facetsDiv = el('div', { class: 'search-facets', 'data-collapsed': collapsed ? 'true' : 'false' });
const toggleBtn = el('button', {
class: 'search-facets__toggle',
type: 'button',
'aria-expanded': String(!collapsed),
title: collapsed ? t('search.facets_expand') : t('search.facets_collapse'),
});
toggleBtn.innerHTML = '<i data-lucide="chevron-down" style="width:12px;height:12px"></i>';
toggleBtn.addEventListener('click', () => {
const nowCollapsed = facetsDiv.getAttribute('data-collapsed') !== 'true';
facetsDiv.setAttribute('data-collapsed', nowCollapsed ? 'true' : 'false');
toggleBtn.setAttribute('aria-expanded', String(!nowCollapsed));
toggleBtn.title = nowCollapsed ? t('search.facets_expand') : t('search.facets_collapse');
localStorage.setItem('obsigate_facets_collapsed', nowCollapsed ? '1' : '0');
});
facetsDiv.appendChild(toggleBtn);
const body = el('div', { class: 'search-facets__body' });
// Vaults — the view is scoped to one vault; clicking the chip jumps to the
// vault root (never a dead control).
if (Object.keys(facets.vaults).length > 0) {
const group = el('div', { class: 'search-facets__group' });
group.appendChild(facetLabel('Vaults'));
for (const [vaultName, count] of Object.entries(facets.vaults)) {
const item = facetItem(vaultName, count, false);
item.addEventListener('click', () => TabManager.openNav(vaultName, ''));
group.appendChild(item);
}
body.appendChild(group);
}
// Tags — filter the listing client-side (toggle).
if (Object.keys(facets.tags).length > 0) {
const group = el('div', { class: 'search-facets__group' });
group.appendChild(facetLabel('Tags'));
for (const [tagName, count] of Object.entries(facets.tags).slice(0, 12)) {
const item = facetItem('#' + tagName, count, state.filterTag === tagName);
item.addEventListener('click', () => {
state.filterTag = state.filterTag === tagName ? '' : tagName;
refreshView();
});
group.appendChild(item);
}
body.appendChild(group);
}
// Extensions — filter the listing client-side (toggle).
if (Object.keys(facets.extensions).length > 0) {
const group = el('div', { class: 'search-facets__group', 'data-facet': 'extensions' });
group.appendChild(facetLabel('Extensions'));
for (const [extName, count] of Object.entries(facets.extensions)) {
const item = facetItem('.' + extName, count, state.filterExt === extName);
item.addEventListener('click', () => {
state.filterExt = state.filterExt === extName ? '' : extName;
refreshView();
});
group.appendChild(item);
}
body.appendChild(group);
}
facetsDiv.appendChild(body);
return facetsDiv;
}
function facetLabel(text) {
const label = el('span', { class: 'search-facets__label' });
label.textContent = text;
return label;
}
function facetItem(label, count, active) {
// textContent: facet labels come from user data (names, tags, extensions),
// never innerHTML.
const item = el('span', { class: 'search-facets__item' + (active ? ' active' : '') });
item.appendChild(document.createTextNode(label + ' '));
const countEl = el('span', { class: 'facet-count' });
countEl.textContent = String(count);
item.appendChild(countEl);
return item;
}
/** Re-render facets + listing after a filter change (no refetch). */
function refreshView() {
if (!_view) return;
_view.facetsHost.replaceChildren();
const facets = buildFacets();
if (facets) _view.facetsHost.appendChild(facets);
renderResults();
safeCreateIcons();
}
// ---------------------------------------------------------------------------
// Listing: subdirectories + files
// ---------------------------------------------------------------------------
function renderResults() {
if (!_view) return;
const container = _view.resultsArea;
container.innerHTML = '';
const files = filterNavFiles(state.files, { tag: state.filterTag, ext: state.filterExt });
const isEmptyListing = state.files.length === 0;
if (isEmptyListing && state.dirs.length === 0) {
container.innerHTML = `
<div class="vault-home-empty">
<i data-lucide="folder-open" style="width:32px;height:32px;color:var(--text-muted)"></i>
<p data-i18n="vaulthome.empty">${t('vaulthome.empty')}</p>
</div>`;
safeCreateIcons();
return;
}
// Header: count + directory label
const header = el('div', { class: 'vault-home-header' });
const shown = isEmptyListing ? state.dirs.length : files.length;
const countKey = shown > 1 ? 'vaulthome.file_count_plural' : 'vaulthome.file_count';
header.appendChild(el('span', { class: 'vault-home-count', 'data-i18n': countKey }, [
document.createTextNode(t(countKey, { count: shown })),
]));
header.appendChild(el('span', { class: 'vault-home-dir-info' }, [
document.createTextNode(state.currentDir ? `/${state.currentDir}` : ` ${t('common.root')}`),
]));
container.appendChild(header);
// Active filter chips (same badges as the search page)
if (state.filterTag || state.filterExt) {
const activeFilters = el('div', { class: 'search-results-active-tags' });
const addChip = (label, onRemove) => {
const removeBtn = el('button', { class: 'search-results-active-tag-remove', title: label }, [
document.createTextNode('×'),
]);
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
onRemove();
});
activeFilters.appendChild(el('span', { class: 'search-results-active-tag' }, [
document.createTextNode(label), removeBtn,
]));
};
if (state.filterTag) addChip('#' + state.filterTag, () => { state.filterTag = ''; refreshView(); });
if (state.filterExt) addChip('.' + state.filterExt, () => { state.filterExt = ''; refreshView(); });
container.appendChild(activeFilters);
}
// Subdirectories — clickable, navigate deeper
if (state.dirs.length > 0) {
const section = el('div', { class: 'vault-home-recent vault-home-dirs' });
const secHeader = el('div', { class: 'vault-home-recent-header' });
secHeader.innerHTML = `
<i data-lucide="folders" style="width:14px;height:14px"></i>
<span data-i18n="vaulthome.directories">${t('vaulthome.directories')}</span>`;
section.appendChild(secHeader);
const list = el('div', { class: 'vault-home-recent-list' });
state.dirs.forEach((d) => {
const item = el('div', { class: 'vault-home-recent-item', 'data-path': d.path });
// Même couleur d'icône que l'arbre de la sidebar (.tree-item .icon)
item.innerHTML = `
<i data-lucide="folder" class="icon" style="width:16px;height:16px;flex-shrink:0"></i>
<span class="vault-home-recent-title">${escapeHtml(d.name)}</span>
<span class="vault-home-recent-time">(${d.children_count})</span>`;
item.addEventListener('click', () => TabManager.openNav(state.currentVault, d.path));
// Menu contextuel identique à celui de la sidebar (#158)
item.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation(); // sinon le handler global de ContextMenuManager referme le menu
ContextMenuManager.show(e.clientX, e.clientY, state.currentVault, d.path, 'directory', false);
});
list.appendChild(item);
});
section.appendChild(list);
container.appendChild(section);
}
// File list — search-result-item style
if (files.length === 0) {
if (!isEmptyListing) {
container.appendChild(el('p', { style: 'color:var(--text-muted);margin-top:20px' }, [
document.createTextNode(t('search.no_results')),
]));
}
safeCreateIcons();
return;
}
const list = el('div', { class: 'search-results' });
const locale = getLocale();
sortNavFiles(files, state.sort).forEach((f) => {
list.appendChild(buildFileItem(f, locale));
});
container.appendChild(list);
safeCreateIcons();
}
function buildFileItem(f, locale) {
const fileIconName = getFileIcon ? getFileIcon(f.name) : 'file';
const ext = f.extension || '';
const titleDiv = el('div', { class: 'search-result-title' }, [
document.createTextNode(navDisplayName(f)),
]);
const modDate = new Date(f.modified * 1000);
const dateStr = modDate.toLocaleDateString(locale === 'fr' ? 'fr-CA' : 'en-CA', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
});
const metaDiv = el('div', { class: 'search-result-vault' }, [
document.createTextNode([dateStr, formatSize(f.size)].join(' · ')),
]);
const item = el('div', {
class: 'search-result-item vault-home-item',
'data-vault': f.vault,
'data-path': f.path,
}, [
el('span', { class: 'search-result-ext' }, [document.createTextNode(ext)]),
el('i', { 'data-lucide': fileIconName, style: 'width:14px;height:14px;margin-right:4px;opacity:0.6' }),
titleDiv,
metaDiv,
]);
// Tags — clicking one applies the tag filter (like the search page)
if (f.tags && f.tags.length > 0) {
const tagsDiv = el('div', { class: 'search-result-tags' });
f.tags.forEach((tag) => {
const tagEl = el('span', { class: 'file-tag' }, [document.createTextNode('#' + tag)]);
tagEl.addEventListener('click', (e) => {
e.stopPropagation();
state.filterTag = tag;
refreshView();
});
tagsDiv.appendChild(tagEl);
});
item.appendChild(tagsDiv);
}
item.addEventListener('click', () => TabManager.openPreview(f.vault, f.path));
item.addEventListener('dblclick', (e) => {
e.preventDefault();
TabManager.openPersistent(f.vault, f.path);
});
// Menu contextuel identique à celui de la sidebar (#158)
item.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
ContextMenuManager.show(e.clientX, e.clientY, f.vault, f.path, 'file', false);
});
return item;
}
// ---------------------------------------------------------------------------
// Breadcrumb
// ---------------------------------------------------------------------------
function renderBreadcrumb(container, vaultName, dirPath) {
@@ -190,12 +501,13 @@ function renderBreadcrumb(container, vaultName, dirPath) {
}
function buildBreadcrumbHTML(parentEl, vaultName, dirPath) {
// Vault icon + name (root)
// Vault icon + name (root) — routed through the navigation tab so the tab
// cache and the view never drift apart.
const vaultLink = el('span', { class: 'vault-home-breadcrumb-item vault-home-breadcrumb-root' }, [
el('i', { 'data-lucide': 'database', style: 'width:14px;height:14px' }),
el('span', {}, [document.createTextNode(vaultName)]),
]);
vaultLink.addEventListener('click', () => updateVaultHomeDir(vaultName, ''));
vaultLink.addEventListener('click', () => TabManager.openNav(vaultName, ''));
vaultLink.style.cursor = 'pointer';
parentEl.appendChild(vaultLink);
@@ -205,126 +517,31 @@ function buildBreadcrumbHTML(parentEl, vaultName, dirPath) {
let cumulative = '';
for (const seg of segments) {
cumulative = cumulative ? cumulative + '/' + seg : seg;
const sep = el('span', { class: 'vault-home-breadcrumb-sep' }, [document.createTextNode(' / ')]);
parentEl.appendChild(sep);
parentEl.appendChild(el('span', { class: 'vault-home-breadcrumb-sep' }, [document.createTextNode(' / ')]));
const target = cumulative;
const segLink = el('span', { class: 'vault-home-breadcrumb-item' }, [
el('i', { 'data-lucide': 'folder', style: 'width:14px;height:14px' }),
el('span', {}, [document.createTextNode(seg)]),
]);
segLink.addEventListener('click', () => updateVaultHomeDir(vaultName, cumulative));
segLink.addEventListener('click', () => TabManager.openNav(vaultName, target));
segLink.style.cursor = 'pointer';
parentEl.appendChild(segLink);
}
}
async function loadAndRender(vaultName, dirPath, container) {
try {
const url = `/api/vault/${encodeURIComponent(vaultName)}/files?dir=${encodeURIComponent(dirPath)}&limit=200&recursive=false`;
const data = await api(url);
if (!data.files || data.files.length === 0) {
container.innerHTML = `
<div class="vault-home-empty">
<i data-lucide="folder-open" style="width:32px;height:32px;color:var(--text-muted)"></i>
<p data-i18n="vaulthome.empty">${t('vaulthome.empty')}</p>
</div>`;
safeCreateIcons();
return;
}
// Header
const header = el('div', { class: 'vault-home-header' });
const countKey = data.files.length > 1 ? 'vaulthome.file_count_plural' : 'vaulthome.file_count';
const countStr = t(countKey, { count: data.files.length });
const count = el('span', { class: 'vault-home-count', 'data-i18n': countKey }, [
document.createTextNode(countStr),
]);
header.appendChild(count);
// Directory label
const dirLabel = dirPath ? `/${dirPath}` : ` ${t('common.root')}`;
const dirInfo = el('span', { class: 'vault-home-dir-info' }, [
document.createTextNode(dirLabel),
]);
header.appendChild(dirInfo);
container.innerHTML = '';
container.appendChild(header);
// File list — using search-result-item style
const list = el('div', { class: 'search-results' });
const locale = getLocale();
data.files.forEach((f) => {
const fileIconName = getFileIcon ? getFileIcon(f.name) : 'file';
const displayName = f.name.endsWith('.md') ? f.name.slice(0, -3) : f.name;
const ext = f.extension || '';
const titleDiv = el('div', { class: 'search-result-title' }, [
document.createTextNode(displayName),
]);
// Format date using current locale
const modDate = new Date(f.modified * 1000);
const dateStr = modDate.toLocaleDateString(locale === 'fr' ? 'fr-CA' : 'en-CA', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
});
// Build metadata line: date + size
const metaParts = [];
metaParts.push(dateStr);
metaParts.push(formatSize(f.size));
const metaDiv = el('div', { class: 'search-result-vault' }, [
document.createTextNode(metaParts.join(' · ')),
]);
const item = el('div', {
class: 'search-result-item vault-home-item',
'data-vault': f.vault,
'data-path': f.path,
}, [
el('span', { class: 'search-result-ext' }, [document.createTextNode(ext)]),
el('i', { 'data-lucide': fileIconName, style: 'width:14px;height:14px;margin-right:4px;opacity:0.6' }),
titleDiv,
metaDiv,
]);
item.addEventListener('click', () => TabManager.openPreview(f.vault, f.path));
item.addEventListener('dblclick', (e) => {
e.preventDefault();
TabManager.openPersistent(f.vault, f.path);
});
list.appendChild(item);
});
container.appendChild(list);
safeCreateIcons();
} catch (err) {
const errMsg = t('vaulthome.error_loading') + ' ' + escapeHtml(err?.detail || err?.message || t('common.unknown'));
container.innerHTML = `
<div class="vault-home-error">
<p data-i18n="vaulthome.error_loading">${errMsg}</p>
</div>`;
}
}
// ---------------------------------------------------------------------------
// Recent files section (loaded at vault root only)
// Recent files section (root only)
// ---------------------------------------------------------------------------
async function loadRecentSection(vaultName, container) {
async function loadRecentSection(vaultName, container, resultsArea) {
try {
const data = await api(`/api/recent?mode=opened&vault=${encodeURIComponent(vaultName)}&limit=5`);
const files = data.files || [];
if (files.length === 0) return;
// The view may have been replaced while fetching
if (!container.isConnected) return;
// Insert recent section BEFORE the results area
const resultsArea = document.getElementById('vault-home-results');
const section = el('div', { class: 'vault-home-recent' });
const header = el('div', { class: 'vault-home-recent-header' });
header.innerHTML = `
<i data-lucide="clock" style="width:14px;height:14px"></i>
@@ -351,16 +568,14 @@ async function loadRecentSection(vaultName, container) {
});
list.appendChild(item);
});
section.appendChild(list);
// Insert before the results area
if (resultsArea) {
if (resultsArea && resultsArea.parentNode === container) {
container.insertBefore(section, resultsArea);
} else {
container.appendChild(section);
}
safeCreateIcons();
} catch {
// Silently skip if recent files can't be loaded
+15
View File
@@ -4120,9 +4120,23 @@ export function highlightSearchText(container, text, query, caseSensitive) {
}
}
// #158 — the static « Raccourcis & Astuces » block lives in index.html inside
// #dashboard-panel-stats; showWelcome() rebuilds #dashboard-home from a
// template that omits it, so the block disappeared after the first rebuild.
// Capture it once, at module load (before any rebuild can destroy it).
const QUICK_HELP_HTML = (() => {
if (typeof document === "undefined") return "";
const qh = document.getElementById("quick-help");
return qh ? qh.outerHTML : "";
})();
export function showWelcome() {
hideProgressBar();
// #158 — the dashboard takes over the content area: drop the active-tab
// marker so clicking a tab re-renders it instead of early-returning.
if (window.TabManager && window.TabManager.deactivate) window.TabManager.deactivate();
// Restore or rebuild the dashboard with tabbed sections
const area = getContentArea();
// #110 — keep playing media alive if the dashboard replaces its surface.
@@ -4154,6 +4168,7 @@ export function showWelcome() {
<div class="dashboard-stats-loading" data-i18n="common.loading">${t('common.loading')}</div>
</div>
<div id="dashboard-conflicts-container" style="margin-top:16px"></div>
${QUICK_HELP_HTML}
</div>
<!-- Bookmarks Panel -->
+5
View File
@@ -344,6 +344,7 @@
"common.previous": "Previous",
"common.refresh": "Refresh",
"common.rename": "Rename",
"common.home": "Home",
"common.root": "(root)",
"common.save": "Save",
"common.search": "Search",
@@ -1101,6 +1102,7 @@
"help.desc_f952db17": "Refresh tokens : Cookies HttpOnly avec rotation automatique",
"help.desc_fa126609": "Converts the document structure into an Obsidian Canvas (visual cards).",
"help.desc_facet_vaults": ": Counters by vault",
"help.desc_facet_extensions": ": Counters by file extension in the results",
"help.desc_fc9391f3": "ObsiGate sends an HTTP POST to your webhook URL with event details.",
"help.desc_fdeaf803": ": Each file type has its icon (markdown, code, etc.)",
"help.desc_file_suggest": ": File title suggestions",
@@ -1608,6 +1610,8 @@
"search.exclude_placeholder": "Exclude: vendor/*, *.lock",
"search.facet_tags": "Tags",
"search.facet_vaults": "Vaults",
"search.facets_collapse": "Collapse facets",
"search.facets_expand": "Expand facets",
"search.files_filtered": "{count} file(s) with filters",
"search.filter_path_title": "Path filters (Alt-F)",
"search.filters": "Filters",
@@ -1803,6 +1807,7 @@
"vault.add_dynamic": "Add vault",
"vault.reindex": "Reindex vault",
"vault.remove_dynamic": "Remove vault",
"vaulthome.directories": "Folders",
"vaulthome.empty": "No files in this directory",
"vaulthome.error_loading": "Loading error:",
"vaulthome.file_count": "{count} file",
+5
View File
@@ -344,6 +344,7 @@
"common.previous": "Précédent",
"common.refresh": "Rafraîchir",
"common.rename": "Renommer",
"common.home": "Accueil",
"common.root": "(racine)",
"common.save": "Enregistrer",
"common.search": "Rechercher",
@@ -1101,6 +1102,7 @@
"help.desc_f952db17": "Refresh tokens : Cookies\n HttpOnly avec rotation automatique",
"help.desc_fa126609": "🔷\n Convert to canvas :\n Convertit en diagramme Mermaid.js",
"help.desc_facet_vaults": ": Compteurs par vault",
"help.desc_facet_extensions": ": Compteurs par extension de fichier dans les résultats",
"help.desc_fc9391f3": "# Exemple avec curl + jq dans un script bash\nWEBHOOK_URL=\"https://discord.com/api/webhooks/...\"\nOBSIGATE_SECRET=\"mon-secret\"\n\n# Lire le payload depuis stdin\nread -r payload\nevent=$(echo \"$payload\" | jq -r '.event')\npath=$(echo \"$payload\" | jq -r '.data.path')\n\ncurl -H \"Content-Type: application/json\" -d \"{\n \\\"content\\\": \\\"📝 **$event** — $path\\\"\n}\" \"$WEBHOOK_URL\"\n\n Tester avec curl\n # Simuler ce qu'ObsiGate envoie (pour tester votre endpoint)\ncurl -X POST https://votre-serveur.com/webhook \\\n -H \"Content-Type: application/json\" \\\n -H \"X-ObsiGate-Event: file_created\" \\\n -H \"X-ObsiGate-Signature: sha256=$(echo -n '{\"event\":\"file_created\",...}' | openssl dgst -sha256 -hmac 'mon-secret' | cut -d' ' -f2)\" \\\n -d '{\"event\":\"file_created\",\"timestamp\":\"2026-06-03T14:30:00Z\",\"data\":{\"vault\":\"IT\",\"path\":\"notes/test.md\"}}'\n\n Limitations actuelles\n \n \n Timeout de 5 secondes par\n requête — les webhooks sont \"fire and\n forget\" (pas de retry)\n \n \n Les modifications détectées par le watcher\n filesystem (watchdog)\n ne déclenchent pas de\n webhooks — seules les opérations via l'API\n REST sont prises en compte\n \n \n file_modified n'est pas encore\n dispatché (réservé pour version future)\n \n Pas de retry automatique en cas d'échec\n \n Pas de filtrage par vault — un webhook\n écoute toutes les vaults\n \n \n \n \n 💾 Sauvegardes et Audits\n\n Backup automatique\n \n ObsiGate crée automatiquement des sauvegardes\n avant chaque modification destructive.",
"help.desc_fdeaf803": ": Chaque type de\n fichier a son icône (markdown, code, etc.)",
"help.desc_file_suggest": ": Suggestions de titres de fichiers",
@@ -1608,6 +1610,8 @@
"search.exclude_placeholder": "Exclure: vendor/*, *.lock",
"search.facet_tags": "Tags",
"search.facet_vaults": "Vaults",
"search.facets_collapse": "Réduire les facettes",
"search.facets_expand": "Afficher les facettes",
"search.files_filtered": "{count} fichier(s) avec filtres",
"search.filter_path_title": "Filtres de chemin (Alt-F)",
"search.filters": "Filtres",
@@ -1803,6 +1807,7 @@
"vault.add_dynamic": "Ajouter une vault",
"vault.reindex": "Réindexer la vault",
"vault.remove_dynamic": "Retirer la vault",
"vaulthome.directories": "Répertoires",
"vaulthome.empty": "Aucun fichier dans ce repertoire",
"vaulthome.error_loading": "Erreur de chargement:",
"vaulthome.file_count": "{count} fichier",
+53 -1
View File
@@ -4038,7 +4038,9 @@ body.resizing-v {
top: 0;
left: 0;
bottom: 0;
z-index: 200;
/* Par-dessus la barre d'outils mobile (.mobile-toolbar, z-index 900)
pour ne pas masquer le bas du sidebar — BUG-103 */
z-index: 950;
width: 280px !important;
min-width: 280px !important;
max-width: 85vw !important;
@@ -5472,10 +5474,55 @@ body.resizing-v {
border-color: var(--accent);
background: var(--bg-hover);
}
/* Facette actuellement appliquée (filtre local — recherche et navigation) */
.search-facets__item.active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 15%, transparent);
color: var(--text-primary);
}
.search-facets__item .facet-count {
font-size: 0.68rem;
color: var(--text-muted);
}
.search-facets__toggle {
display: inline-flex;
align-items: center;
justify-content: center;
align-self: flex-start; /* mobile (colonne) : ne jamais s'étirer en pleine largeur */
padding: 2px 6px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-secondary);
color: var(--text-muted);
cursor: pointer;
transition:
border-color 120ms,
color 120ms,
background 120ms;
}
.search-facets__toggle:hover {
border-color: var(--accent);
color: var(--text-secondary);
}
/* Seule l'icône pivote — la boîte du bouton reste fixe */
.search-facets__toggle i,
.search-facets__toggle svg {
transition: transform 120ms;
}
.search-facets[data-collapsed="true"] .search-facets__toggle i,
.search-facets[data-collapsed="true"] .search-facets__toggle svg {
transform: rotate(-90deg);
}
.search-facets__body {
display: flex;
flex-wrap: wrap;
gap: 12px;
flex: 1 1 auto;
min-width: 0;
}
.search-facets[data-collapsed="true"] .search-facets__body {
display: none;
}
/* ---------------------------------------------------------------------------
Advanced Search — Pagination
@@ -5860,6 +5907,11 @@ body.resizing-v {
cursor: pointer;
}
/* Icône des répertoires : même couleur que l'arbre de la sidebar (.tree-item .icon) */
.vault-home-dirs .icon {
color: var(--accent);
}
.vault-home-item:hover {
background: var(--bg-secondary);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.45.1",
"version": "2.49.4",
"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": {
+106
View File
@@ -0,0 +1,106 @@
/**
* E2E — Page de navigation & onglet Accueil (#158).
*
* Couvre :
* - clic sur la racine d'un vault -> page de navigation (onglet dédié)
* - icône des répertoires à la couleur de l'arbre de la sidebar
* - menu contextuel des répertoires et des fichiers identique à la sidebar
* - clic sur le titre -> onglet Accueil (icône Maison) épinglé en tête de barre
*/
import { test, expect } from '@playwright/test';
async function boot(page) {
await page.goto('/');
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
await page.waitForSelector('#dashboard-home, #search-input', { timeout: 15000 });
}
const menuItems = (page) =>
page.$$eval('#context-menu .context-menu-item', (els) => els.map((e) => e.textContent.trim()))
.catch(() => []);
test.describe('Page de navigation (#158)', () => {
test.beforeEach(async ({ page }) => {
await boot(page);
});
test("clic sur la racine d'un vault ouvre la page de navigation", async ({ page }) => {
const vault = await page.locator('.vault-item').first().getAttribute('data-vault');
await page.locator('.vault-item').first().click();
await page.waitForSelector('#vault-home .vault-home-breadcrumb', { timeout: 15000 });
await expect(page.locator(`#tab-list .tab-item[data-tab-id="nav::${vault}"]`)).toHaveCount(1);
// Le fil d'Ariane est à la racine du vault
await expect(page.locator('#vault-home .vault-home-breadcrumb-item')).toHaveCount(1);
});
test("répertoires de la vue : icône à la couleur de la sidebar + menu contextuel", async ({ page }) => {
await page.locator('.vault-item').first().click();
await page.waitForSelector('#vault-home .vault-home-dirs .vault-home-recent-item', { timeout: 15000 });
// Couleur identique à l'icône de l'arbre (.tree-item .icon -> var(--accent))
const colors = await page.evaluate(() => {
const cs = (el) => (el ? getComputedStyle(el).color : null);
return {
nav: cs(document.querySelector('#vault-home .vault-home-dirs .vault-home-recent-item svg')),
side: cs(document.querySelector('.tree-item[data-type="directory"] svg')),
};
});
expect(colors.nav).toBe(colors.side);
expect(colors.nav).toBeTruthy();
// Menu contextuel : page de navigation vs sidebar
await page.locator('#vault-home .vault-home-dirs .vault-home-recent-item').first().click({ button: 'right' });
await page.waitForSelector('#context-menu', { timeout: 5000 });
const navMenu = await menuItems(page);
expect(navMenu.length).toBeGreaterThan(0);
await page.keyboard.press('Escape');
await page.locator('.tree-item[data-type="directory"]').first().click({ button: 'right' });
await page.waitForSelector('#context-menu', { timeout: 5000 });
const sideMenu = await menuItems(page);
await page.keyboard.press('Escape');
expect(navMenu).toEqual(sideMenu);
});
test('fichiers de la vue : menu contextuel identique à la sidebar', async ({ page }) => {
await page.locator('.vault-item').first().click();
await page.waitForSelector('#vault-home .search-result-item', { timeout: 15000 });
await page.locator('#vault-home .search-result-item').first().click({ button: 'right' });
await page.waitForSelector('#context-menu', { timeout: 5000 });
const navMenu = await menuItems(page);
expect(navMenu.length).toBeGreaterThan(0);
await page.keyboard.press('Escape');
await page.locator('.tree-item[data-type="file"]').first().click({ button: 'right' });
await page.waitForSelector('#context-menu', { timeout: 5000 });
const sideMenu = await menuItems(page);
await page.keyboard.press('Escape');
expect(navMenu).toEqual(sideMenu);
});
test('titre -> onglet Accueil (Maison) épinglé en tête de barre', async ({ page }) => {
// Un fichier ouvert pour vérifier que l'onglet Accueil passe devant
await page.locator('.vault-item').first().click();
await page.waitForSelector('.tree-item[data-type="file"]', { timeout: 15000 });
await page.locator('.tree-item[data-type="file"]').first().dblclick();
await page.waitForTimeout(500);
await page.locator('#header-logo').click();
await page.waitForSelector('#dashboard-home', { timeout: 10000 });
const ids = await page.$$eval('#tab-list .tab-item', (els) => els.map((e) => e.dataset.tabId));
expect(ids[0]).toBe('home');
expect(ids.length).toBeGreaterThan(1);
await expect(page.locator('#tab-list .tab-item.active')).toHaveAttribute('data-tab-id', 'home');
// Icône Maison + libellé traduit
const iconClass = await page.locator('#tab-list .tab-item').first().locator('.tab-icon').getAttribute('class');
expect(iconClass).toContain('home');
const label = (await page.locator('#tab-list .tab-item').first().locator('.tab-name').textContent()) || '';
expect(['Accueil', 'Home']).toContain(label.trim());
// La page d'accueil est affichée (avec sa section Raccourcis & Astuces)
await expect(page.locator('#dashboard-home #quick-help')).toHaveCount(1);
});
});
+27
View File
@@ -143,6 +143,33 @@ test.describe('Full-Text Search', () => {
// Not always present — depends on vault content
});
test('should show the extensions facet and collapse the facets panel (#157)', async ({ page }) => {
await goHome(page);
await searchFor(page, 'test');
const extFacet = page.locator('.search-facets__group[data-facet="extensions"]');
await expect(extFacet).toBeVisible();
await expect(extFacet.locator('.search-facets__item').first()).toBeVisible();
// discreet collapse/expand of the whole vaults+tags+extensions section
await page.locator('.search-facets__toggle').click();
await expect(page.locator('.search-facets__body')).toBeHidden();
await page.locator('.search-facets__toggle').click();
await expect(page.locator('.search-facets__body')).toBeVisible();
// mobile: the toggle stays compact (no full-width stretch) and only the
// icon rotates — the button box itself never spins
await page.setViewportSize({ width: 390, height: 844 });
const toggle = page.locator('.search-facets__toggle');
const box = await toggle.boundingBox();
expect(box).not.toBeNull();
expect(box!.width).toBeLessThan(150);
await toggle.click();
expect(await toggle.evaluate((el) => getComputedStyle(el).transform)).toBe('none');
const icon = toggle.locator('svg');
expect(await icon.evaluate((el) => getComputedStyle(el).transform)).not.toBe('none');
await toggle.click();
await page.setViewportSize({ width: 1280, height: 720 });
});
test('should navigate results with floating bar arrows', async ({ page }) => {
await goHome(page);
await searchFor(page, 'test');
+16 -12
View File
@@ -160,8 +160,9 @@ test.describe('Split View Matrix — Boutons & menu contextuel', () => {
await flyout.click();
await page.waitForTimeout(600);
// Pane 0 (single) holds both files now; empty source pane auto-collapsed
// (+ onglet navigation ouvert au clic sur la racine du vault — #158)
await expect(page.locator('.pane-container')).toHaveCount(0);
await expect(page.locator('#tab-list .tab-item')).toHaveCount(2);
await expect(page.locator('#tab-list .tab-item')).toHaveCount(3);
});
});
@@ -205,10 +206,10 @@ test.describe('Split View Matrix — Fermeture', () => {
test('X button on tab closes it', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await openFile(page, 'TestVault', 'note2.md');
await expect(page.locator('.tab-item')).toHaveCount(2);
await expect(page.locator('.tab-item')).toHaveCount(3); // + onglet navigation (#158)
await page.locator('.tab-item').first().locator('.tab-close').click();
await page.waitForTimeout(300);
await expect(page.locator('.tab-item')).toHaveCount(1);
await expect(page.locator('.tab-item')).toHaveCount(2);
});
test('double-click on tab closes it', async ({ page }) => {
@@ -216,12 +217,15 @@ test.describe('Split View Matrix — Fermeture', () => {
await openFile(page, 'TestVault', 'note2.md');
await page.locator('.tab-item').first().dblclick();
await page.waitForTimeout(300);
await expect(page.locator('.tab-item')).toHaveCount(1);
await expect(page.locator('.tab-item')).toHaveCount(2); // reste : onglet navigation (#158)
});
test('Ctrl+W closes active tab (single pane)', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await page.keyboard.press('Control+w');
await openFile(page, 'TestVault', 'note1.md'); // ouvre aussi l'onglet navigation (#158)
await page.keyboard.press('Control+w'); // ferme l'onglet fichier
await page.waitForTimeout(400);
await expect(page.locator('.tab-item')).toHaveCount(1);
await page.keyboard.press('Control+w'); // ferme l'onglet navigation
await page.waitForTimeout(400);
await expect(page.locator('.tab-item')).toHaveCount(0);
await expect(page.locator('#dashboard-home')).toBeVisible();
@@ -233,7 +237,7 @@ test.describe('Split View Matrix — Fermeture', () => {
await page.locator('.tab-item').first().click({ button: 'right' });
await page.locator('#tab-context-menu [data-action="close"]').click();
await page.waitForTimeout(300);
await expect(page.locator('.tab-item')).toHaveCount(1);
await expect(page.locator('.tab-item')).toHaveCount(2); // reste navigation + fichier (#158)
});
test('context menu Fermer les autres keeps one', async ({ page }) => {
@@ -243,7 +247,7 @@ test.describe('Split View Matrix — Fermeture', () => {
await openFile(page, 'TestVault', 'Accueil.md');
await page.waitForTimeout(300);
const tabs = page.locator('.tab-item');
await expect(tabs).toHaveCount(3);
await expect(tabs).toHaveCount(5); // accueil + navigation + 3 fichiers (#158)
await tabs.nth(1).click({ button: 'right' });
await page.locator('#tab-context-menu [data-action="closeOthers"]').click();
await page.waitForTimeout(300);
@@ -256,7 +260,7 @@ test.describe('Split View Matrix — Fermeture', () => {
await page.locator('#header-logo').click(); await page.waitForTimeout(300);
await openFile(page, 'TestVault', 'Accueil.md');
await page.waitForTimeout(300);
await expect(page.locator('.tab-item')).toHaveCount(3);
await expect(page.locator('.tab-item')).toHaveCount(5); // accueil + navigation + 3 fichiers (#158)
await page.locator('.tab-item').first().click({ button: 'right' });
await page.locator('#tab-context-menu [data-action="closeRight"]').click();
await page.waitForTimeout(300);
@@ -286,8 +290,8 @@ test.describe('Split View Matrix — Fermeture', () => {
await pane1.locator('.tab-item').first().locator('.tab-close').click({ force: true });
await page.waitForTimeout(700);
await expect(page.locator('.pane-container')).toHaveCount(0);
// pane 0 file is still open in single mode
await expect(page.locator('#tab-list .tab-item')).toHaveCount(1);
// pane 0 file + onglet navigation still open in single mode (#158)
await expect(page.locator('#tab-list .tab-item')).toHaveCount(2);
});
test('closing pane 0 of 2 focuses neighbour then collapses correctly', async ({ page }) => {
@@ -376,7 +380,7 @@ test.describe('Split View Matrix — Edge cases', () => {
// dblclick note1 again
await page.locator('.tree-item[data-path="note1.md"]').dblclick();
await page.waitForTimeout(400);
await expect(page.locator('.tab-item')).toHaveCount(2);
await expect(page.locator('.tab-item')).toHaveCount(3); // + onglet navigation (#158)
// it should be the active one
const active = page.locator('.tab-item.active');
await expect(active).toHaveCount(1);
+9 -8
View File
@@ -79,7 +79,7 @@ test.describe('Split View — Basic Operations', () => {
const tabBar = page.locator('#tab-bar');
await expect(tabBar).toBeVisible();
const tabs = page.locator('.tab-item');
await expect(tabs).toHaveCount(1);
await expect(tabs).toHaveCount(2); // navigation (racine vault) + fichier (#158)
// Content area should show the file
const content = page.locator('#content-area');
await expect(content).not.toBeEmpty();
@@ -91,7 +91,7 @@ test.describe('Split View — Basic Operations', () => {
await page.waitForTimeout(300);
await openFile(page, 'TestVault', 'note2.md');
const tabs = page.locator('.tab-item');
await expect(tabs).toHaveCount(2);
await expect(tabs).toHaveCount(4); // accueil + navigation + 2 fichiers (#158)
});
test('split right via keyboard shortcut', async ({ page }) => {
@@ -192,7 +192,7 @@ test.describe('Split View — Tab Drag & Drop', () => {
// Source pane should have 1 tab, target should have 1 tab
const pane0Tabs = page.locator('.pane-container').nth(0).locator('.tab-item');
const pane1Tabs = page.locator('.pane-container').nth(1).locator('.tab-item');
await expect(pane0Tabs).toHaveCount(1);
await expect(pane0Tabs).toHaveCount(2); // navigation + fichier (#158)
await expect(pane1Tabs).toHaveCount(1);
});
@@ -343,12 +343,13 @@ test.describe('Split View — Regression', () => {
});
test('closing last tab shows dashboard', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await openFile(page, 'TestVault', 'note1.md'); // ouvre aussi l'onglet navigation (#158)
// Close tab via middle-click
const tab = page.locator('.tab-item').first();
await tab.click({ button: 'middle' });
await page.waitForTimeout(500);
// Close tabs via middle-click until none remains (dernier onglet -> dashboard)
while (await page.locator('.tab-item').count()) {
await page.locator('.tab-item').first().click({ button: 'middle' });
await page.waitForTimeout(300);
}
// Dashboard should be visible
const dashboard = page.locator('#dashboard-home');
+10 -6
View File
@@ -42,19 +42,23 @@ function test(label, fn) {
/** Body of the first @media (max-width: 768px) block whose body contains `needle`. */
function mobileBlockContaining(needle) {
const marker = "@media (max-width: 768px)";
// Les commentaires sont ignorés : un sélecteur cité dans une annotation
// (ex. « .mobile-toolbar » dans un commentaire de style.css) ne doit pas
// faire rétrograder ce helper sur le mauvais bloc mobile (BUG-103/CI #842).
const source = css.replace(/\/\*[\s\S]*?\*\//g, "");
let idx = 0;
while ((idx = css.indexOf(marker, idx)) !== -1) {
const open = css.indexOf("{", idx);
while ((idx = source.indexOf(marker, idx)) !== -1) {
const open = source.indexOf("{", idx);
let depth = 0;
let end = open;
for (; end < css.length; end++) {
if (css[end] === "{") depth++;
else if (css[end] === "}") {
for (; end < source.length; end++) {
if (source[end] === "{") depth++;
else if (source[end] === "}") {
depth--;
if (depth === 0) break;
}
}
const body = css.slice(open + 1, end);
const body = source.slice(open + 1, end);
if (body.includes(needle)) return body;
idx = end + 1;
}
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env node
/**
* ObsiGate — pure tests for the navigation page facets/filters (#158):
* `frontend/js/navfacets.js` (no DOM, real module import).
*
* Usage: node tests/frontend/navfacets.test.mjs
*/
import { strict as assert } from "node:assert";
import { buildNavFacets, filterNavFiles, sortNavFiles, navDisplayName } from "../../frontend/js/navfacets.js";
const files = [
{ name: "note2.md", path: "note2.md", vault: "V1", extension: "md", modified: 100, tags: ["docker"] },
{ name: "note1.md", path: "note1.md", vault: "V1", extension: "md", modified: 300, tags: ["python", "docker"] },
{ name: "photo.png", path: "sub/photo.png", vault: "V1", extension: ".PNG", modified: 200, tags: [] },
{ name: "sans-titre.md", path: "sans-titre.md", vault: "V1", extension: "md", modified: 400 },
];
let pass = 0;
function test(name, fn) {
try {
fn();
pass++;
console.log(` ✓ ${name}`);
} catch (err) {
console.error(` ✗ ${name}\n ${err.message}`);
process.exitCode = 1;
}
}
test("buildNavFacets counts vaults/tags/extensions, sorted desc", () => {
const f = buildNavFacets(files);
assert.deepStrictEqual(f.vaults, { V1: 4 });
assert.deepStrictEqual(f.tags, { docker: 2, python: 1 });
// leading dot stripped + lowercased, untagged file contributes nothing
assert.deepStrictEqual(f.extensions, { md: 3, png: 1 });
});
test("buildNavFacets tolerates empty/missing input", () => {
assert.deepStrictEqual(buildNavFacets([]), { vaults: {}, tags: {}, extensions: {} });
assert.deepStrictEqual(buildNavFacets(undefined), { vaults: {}, tags: {}, extensions: {} });
});
test("filterNavFiles: no filter returns the input untouched", () => {
assert.strictEqual(filterNavFiles(files, {}), files);
assert.strictEqual(filterNavFiles(files, { tag: "", ext: "" }), files);
});
test("filterNavFiles: tag filter keeps files carrying the tag", () => {
const out = filterNavFiles(files, { tag: "docker" });
assert.deepStrictEqual(out.map((f) => f.name).sort(), ["note1.md", "note2.md"]);
});
test("filterNavFiles: extension filter is dot-insensitive", () => {
assert.deepStrictEqual(filterNavFiles(files, { ext: "png" }).map((f) => f.name), ["photo.png"]);
assert.deepStrictEqual(filterNavFiles(files, { ext: ".png" }).map((f) => f.name), ["photo.png"]);
});
test("filterNavFiles: tag + ext combine (AND) and can empty the list", () => {
assert.deepStrictEqual(filterNavFiles(files, { tag: "docker", ext: "md" }).map((f) => f.name).sort(), ["note1.md", "note2.md"]);
assert.deepStrictEqual(filterNavFiles(files, { tag: "python", ext: "png" }), []);
});
test("sortNavFiles 'modified' = newest first", () => {
assert.deepStrictEqual(sortNavFiles(files, "modified").map((f) => f.modified), [400, 300, 200, 100]);
});
test("sortNavFiles 'relevance' = alphabetical, .md stripped, case-insensitive", () => {
const names = sortNavFiles(files, "relevance").map(navDisplayName);
assert.deepStrictEqual(names, ["note1", "note2", "photo.png", "sans-titre"]);
});
test("sortNavFiles does not mutate its input", () => {
const before = files.map((f) => f.name);
sortNavFiles(files, "relevance");
sortNavFiles(files, "modified");
assert.deepStrictEqual(files.map((f) => f.name), before);
});
test("navDisplayName strips only a trailing .md", () => {
assert.strictEqual(navDisplayName({ name: "a.md" }), "a");
assert.strictEqual(navDisplayName({ name: "a.markdown" }), "a.markdown");
assert.strictEqual(navDisplayName({ path: "dir/b.md" }), "b");
});
console.log(`\n${pass} passed`);
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env node
/**
* ObsiGate - JSDOM tests for the search results facets panel (#157):
* vault, tag and file-extension facets rendered by
* `renderAdvancedSearchResults()` (frontend/js/search.js).
*
* Covers:
* - the three facet groups are rendered (Vaults / Tags / Extensions)
* - the extensions facet appears even when tags and vaults are empty
* - clicking an extension replaces any `ext:` operator in the search
* input and triggers a new search
*
* Usage: node tests/frontend/search-facets.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(
`<!DOCTYPE html>
<html><body>
<input id="search-input" value="" />
<select id="vault-filter"><option value="all">all</option></select>
<div id="results-bar" style="display:none"></div>
<span id="rb-count"></span>
<div id="content-area"></div>
</body></html>`,
{ 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;
}
}
// Backend payload shape for /api/search/advanced (facets only matter here).
const PAYLOAD = {
results: [],
total: 0,
offset: 0,
limit: 50,
query_time_ms: 1,
facets: {
vaults: { IT: 4, Recettes: 2 },
tags: { linux: 3 },
extensions: { md: 5, pdf: 2, xlsx: 1 },
},
};
globalThis.fetch = async () => ({
ok: true,
status: 200,
json: async () => PAYLOAD,
});
const { renderAdvancedSearchResults } = await import(
pathToFileURL(path.join(JS_DIR, "search.js")).href
);
const facetGroups = () =>
Array.from(document.querySelectorAll(".search-facets__group"));
const extGroup = () =>
document.querySelector('.search-facets__group[data-facet="extensions"]');
const extItems = () =>
Array.from(extGroup()?.querySelectorAll(".search-facets__item") || []);
console.log("Search facets (#157) — Vaults / Tags / Extensions");
await test("renders the three facet groups", async () => {
renderAdvancedSearchResults(PAYLOAD, "python", null);
assert.equal(facetGroups().length, 3, "vault, tag and extension groups");
const labels = facetGroups().map((g) =>
g.querySelector(".search-facets__label").textContent,
);
assert.deepEqual(labels, ["Vaults", "Tags", "Extensions"]);
});
await test("lists extensions with their counts", async () => {
const texts = extItems().map((i) => i.textContent.trim());
assert.deepEqual(texts, [".md 5", ".pdf 2", ".xlsx 1"]);
assert.equal(
extItems()[0].querySelector(".facet-count").textContent,
"5",
"count lives in .facet-count",
);
});
await test("extension facet shows even with empty tags and vaults", async () => {
renderAdvancedSearchResults(
{ ...PAYLOAD, facets: { vaults: {}, tags: {}, extensions: { md: 1 } } },
"",
null,
);
assert.ok(extGroup(), "extensions group rendered alone");
assert.equal(extItems().length, 1);
});
await test("clicking an extension adds the ext: operator", async () => {
document.getElementById("search-input").value = "python ext:md";
renderAdvancedSearchResults(PAYLOAD, "python", null);
extItems()[1].click(); // .pdf
assert.equal(
document.getElementById("search-input").value,
"python ext:pdf",
"existing ext: operator replaced, not duplicated",
);
});
await test("facets panel collapses and restores via the toggle", async () => {
localStorage.removeItem("obsigate_facets_collapsed");
renderAdvancedSearchResults(PAYLOAD, "python", null);
const panel = document.querySelector(".search-facets");
const toggle = panel.querySelector(".search-facets__toggle");
assert.ok(toggle, "collapse toggle rendered");
assert.equal(panel.getAttribute("data-collapsed"), "false", "expanded by default");
assert.ok(panel.querySelector(".search-facets__body"), "groups live in the body");
toggle.click();
assert.equal(panel.getAttribute("data-collapsed"), "true", "click collapses");
assert.equal(
localStorage.getItem("obsigate_facets_collapsed"),
"1",
"collapsed state persisted",
);
renderAdvancedSearchResults(PAYLOAD, "python", null);
const panel2 = document.querySelector(".search-facets");
assert.equal(
panel2.getAttribute("data-collapsed"),
"true",
"collapsed state survives a re-render",
);
panel2.querySelector(".search-facets__toggle").click();
assert.equal(panel2.getAttribute("data-collapsed"), "false", "toggle restores");
localStorage.removeItem("obsigate_facets_collapsed");
});
// ── Summary ──
console.log(`\n${passCount}/${testCount} tests passed`);
if (passCount !== testCount) {
process.exit(1);
}
+18
View File
@@ -109,6 +109,7 @@ function testModulesHaveImports() {
{ file: 'pane-manager.js', reason: 'standalone — no deps' },
{ file: 'editor-inline.js', reason: 'standalone — DOM-only helpers for the inline editor host (#93)' },
{ file: 'themes.js', reason: 'standalone — no deps' },
{ file: 'navfacets.js', reason: 'pure helpers — no deps, imported by vaulthome.js' },
{ file: 'sidebar_raw.js', reason: 'legacy file — excluded' },
{ file: 'desktop.js', reason: 'standalone — reads window.__TAURI__ global, no imports needed' },
];
@@ -306,6 +307,22 @@ function testSyntaxHighlightTheme() {
console.log(' ✓ syntax highlighting follows the theme mode (BUG-078)');
}
// ── Test mobile : le sidebar passe par-dessus la barre d'outils du bas (BUG-103) ──
function testMobileSidebarOverToolbar() {
const css = readFileSync(join(JS_DIR, '..', 'style.css'), 'utf-8');
const zOf = (selector) => {
const blocks = css.match(new RegExp('\\' + selector + ' \\{[^}]*\\}', 'g')) || [];
return Math.max(0, ...blocks.map((b) => Number((b.match(/z-index: (\d+)/) || [])[1] || 0)));
};
const sidebarZ = zOf('.sidebar');
const toolbarZ = zOf('.mobile-toolbar');
assert.ok(
sidebarZ > toolbarZ,
`.sidebar z-index (${sidebarZ}) doit être supérieur à .mobile-toolbar (${toolbarZ})`,
);
console.log(' ✓ mobile sidebar over toolbar');
}
// ── Run all tests ──────────────────────────────────────────────────────────
async function main() {
let passed = 0, failed = 0;
@@ -323,6 +340,7 @@ async function main() {
['config TOC icons', testConfigTocIcons],
['sidebar user section', testSidebarUserSection],
['syntax highlight theme', testSyntaxHighlightTheme],
['mobile sidebar over toolbar', testMobileSidebarOverToolbar],
];
for (const [name, fn] of tests) {
+1
View File
@@ -254,6 +254,7 @@ class TestDependencySecurityFloors:
"pypdf": (6, 16, 1), # PYSEC-2026-3910, PYSEC-2026-3911 (fix 6.16.1)
# BUG-095 : 2.13.0 a son propre advisory (CVE-2026-102274, fix 2.14.0).
"pyjwt": (2, 14, 0), # PYSEC-2026-178 (2.13.0) puis CVE-2026-102274 (2.14.0)
"urllib3": (2, 8, 0), # CVE-2026-97687, CVE-2026-97688, CVE-2026-97689 (fix 2.8.0)
}
def test_security_floors_are_declared(self):
+49
View File
@@ -0,0 +1,49 @@
# tests/test_nav_files.py — Navigation page listing (#158)
#
# The vault home / navigation page lists a directory with its indexed tags so
# the Vaults · Tags · Extensions facets can filter the listing client-side.
# The tags must survive the endpoint's response_model.
from __future__ import annotations
def test_vault_files_expose_indexed_tags(client):
"""Every entry carries a `tags` list; markdown tags come from the index."""
resp = client.get("/api/vault/TestVault/files", params={"recursive": False})
assert resp.status_code == 200
data = resp.json()
files = data["files"]
assert files, "expected a non-empty listing"
# The field is always present (response_model must not strip it)
assert all("tags" in f for f in files)
by_name = {f["name"]: f for f in files}
assert "note1.md" in by_name
assert sorted(by_name["note1.md"]["tags"]) == ["python", "tutorial"]
# Unindexed / tagless files keep an empty list, never null
assert sorted(by_name["note2.md"]["tags"]) == ["devops", "docker"]
for f in files:
assert isinstance(f["tags"], list)
def test_vault_files_tags_in_subdirectory(client):
"""Tags are resolved per directory listing, not only at the vault root."""
resp = client.get(
"/api/vault/TestVault/files",
params={"dir": "Projets", "recursive": False},
)
assert resp.status_code == 200
files = resp.json()["files"]
by_name = {f["name"]: f for f in files}
assert "projet.md" in by_name
assert sorted(by_name["projet.md"]["tags"]) == ["projet", "python"]
def test_indexed_tags_unknown_vault_returns_empty():
"""Index miss (unknown vault/file) degrades to an empty tag list."""
from backend.services.vaults import _indexed_tags
assert _indexed_tags("NoSuchVault", "ghost.md") == []
+15 -5
View File
@@ -204,7 +204,7 @@ class TestAdvancedSearchAPI:
pytest.skip(f"Advanced search non-JSON response: {resp.text[:200]}")
def test_basic(self, client):
resp = client.get("/api/advanced-search?q=python&vault=all")
resp = client.get("/api/search/advanced?q=python&vault=all")
data = self._check(resp)
assert data["total"] >= 1
assert len(data["results"]) > 0
@@ -217,17 +217,27 @@ class TestAdvancedSearchAPI:
assert "path" in r
def test_pagination(self, client):
resp = client.get("/api/advanced-search?q=python&limit=1&offset=0")
resp = client.get("/api/search/advanced?q=python&limit=1&offset=0")
data = self._check(resp)
assert len(data["results"]) <= 1
def test_facets(self, client):
resp = client.get("/api/advanced-search?q=python&vault=all")
data = self._check(resp)
resp = client.get("/api/search/advanced?q=python&vault=all")
# Hard assertion: the facet contract must hold through the response_model
# (regression #157: `extensions` used to be stripped by SearchFacets).
assert resp.status_code == 200, resp.text[:200]
data = resp.json()
assert "facets" in data
assert "extensions" in data["facets"]
exts = data["facets"]["extensions"]
assert isinstance(exts, dict)
assert all(not e.startswith(".") for e in exts)
if data["total"] > 0:
assert len(exts) >= 1
assert sum(exts.values()) <= data["total"]
def test_empty_query(self, client):
resp = client.get("/api/advanced-search?q=")
resp = client.get("/api/search/advanced?q=")
data = self._check(resp)
# Empty query should return 0 results
assert data["total"] == 0