Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34428ea7b3 | ||
|
|
83e6a951dd | ||
|
|
3d618eb660 | ||
|
|
2add24a9c1 | ||
|
|
a50227849d | ||
|
|
5ffc9d851a |
@@ -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
|
||||
|
||||
+84
-1
@@ -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.0**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.49.2**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,85 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.49.2] — 2026-10-02
|
||||
|
||||
### Corrigé
|
||||
|
||||
- Tests : `mobileBlockContaining` ignore les commentaires CSS — un sélecteur cité dans
|
||||
une annotation ne faisait plus trouver le bon bloc mobile (CI #842).
|
||||
|
||||
---
|
||||
|
||||
## [2.49.1] — 2026-10-02
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **Mobile (BUG-103)** : le bas du sidebar de navigation n'est plus masqué par la barre
|
||||
d'outils du bas — `z-index` du sidebar mobile `200` → `950` (au-dessus de
|
||||
`.mobile-toolbar`, `900`).
|
||||
|
||||
---
|
||||
|
||||
## [2.49.0] — 2026-10-02
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#158 — onglet Accueil.** Le clic sur le titre ouvre désormais la page d'accueil **dans un
|
||||
onglet** (icône Maison) systématiquement épinglé **en tête de barre**, réactivable d'un clic
|
||||
comme un onglet normal (le mode split a son propre onglet Accueil dans le panneau actif).
|
||||
- **#158 — menus contextuels dans la page de navigation.** Clic droit sur les répertoires et
|
||||
les fichiers de la vue : même menu que celui de la sidebar (`ContextMenuManager`), et la
|
||||
ligne d'un vault dans l'arbre ouvre à nouveau la page de navigation (racine du vault).
|
||||
- **#158 — icône des répertoires alignée sur la sidebar.** La couleur du dossier dans la
|
||||
section Répertoires est celle de l'arbre (`var(--accent)`).
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-102 — menu contextuel refermé par son propre affichage.** L'auto-scroll déclenché par
|
||||
la reflow des icônes (lucide) à l'ouverture du menu arrivait dans le listener `scroll`
|
||||
(capture) et refermait le menu ~10 ms après son apparition : un décalage de 250 ms est
|
||||
ignoré, les vrais défilements ferment toujours le menu.
|
||||
- **BUG-101 — raccourcis d'onglets déclarés deux fois.** Le bloc « Keyboard shortcuts for
|
||||
tabs » existed en double dans `ui.js` (fin de fichier) : un seul `Ctrl+W` fermait **deux**
|
||||
onglets (le second refermait celui qui venait d'être ré-activé) et `Ctrl+Tab` sautait un
|
||||
onglet. Bloc dupliqué supprimé.
|
||||
- **#158 — clic titre ne repliait plus l'arborescence.** L'effacement du filtre de la sidebar
|
||||
n'est déclenché que s'il contenait quelque chose (sinon `restoreSidebarTree()` repliait tout
|
||||
l'arbre à chaque retour à l'accueil).
|
||||
|
||||
---
|
||||
|
||||
## [2.48.0] — 2026-10-02
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#158 — onglet de navigation.** Chaque clic sur un répertoire de l'arbre (mode focus vault
|
||||
**et** mode All) ouvre un **onglet de navigation** dédié qui coexiste avec les onglets de
|
||||
fichiers : la vue chemin + Récents + fichiers n'est plus écrasée par un document ouvert, et
|
||||
change de répertoire en se mettant à jour dans son onglet. La ligne d'un vault reste un
|
||||
simple expandeur ; la racine du vault s'atteint depuis la vue (fil d'Ariane, facette Vaults)
|
||||
ou le sélecteur de vault.
|
||||
- **#158 — contenu de la page de navigation.** La vue liste désormais les **sous-répertoires
|
||||
cliquables** et embarque le mécanisme de la page de recherche : panneau de facettes
|
||||
**Vaults · Tags · Extensions** (repli partagé, filtre local avec pastille active), tri
|
||||
**Pertinence / Date** et bouton **Sauver** (enregistre le répertoire comme recherche
|
||||
sauvegardée). `GET /api/vault/{vault}/files` renvoie les `tags` indexés de chaque fichier
|
||||
(champ `tags` ajouté à `VaultFileEntry`) pour calculer les facettes.
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-100 — retour Home incomplet.** Le clic sur le titre perdait la section
|
||||
**Raccourcis & Astuces** (le template de reconstruction de `showWelcome()` ne la contenait
|
||||
pas) et laissait la recherche globale ainsi que le filtre de la sidebar actifs : le bloc
|
||||
`#quick-help` est désormais capturé puis réinjecté, et `goHome()` efface la recherche
|
||||
(chips, barre de résultats) avant d'afficher l'accueil.
|
||||
|
||||
---
|
||||
|
||||
## [2.47.1] — 2026-10-02
|
||||
|
||||
---
|
||||
|
||||
## [2.47.0] — 2026-10-01
|
||||
|
||||
### Ajouté
|
||||
@@ -30,6 +109,10 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
plus les tests d'advanced-search pointaient la route inexistante `/api/advanced-search`
|
||||
et sautaient en silence — ils ciblent désormais `/api/search/advanced`, avec une assertion
|
||||
stricte sur `facets.extensions`.
|
||||
- **#157 — bouton de repli des facettes maladroit sur mobile.** En dessous de 768 px, le
|
||||
panneau passe en colonne et le bouton s'étirait en pleine largeur d'écran, pivotait
|
||||
entièrement à la vertical au clic. Le bouton reste compact (`align-self: flex-start`) et
|
||||
**seule l'icône chevron pivote** (transition 120 ms), sur mobile comme sur desktop.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+4
-4
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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.0).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.49.2).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.47.0 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.49.2 | Dernière mise à jour : Septembre 2026*
|
||||
|
||||
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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.0).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.49.2).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.47.0 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.49.2 | Last updated: September 2026*
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.47.0"
|
||||
version = "2.49.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.47.0"
|
||||
version = "2.49.2"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.47.0",
|
||||
"version": "2.49.2",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
@@ -207,6 +207,10 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| *BUG-097* | [🟡 IMPORTANT] Feuille `.xlsm` tronquée : « Charger la suite » échoue en **415** (`GET …/xlsx/sheet` n'accepte que `.xlsx`) alors que le `.xlsm` est éditable | 🟢 corrigé | P1 | ⚙️ backend + 🔌 api | IA | `backend/routers/files_read.py` (`api_file_xlsx_sheet`), `frontend/js/viewer.js` (`wireLazyRows`) | Ouvrir un `.xlsm` de plus de 500 lignes puis cliquer le pied « Charger la suite » (ou approcher du bas du tableau) | `backend/routers/files_read.py` : `GET …/xlsx/sheet` accepte `.xlsx` **et** `.xlsm` (autres formats → 415 inchangé). Tests `TestXlsmEditable::test_sheet_window_is_served_for_xlsm` + `test_sheet_window_still_refuses_other_formats` | Défaut #156 A2. `.xlsm` est servi éditable (`files_read.py:491-512`, pas de `xlsx_readonly`) donc la visionneuse câble le chargement paresseux, mais `api_file_xlsx_sheet` refuse tout ce qui n'est pas `.xlsx` (`files_read.py:272`). Analyse et critères : `docs/features/xlsx-editor-completeness.md` |
|
||||
| *BUG-098* | [🟡 IMPORTANT] Délimiteur CSV figé `,` : un `.csv` français (`;`) s'affiche en une seule colonne et se réécrit dans un autre format | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/xlsx_reader.py` (`render_csv_table`, `delimiter=","` par défaut), `backend/routers/files_read.py:551`, `backend/services/mutations.py` (`save_csv_cells`) | Ouvrir un CSV `;` (export Excel FR) dans ObsiGate : une seule colonne ; éditer une cellule puis enregistrer : le fichier est réécrit en `,` | `backend/xlsx_reader.py` (`sniff_csv_delimiter`) + `backend/services/mutations.py::save_csv_cells` : délimiteur détecté et **réutilisé**. Tests `TestCsvDelimiter` + lecture/écriture/guillemets point-virgule | Défaut #156 A3. `render_csv_table(raw)` est appelé sans délimiteur et `save_csv_cells()` re-parse en `,`, alors que l'export CSV de la visionneuse écrit en `;`. Traitement prévu : détection `;`/`,`/tab partagée lecture/écriture/export. Analyse : `docs/features/xlsx-editor-completeness.md` |
|
||||
| *BUG-099* | [🔵 MINEUR] Sonde de perte plafonnée (8 Mo, budget global) : risque de perte **silencieuse** des valeurs calculées en cache au-delà du budget | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/xlsx_reader.py` (`_MAX_PROBE_BYTES`, `_has_cached_formulas`, `inspect_workbook`) | Constituer un classeur dont le XML de feuille dépasse 8 Mo **avant** la première formule cachée, puis l'éditer : la garde 409 `xlsx_lossy_content` ne se déclenche pas | `backend/xlsx_reader.py` : budget **par feuille** (4 Mo) + plafond global (32 Mo) ; `_scan_cached_formulas()` renvoie `(found, unverified)` et `inspect_workbook()` ajoute `cached_values_unverified` (libellé i18n FR/EN). Contre-preuve : budget épuisé → signalé au lieu de `[]`. Tests `TestXlsxCachedValueProbe` (3) | Défaut #156 A4, **analyse statique (non reproduit)**. Le budget est partagé entre toutes les feuilles : au-delà, `cached_values` n'est pas détecté et l'écriture détruit ces valeurs sans avertissement (risque n°1 de #153). Traitement prévu : budget par feuille + signal d'incertitude pour que la garde reste prudente. Analyse : `docs/features/xlsx-editor-completeness.md` |
|
||||
| *BUG-100* | Retour à l'accueil incomplet : le clic sur le titre perd la section « Raccourcis & Astuces » et laisse la recherche globale ainsi que le filtre de la sidebar actifs | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/viewer.js` (`showWelcome`), `frontend/js/legacy.js` (`goHome`) | Ouvrir un fichier (le dashboard est détruit), puis cliquer le titre ObsiGate | `viewer.js` : `#quick-help` capturé au chargement du module et réinjecté dans le template reconstruit de `showWelcome()` ; `legacy.js::goHome` clique `#search-clear-btn` (chips + barre de résultats) et `#sidebar-filter-clear-btn` avant d'afficher l'accueil | **Cause racine :** `showWelcome()` reconstruit `#dashboard-home` depuis un template qui n'embarque pas le bloc `#quick-help` présent dans `index.html` — il ne disparaissait qu'après la première reconstruction (ouverture de fichier, puis retour). Livré avec #158 A3 |
|
||||
| *BUG-101* | Raccourcis d'onglets déclarés deux fois : un seul `Ctrl+W` fermait deux onglets (le second fermait celui ré-activé par la fermeture précédente) et `Ctrl+Tab` sautait un onglet | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/ui.js` (bloc « Keyboard shortcuts for tabs » dupliqué en fin de fichier) | Ouvrir deux onglets puis `Ctrl+W` une fois | Suppression du second bloc `document.addEventListener("keydown", …)` dupliqué (fin de `ui.js`) — un seul listener reste | Exposé par #158 : le second onglet n'était plus jamais vide auparavant (la 2ᵉ fermeture tombait sur `_activeTabId === null` et devenait sans effet). Vérifié : repro Playwright (`closes: ["fichier", "nav"]` avant → `["fichier"]` après) + suite E2E |
|
||||
| *BUG-102* | Menu contextuel qui se referme ~10 ms après son ouverture : la reflow des icônes (remplacement `<i>` → `<svg>` par lucide) déclenche un événement `scroll` capturé par `ContextMenuManager` qui referme le menu | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/ui.js` (`ContextMenuManager.init/show`) | Clic droit sur un fichier de la page de navigation (ou de l'arbre) juste après un rendu d'icônes | `show()` mémorise `_shownAt` ; le listener `scroll` (capture) ignore un déclenchement dans les 250 ms suivant l'ouverture | Exposé par #158 A5 (test E2E `nav-tab.spec.js` : menu résolu « hidden » alors que 6 items étaient construits). Vérifié : instrumentation Playwright (display block conservé après le scroll) + suite E2E |
|
||||
| *BUG-103* | Mobile : le bas du sidebar de navigation est masqué par la barre d'outils du bas (`z-index` 200 < 900) | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/style.css` (règle `.sidebar` du bloc `@media (max-width: 768px)`) | Vue mobile (≤768 px), sidebar ouvert depuis « Explorateur » | Passé à `z-index: 950` (au-dessus de `.mobile-toolbar`, 900) | Exposé après #158 (retour utilisateur). Vérifié : `elementFromPoint` Playwright (393×851) + test source `mobile sidebar over toolbar` (`unit.test.mjs`) |
|
||||
| | | | | | | | | | |
|
||||
### TODOs techniques (améliorations / nouvelles tâches)
|
||||
|
||||
@@ -309,6 +313,11 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| 2026-09-29 | #156 A5-A7 (P1) | Fonctionnalité (sans nouveau défaut) | `frontend/js/viewer.js`, `frontend/js/xlsx/command-bar.js`, `frontend/locales/{fr,en}.json`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md` | **P1 livré** — (A5) presse-papiers de plage : copier/couper/coller un bloc TSV au clavier et au menu contextuel, presse-papiers interne + miroir système, remplissage multi-cellules, insertion **texte** échappée, débordement signalé, annulable ; (A6) clavier complet (`Ctrl+S`/`Ctrl+A`/`Suppr`/`F2`/`Ctrl+Home|End`/`Home|End`/`PgUp|PgDn`/`Ctrl+flèches`/`Maj+Entrée`) ; (A7) zone Nom éditable (« Atteindre ») + liste de fonctions. Vérifié : JSDOM `xlsx-viewer.test.mjs` 84/84 (20 nouveaux), E2E Playwright. | ✅ livré |
|
||||
| 2026-09-30 | #156 A8-A14 (P2 + P3) — clôture du backlog | Fonctionnalité (sans nouveau défaut) | `backend/services/mutations.py`, `backend/routers/files_read.py`, `backend/routers/files_write.py`, `backend/schemas.py`, `backend/xlsx_reader.py`, `backend/openapi_docs.py`, `backend/tools/{spreadsheets,schemas,labels}.py`, `frontend/js/viewer.js`, `frontend/js/xlsx/command-bar.js`, `frontend/locales/{fr,en}.json`, `frontend/style.css`, `tests/test_xlsx_styles.py`, `tests/test_xlsx_viewer.py`, `tests/test_spreadsheet_tools.py`, `tests/test_xlsx_formats.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md`, `README.md`, `README.fr.md` | **P2 + P3 livrés, backlog #156 clôturé** — (A8) **mise en forme en écriture** : bouton Mise en forme (gras/italique/souligné, alignements, couleurs via sélecteur natif, formats de nombre, fusion/défusion, volets figés, largeur/hauteur) et nouvelle route `PUT …/xlsx/style` (`mutate_xlsx_style` : verrou, backup, swap atomique, garde de perte, `If-Match`) ; (A9) décision « pas de moteur de formule » **annoncée dans l'UI** ; (A10) undo/redo unifié avec piles par fichier conservées au re-rendu ; (A11) export de la sélection + Markdown + HTML + impression et recherche sur **toutes** les feuilles (`n/m · k feuilles`) ; (A12) concurrence optimiste `If-Match` → **409** `conflict`/`stale_revision`, retry qui relit ; (A13) cache de `read_workbook_meta()` par `(chemin, mtime_ns, taille)` (LRU 8) ; (A14) outils IA `.xlsm`/`.csv` + `search_workbook`, `analyze_range`, `edit_xlsx_structure` (confirmation conservée). Vérifié : suite **1525 passed / 6 skipped**, ruff 0, mypy 0 (102 fichiers), JSDOM `xlsx-viewer.test.mjs` 107/107, validate-imports + unit, i18n parity 2355/2355, E2E `xlsx` 10/10 + `Split View` 37 + `XSS` 2. | ✅ livré |
|
||||
| 2026-09-29 | #156 (audit), BUG-096 → BUG-099 | Enregistrement (audit statique + 1 repro JSDOM) | `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md` (nouveau), `docs/ISSUES_TODOLIST.md`, `CHANGELOG.md` | **Audit de complétude de l'éditeur Excel → ouverture de l'item #156** (P0 défauts · P1 presse-papiers/clavier · P2 mise en forme/calcul/undo · P3 sortie/robustesse/perf) avec fiche dédiée. **4 défauts** enregistrés : BUG-096 (enregistrement `.csv` en `TypeError`, **reproduit en JSDOM** — `Cannot read properties of undefined (reading 'name')`, aucun `PUT …/csv/save`), BUG-097 (lazy-load `.xlsm` → 415), BUG-098 (délimiteur CSV `,` figé vs export `;`), BUG-099 (sonde de perte plafonnée à 8 Mo, risque théorique). Manques fonctionnels recensés : presse-papiers de plage, clavier complet, zone Nom éditable, mise en forme en écriture, calcul, undo/redo unifié, export/impression, concurrence optimiste, cache des métadonnées, outils IA `.xlsm`/`.csv`. **Aucun code modifié** (documentation seule). | 🔴 ouvert (à traiter) |
|
||||
| 2026-10-02 | #158 (A1-A3), BUG-100 | Fonctionnalité + correction | `frontend/js/navfacets.js` (nouveau), `frontend/js/vaulthome.js`, `frontend/js/ui.js`, `frontend/js/sidebar.js`, `frontend/js/viewer.js`, `frontend/js/legacy.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `backend/services/vaults.py`, `backend/schemas.py`, `tests/test_nav_files.py` (nouveau), `tests/frontend/navfacets.test.mjs` (nouveau), `tests/frontend/unit.test.mjs`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **A1** : `TabManager.openNav(vault, dir)` — un onglet de navigation par vault, ouvert à chaque clic répertoire de l'arbre (mode focus et mode All) sans fermer les onglets de fichiers ; la ligne vault reste un expandeur. **A2** : la vue liste les sous-répertoires cliquables et embarque le panneau de facettes **Vaults · Tags · Extensions** (mêmes classes que la page de recherche), les tris **Pertinence / Date** et le bouton **Sauver** ; `GET /api/vault/{v}/files` expose désormais les `tags` indexés (facettes calculées côté client). **A3 / BUG-100** : `showWelcome()` réinjecte `#quick-help` et `goHome()` efface recherche globale + filtre sidebar. Tests : `test_nav_files.py` 3 passed, `navfacets.test.mjs` 10/10, `unit.test.mjs` 12/12, `validate-imports` 41 modules, ruff/mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-02 | #158 (A4-A6) | Fonctionnalité | `frontend/js/vaulthome.js`, `frontend/js/ui.js`, `frontend/js/pane-manager.js`, `frontend/js/legacy.js`, `frontend/js/sidebar.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/e2e/nav-tab.spec.js` (nouveau), `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/navigation-tab-158.md` | **A4** : `TabManager.openHome()` — le clic sur le titre ouvre la page d'accueil dans un **onglet Accueil** (icône Maison, `common.home` FR/EN) remonté en position 0 à chaque fois ; `deactivate()` conserve cet onglet actif, `pane-manager.js` a son propre `openHome` pour le split. **A5** : menus contextuels répertoires **et** fichiers de la page de navigation via `ContextMenuManager.show()` (avec `stopPropagation()`, le handler global referme sinon le menu) — listes vérifiées identiques à la sidebar ; la ligne d'un vault rouvre la page de navigation. **A6** : icône des répertoires à `var(--accent)` (couleur de l'arbre). **Correctif annexé** : l'effacement du filtre sidebar au retour à l'accueil n'est déclenché que s'il était actif (sinon `restoreSidebarTree()` repliait l'arbre). Vérifié : Playwright jetable 16/16, `nav-tab.spec.js` 4 tests, validate-imports/unit/i18n parity verts. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-02 | BUG-101 | Correction | `frontend/js/ui.js` | **BUG-101 — le bloc « Keyboard shortcuts for tabs » existait en double dans `ui.js`** (fin de fichier) : un seul `Ctrl+W` déclenchait **deux** `close()`, le second refermant l'onglet ré-activé par le premier — observé avec #158 quand un onglet navigation survit à la fermeture du fichier ; `Ctrl+Tab` sautait également un onglet. Bloc dupliqué supprimé (un seul listener). Vérifié : repro Playwright (`close()` appelé 2× puis 1×) et suite E2E. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-02 | BUG-102 | Correction | `frontend/js/ui.js` | **BUG-102 — menu contextuel refermé par sa propre ouverture** : la reflow des icônes lucide à l'affichage déclenche un `scroll` capturé qui refermait le menu ~10 ms après son apparition (constaté sur les fichiers de la page de navigation, #158 A5). `show()` mémorise `_shownAt` et le listener `scroll` ignore un déclenchement dans les 250 ms. Vérifié : instrumentation Playwright + suite E2E (`nav-tab.spec.js`). | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-02 | BUG-103 | Correction | `frontend/style.css` | **BUG-103 — sidebar mobile sous la barre d'outils du bas** : en mobile le `.sidebar` (`z-index: 200`) passait sous `.mobile-toolbar` (`z-index: 900`, 64 px de haut), masquant les dernières entrées de l'arbre ; passé à `950`. Vérifié : `elementFromPoint` Playwright (393×851) + test source `mobile sidebar over toolbar` (`unit.test.mjs`). | 🟢 corrigé |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+36
-1
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.47.0 | **Dernière mise à jour :** 2026-10-01
|
||||
> **Version :** 2.49.2 | **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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+24
-4
@@ -4038,7 +4038,9 @@ body.resizing-v {
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 200;
|
||||
/* Par-dessus la barre d'outils mobile (.mobile-toolbar, z-index 900)
|
||||
pour ne pas masquer le bas du sidebar — BUG-103 */
|
||||
z-index: 950;
|
||||
width: 280px !important;
|
||||
min-width: 280px !important;
|
||||
max-width: 85vw !important;
|
||||
@@ -5472,6 +5474,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);
|
||||
@@ -5480,6 +5488,7 @@ body.resizing-v {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: flex-start; /* mobile (colonne) : ne jamais s'étirer en pleine largeur */
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
@@ -5487,15 +5496,21 @@ body.resizing-v {
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 120ms,
|
||||
border-color 120ms,
|
||||
color 120ms;
|
||||
color 120ms,
|
||||
background 120ms;
|
||||
}
|
||||
.search-facets__toggle:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.search-facets[data-collapsed="true"] .search-facets__toggle {
|
||||
/* Seule l'icône pivote — la boîte du bouton reste fixe */
|
||||
.search-facets__toggle i,
|
||||
.search-facets__toggle svg {
|
||||
transition: transform 120ms;
|
||||
}
|
||||
.search-facets[data-collapsed="true"] .search-facets__toggle i,
|
||||
.search-facets[data-collapsed="true"] .search-facets__toggle svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.search-facets__body {
|
||||
@@ -5892,6 +5907,11 @@ body.resizing-v {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Icône des répertoires : même couleur que l'arbre de la sidebar (.tree-item .icon) */
|
||||
.vault-home-dirs .icon {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.vault-home-item:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.47.0",
|
||||
"version": "2.49.2",
|
||||
"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": {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -154,6 +154,20 @@ test.describe('Full-Text Search', () => {
|
||||
await expect(page.locator('.search-facets__body')).toBeHidden();
|
||||
await page.locator('.search-facets__toggle').click();
|
||||
await expect(page.locator('.search-facets__body')).toBeVisible();
|
||||
|
||||
// mobile: the toggle stays compact (no full-width stretch) and only the
|
||||
// icon rotates — the button box itself never spins
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const toggle = page.locator('.search-facets__toggle');
|
||||
const box = await toggle.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.width).toBeLessThan(150);
|
||||
await toggle.click();
|
||||
expect(await toggle.evaluate((el) => getComputedStyle(el).transform)).toBe('none');
|
||||
const icon = toggle.locator('svg');
|
||||
expect(await icon.evaluate((el) => getComputedStyle(el).transform)).not.toBe('none');
|
||||
await toggle.click();
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
});
|
||||
|
||||
test('should navigate results with floating bar arrows', async ({ page }) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -42,19 +42,23 @@ function test(label, fn) {
|
||||
/** Body of the first @media (max-width: 768px) block whose body contains `needle`. */
|
||||
function mobileBlockContaining(needle) {
|
||||
const marker = "@media (max-width: 768px)";
|
||||
// Les commentaires sont ignorés : un sélecteur cité dans une annotation
|
||||
// (ex. « .mobile-toolbar » dans un commentaire de style.css) ne doit pas
|
||||
// faire rétrograder ce helper sur le mauvais bloc mobile (BUG-103/CI #842).
|
||||
const source = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
let idx = 0;
|
||||
while ((idx = css.indexOf(marker, idx)) !== -1) {
|
||||
const open = css.indexOf("{", idx);
|
||||
while ((idx = source.indexOf(marker, idx)) !== -1) {
|
||||
const open = source.indexOf("{", idx);
|
||||
let depth = 0;
|
||||
let end = open;
|
||||
for (; end < css.length; end++) {
|
||||
if (css[end] === "{") depth++;
|
||||
else if (css[end] === "}") {
|
||||
for (; end < source.length; end++) {
|
||||
if (source[end] === "{") depth++;
|
||||
else if (source[end] === "}") {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
const body = css.slice(open + 1, end);
|
||||
const body = source.slice(open + 1, end);
|
||||
if (body.includes(needle)) return body;
|
||||
idx = end + 1;
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
@@ -109,6 +109,7 @@ function testModulesHaveImports() {
|
||||
{ file: 'pane-manager.js', reason: 'standalone — no deps' },
|
||||
{ file: 'editor-inline.js', reason: 'standalone — DOM-only helpers for the inline editor host (#93)' },
|
||||
{ file: 'themes.js', reason: 'standalone — no deps' },
|
||||
{ file: 'navfacets.js', reason: 'pure helpers — no deps, imported by vaulthome.js' },
|
||||
{ file: 'sidebar_raw.js', reason: 'legacy file — excluded' },
|
||||
{ file: 'desktop.js', reason: 'standalone — reads window.__TAURI__ global, no imports needed' },
|
||||
];
|
||||
@@ -306,6 +307,22 @@ function testSyntaxHighlightTheme() {
|
||||
console.log(' ✓ syntax highlighting follows the theme mode (BUG-078)');
|
||||
}
|
||||
|
||||
// ── Test mobile : le sidebar passe par-dessus la barre d'outils du bas (BUG-103) ──
|
||||
function testMobileSidebarOverToolbar() {
|
||||
const css = readFileSync(join(JS_DIR, '..', 'style.css'), 'utf-8');
|
||||
const zOf = (selector) => {
|
||||
const blocks = css.match(new RegExp('\\' + selector + ' \\{[^}]*\\}', 'g')) || [];
|
||||
return Math.max(0, ...blocks.map((b) => Number((b.match(/z-index: (\d+)/) || [])[1] || 0)));
|
||||
};
|
||||
const sidebarZ = zOf('.sidebar');
|
||||
const toolbarZ = zOf('.mobile-toolbar');
|
||||
assert.ok(
|
||||
sidebarZ > toolbarZ,
|
||||
`.sidebar z-index (${sidebarZ}) doit être supérieur à .mobile-toolbar (${toolbarZ})`,
|
||||
);
|
||||
console.log(' ✓ mobile sidebar over toolbar');
|
||||
}
|
||||
|
||||
// ── Run all tests ──────────────────────────────────────────────────────────
|
||||
async function main() {
|
||||
let passed = 0, failed = 0;
|
||||
@@ -323,6 +340,7 @@ async function main() {
|
||||
['config TOC icons', testConfigTocIcons],
|
||||
['sidebar user section', testSidebarUserSection],
|
||||
['syntax highlight theme', testSyntaxHighlightTheme],
|
||||
['mobile sidebar over toolbar', testMobileSidebarOverToolbar],
|
||||
];
|
||||
|
||||
for (const [name, fn] of tests) {
|
||||
|
||||
@@ -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") == []
|
||||
Reference in New Issue
Block a user