Compare commits

...
2 Commits
Author SHA1 Message Date
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
30 changed files with 1155 additions and 285 deletions
+1
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
+57 -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.47.1**.
> [Unreleased](#unreleased). La dernière version livrée est **2.49.0**.
---
@@ -14,6 +14,62 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [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
---
+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.47.1-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.49.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -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.47.1).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.49.0).
---
*Projet : ObsiGate | Version : 2.47.1 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.49.0 | 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.47.1-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.49.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -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.47.1).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.49.0).
---
*Project: ObsiGate | Version: 2.47.1 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.49.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.47.1
2.49.0
+1
View File
@@ -863,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):
+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.47.1"
version = "2.49.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.47.1"
version = "2.49.0"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+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.47.1",
"version": "2.49.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+7
View File
@@ -207,6 +207,9 @@ 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 |
| | | | | | | | | | |
### TODOs techniques (améliorations / nouvelles tâches)
@@ -309,6 +312,10 @@ 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) |
---
+36 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.47.1 | **Dernière mise à jour :** 2026-10-02
> **Version :** 2.49.0 | **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)
@@ -305,6 +339,7 @@
| 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) |
---
+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.
+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
+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 -->
+2
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",
@@ -1806,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",
+2
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",
@@ -1806,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",
+11
View File
@@ -5472,6 +5472,12 @@ 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);
@@ -5899,6 +5905,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.47.1",
"version": "2.49.0",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
"main": "patch.js",
"directories": {
+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);
});
});
+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');
+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`);
+1
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' },
];
+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") == []