Compare commits

...
5 Commits
Author SHA1 Message Date
bruno bab272bb5c fix: desktop — commandes Tauri bloquées par l'ACL et crash heap à l'ouverture (BUG-104, BUG-105)
CI / lint (push) Successful in 2m45s
CI / security (push) Successful in 1m59s
CI / test (push) Successful in 4m44s
CI / build (push) Successful in 1m52s
CI / e2e (push) Successful in 16m16s
2026-10-02 20:17:20 -04:00
bruno 34428ea7b3 test: mobileBlockContaining ignore les commentaires CSS (CI #842)
CI / lint (push) Successful in 2m45s
CI / security (push) Successful in 1m58s
CI / test (push) Successful in 4m41s
CI / build (push) Successful in 1m52s
CI / e2e (push) Successful in 16m32s
- le helper choisissait le premier bloc media contenant la chaine 'mobile-toolbar' ; un commentaire citant ce selecteur (correctif BUG-103) le faisait retomber sur le mauvais bloc, sans la clearance .main-body (BUG-073)
- les commentaires sont maintenant retires avant la recherche de bloc
2026-10-02 15:46:05 -04:00
bruno 83e6a951dd fix: sidebar mobile passe au-dessus de la barre d outils du bas (BUG-103)
CI / lint (push) Failing after 2m11s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 1m58s
- en mobile le sidebar de navigation (z-index 200) passait sous .mobile-toolbar (900, 64 px) : les dernieres entrees de l'arbre etaient masquees
- regle mobile .sidebar : z-index 200 -> 950 ; test de non-regression 'mobile sidebar over toolbar' dans unit.test.mjs
2026-10-02 15:34:35 -04:00
bruno 3d618eb660 ci: relance du workflow apres annulation des runs 839 et 840
CI / lint (push) Successful in 2m44s
CI / security (push) Successful in 1m57s
CI / test (push) Successful in 4m37s
CI / build (push) Successful in 1m51s
CI / e2e (push) Successful in 15m58s
2026-10-02 14:42:18 -04:00
bruno 2add24a9c1 feat: onglet Accueil, menus contextuels de navigation et racine vault #158
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
- clic sur le titre: la page d'accueil s'ouvre dans un onglet Accueil (icone Maison) epingle en tete de barre, reactivable d'un clic (onglet propre au panneau actif en split)
- page de navigation: menus contextuels des repertoires et des fichiers identiques a ceux de la sidebar, clic sur la racine d'un vault qui ouvre la vue, icone dossier a la couleur de l'arbre de navigation
- BUG-101: bloc « Keyboard shortcuts for tabs » duplique dans ui.js (un seul Ctrl+W fermait deux onglets)
- BUG-102: le menu contextuel se refermait 10 ms apres son ouverture (scroll de reflow des icones lucide)
2026-10-02 14:05:01 -04:00
33 changed files with 517 additions and 127 deletions
+72 -1
View File
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.48.0**.
> [Unreleased](#unreleased). La dernière version livrée est **2.49.4**.
---
@@ -14,6 +14,77 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.49.4] — 2026-10-02
---
## [2.49.3] — 2026-10-02
### Corrigé
- **BUG-104 — commandes desktop bloquées par l'ACL Tauri** : la fenêtre desktop sert
sa page depuis `http://127.0.0.1` (origine *remote*) et aucune commande applicative
n'était déclarée — toutes étaient rejetées « not allowed by ACL », erreurs avalées
par `desktop.js:invoke()`. Nouveau manifeste `desktop/permissions/commands.toml`
(`allow-app-commands`, 19 commandes) référencé par la capability, avec un test
garde-fou (`test_frontend_invokes_are_acl_allowed`). Le bouton « Choisir mon
dossier » du wizard et toute la gestion vaults/dossiers desktop fonctionnent.
- **BUG-105 — crash du desktop à l'ouverture (heap corruption `c0000374`)** : le
setter de titre du menu contextuel des raccourcis (#77) confiait un `PROPVARIANT
VT_LPWSTR` sur un buffer heap Rust au property store du shell, qui le libérait avec
l'allocateur Windows — l'application se fermait 2 à 15 s après le lancement. Fonction
supprimée (libellé assuré par `SetDescription`).
---
## [2.49.2] — 2026-10-02
### Corrigé
- Tests : `mobileBlockContaining` ignore les commentaires CSS — un sélecteur cité dans
une annotation ne faisait plus trouver le bon bloc mobile (CI #842).
---
## [2.49.1] — 2026-10-02
### Corrigé
- **Mobile (BUG-103)** : le bas du sidebar de navigation n'est plus masqué par la barre
d'outils du bas — `z-index` du sidebar mobile `200` → `950` (au-dessus de
`.mobile-toolbar`, `900`).
---
## [2.49.0] — 2026-10-02
### Ajouté
- **#158 — onglet Accueil.** Le clic sur le titre ouvre désormais la page d'accueil **dans un
onglet** (icône Maison) systématiquement épinglé **en tête de barre**, réactivable d'un clic
comme un onglet normal (le mode split a son propre onglet Accueil dans le panneau actif).
- **#158 — menus contextuels dans la page de navigation.** Clic droit sur les répertoires et
les fichiers de la vue : même menu que celui de la sidebar (`ContextMenuManager`), et la
ligne d'un vault dans l'arbre ouvre à nouveau la page de navigation (racine du vault).
- **#158 — icône des répertoires alignée sur la sidebar.** La couleur du dossier dans la
section Répertoires est celle de l'arbre (`var(--accent)`).
### Corrigé
- **BUG-102 — menu contextuel refermé par son propre affichage.** L'auto-scroll déclenché par
la reflow des icônes (lucide) à l'ouverture du menu arrivait dans le listener `scroll`
(capture) et refermait le menu ~10 ms après son apparition : un décalage de 250 ms est
ignoré, les vrais défilements ferment toujours le menu.
- **BUG-101 — raccourcis d'onglets déclarés deux fois.** Le bloc « Keyboard shortcuts for
tabs » existed en double dans `ui.js` (fin de fichier) : un seul `Ctrl+W` fermait **deux**
onglets (le second refermait celui qui venait d'être ré-activé) et `Ctrl+Tab` sautait un
onglet. Bloc dupliqué supprimé.
- **#158 — clic titre ne repliait plus l'arborescence.** L'effacement du filtre de la sidebar
n'est déclenché que s'il contenait quelque chose (sinon `restoreSidebarTree()` repliait tout
l'arbre à chaque retour à l'accueil).
---
## [2.48.0] — 2026-10-02
### Ajouté
+3 -3
View File
@@ -4,7 +4,7 @@
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
[![Version](https://img.shields.io/badge/Version-2.48.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.49.4-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -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.48.0).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.49.4).
---
*Projet : ObsiGate | Version : 2.48.0 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.49.4 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-2.48.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.49.4-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -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.48.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.49.4).
---
*Project: ObsiGate | Version: 2.48.0 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.49.4 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.48.0
2.49.4
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.48.0"
version = "2.49.4"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.48.0"
version = "2.49.4"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+2
View File
@@ -38,5 +38,7 @@ fn main() {
println!("cargo:rerun-if-changed=.git/refs/heads/main");
println!("cargo:rerun-if-changed=.git/refs/tags");
println!("cargo:rerun-if-changed=permissions");
tauri_build::build()
}
+1
View File
@@ -7,6 +7,7 @@
},
"permissions": [
"core:default",
"allow-app-commands",
"shell:allow-open",
"shell:allow-execute",
"dialog:default",
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for ObsiGate Desktop","remote":{"urls":["http://127.0.0.1:*","http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","shell:allow-execute","dialog:default","notification:default","fs:default","process:default","store:default"]}}
{"default":{"identifier":"default","description":"Default capabilities for ObsiGate Desktop","remote":{"urls":["http://127.0.0.1:*","http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","allow-app-commands","shell:allow-open","shell:allow-execute","dialog:default","notification:default","fs:default","process:default","store:default"]}}
+6
View File
@@ -2210,6 +2210,12 @@
"Identifier": {
"description": "Permission identifier",
"oneOf": [
{
"description": "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1).",
"type": "string",
"const": "allow-app-commands",
"markdownDescription": "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1)."
},
{
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
"type": "string",
+6
View File
@@ -2210,6 +2210,12 @@
"Identifier": {
"description": "Permission identifier",
"oneOf": [
{
"description": "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1).",
"type": "string",
"const": "allow-app-commands",
"markdownDescription": "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1)."
},
{
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
"type": "string",
+35
View File
@@ -0,0 +1,35 @@
# Commandes applicatives ObsiGate — autorisées depuis l'origine distante.
#
# La fenêtre Tauri redirige vers http://127.0.0.1:<port> (page servie par le
# backend Python). Pour Tauri v2 c'est une origine *remote* : sans entrée ACL
# explicite, TOUTE commande de l'app (invoke_handler) est rejetée avec
# "Command ... not allowed by ACL" — le bouton « Choisir mon dossier » et le
# wizard ne faisaient alors rien (erreurs avalées par desktop.js:invoke()).
#
# Auto-généré en… non : édité à la main. Liste = tauri::generate_handler![…]
# dans src/main.rs — ajouter toute nouvelle commande ici.
[[permission]]
identifier = "allow-app-commands"
description = "Commandes de l'application ObsiGate appelées depuis la page backend (http://127.0.0.1)."
commands.allow = [
"get_backend_url",
"get_version",
"get_config",
"save_vault_path",
"get_vault_path",
"get_wizard_state",
"complete_wizard",
"pick_vault_folder",
"get_system_theme",
"restart_backend",
"check_backend_health",
"save_window_state",
"get_window_state",
"add_vault",
"remove_vault",
"list_vaults",
"add_dir",
"remove_dir",
"list_dirs",
]
+19 -43
View File
@@ -10,48 +10,23 @@ use log::{info, warn};
#[cfg(target_os = "windows")]
mod imp {
use super::*;
use std::os::windows::ffi::OsStrExt;
use windows::core::{Interface, HSTRING, PCWSTR};
use windows::Win32::Foundation::PROPERTYKEY;
use windows::Win32::System::Com::{
CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED,
};
use windows::Win32::System::Com::StructuredStorage::PROPVARIANT;
use windows::Win32::System::Variant::VT_LPWSTR;
use windows::Win32::UI::Shell::{
ICustomDestinationList, IShellLinkW, SetCurrentProcessExplicitAppUserModelID,
DestinationList, EnumerableObjectCollection, ShellLink,
};
use windows::Win32::UI::Shell::Common::IObjectCollection;
use windows::Win32::UI::Shell::PropertiesSystem::IPropertyStore;
use super::*;
use std::os::windows::ffi::OsStrExt;
use windows::core::{Interface, HSTRING, PCWSTR};
use windows::Win32::System::Com::{
CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED,
};
use windows::Win32::UI::Shell::{
ICustomDestinationList, IShellLinkW, SetCurrentProcessExplicitAppUserModelID,
DestinationList, EnumerableObjectCollection, ShellLink,
};
use windows::Win32::UI::Shell::Common::IObjectCollection;
const APP_ID: &str = "com.obsigate.desktop";
const APP_ID: &str = "com.obsigate.desktop";
// PKEY_Title (System.Title): {F29F85E0-4FF9-1068-AB91-08002B27B3D9}, pid 2
const PKEY_TITLE: PROPERTYKEY = PROPERTYKEY {
fmtid: windows::core::GUID::from_u128(0xF29F85E0_4FF9_1068_AB91_08002B27B3D9),
pid: 2,
};
fn wide_null_terminated(s: &str) -> Vec<u16> {
std::ffi::OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect()
}
/// Set the jump-list display title via the shell link's property store.
fn set_link_title(link: &IShellLinkW, title: &str) -> windows::core::Result<()> {
let store: IPropertyStore = link.cast()?;
let mut wide = wide_null_terminated(title);
let mut pv: PROPVARIANT = unsafe { std::mem::zeroed() };
unsafe {
let inner = &mut *pv.Anonymous.Anonymous;
inner.vt = VT_LPWSTR;
inner.Anonymous.pwszVal = windows::core::PWSTR(wide.as_mut_ptr());
store.SetValue(&PKEY_TITLE, &pv)?;
store.Commit()?;
}
Ok(())
}
fn wide_null_terminated(s: &str) -> Vec<u16> {
std::ffi::OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect()
}
/// Build one shell link for a vault. `arg` is the command-line argument the
/// app expects to open that vault (see file associations / single-instance).
@@ -70,9 +45,10 @@ mod imp {
}
link.SetDescription(&HSTRING::from(name))?;
}
// Title (display name) is best-effort — a missing title only means the
// jump-list entry falls back to the executable name.
let _ = set_link_title(&link, name);
// Title (display name): the raw PROPVARIANT setter that used to live
// here corrupted the process heap (STATUS_HEAP_CORRUPTION c0000374 —
// the shell property store freed our Rust-allocated VT_LPWSTR buffer).
// SetDescription below already gives jump-list entries their label.
Ok(link)
}
+61
View File
@@ -1122,4 +1122,65 @@ mod tests {
let dbg = format!("{:?}", d);
assert!(dbg.contains("x"));
}
/// Guardrail ACL : toute commande Tauri invoquée par le frontend doit
/// figurer dans desktop/permissions/commands.toml. La page servie par le
/// backend (http://127.0.0.1) est une origine *remote* pour Tauri v2 :
/// sans cette entrée, la commande est rejetée « not allowed by ACL » et
/// desktop.js:invoke() avale l'erreur → bouton silencieusement mort
/// (bouton « Choisir mon dossier » du wizard, BUG-104).
#[test]
fn test_frontend_invokes_are_acl_allowed() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let perms = fs::read_to_string(dir.join("permissions/commands.toml"))
.expect("desktop/permissions/commands.toml manquant");
let main_src = fs::read_to_string(dir.join("src/main.rs")).unwrap();
// Commandes déclarées dans le manifeste de permissions.
let allow_list = perms
.split("commands.allow")
.nth(1)
.and_then(|s| s.split(']').next())
.unwrap_or("");
let allowed: Vec<String> = allow_list
.split('"')
.skip(1)
.step_by(2)
.map(|s| s.to_string())
.collect();
assert!(!allowed.is_empty(), "commands.allow vide dans commands.toml");
// Chaque permission doit correspondre à une commande réellement
// enregistrée (faute de frappe → permission morte).
for cmd in &allowed {
assert!(
main_src.contains(&format!("fn {cmd}(")),
"permission allow pour la commande inconnue {cmd}"
);
}
// Toute commande invoke('…') du frontend doit être autorisée.
let js_dir = dir.join("../frontend/js");
let mut seen = 0usize;
for entry in fs::read_dir(&js_dir).unwrap() {
let path = entry.unwrap().path();
if path.extension().and_then(|e| e.to_str()) != Some("js") { continue; }
let src = fs::read_to_string(&path).unwrap();
let mut rest = src.as_str();
while let Some(pos) = rest.find("invoke('") {
rest = &rest[pos + "invoke('".len()..];
let name: String = rest.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
if !name.is_empty() {
seen += 1;
assert!(
allowed.contains(&name),
"commande {name} invoquée par {} absente de permissions/commands.toml",
path.file_name().unwrap().to_string_lossy()
);
}
rest = &rest[rest.find('\'').map(|i| i + 1).unwrap_or(rest.len())..];
}
}
assert!(seen > 0, "aucun invoke('…') trouvé dans frontend/js");
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.48.0",
"version": "2.49.4",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+11
View File
@@ -208,6 +208,11 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-098* | [🟡 IMPORTANT] Délimiteur CSV figé `,` : un `.csv` français (`;`) s'affiche en une seule colonne et se réécrit dans un autre format | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/xlsx_reader.py` (`render_csv_table`, `delimiter=","` par défaut), `backend/routers/files_read.py:551`, `backend/services/mutations.py` (`save_csv_cells`) | Ouvrir un CSV `;` (export Excel FR) dans ObsiGate : une seule colonne ; éditer une cellule puis enregistrer : le fichier est réécrit en `,` | `backend/xlsx_reader.py` (`sniff_csv_delimiter`) + `backend/services/mutations.py::save_csv_cells` : délimiteur détecté et **réutilisé**. Tests `TestCsvDelimiter` + lecture/écriture/guillemets point-virgule | Défaut #156 A3. `render_csv_table(raw)` est appelé sans délimiteur et `save_csv_cells()` re-parse en `,`, alors que l'export CSV de la visionneuse écrit en `;`. Traitement prévu : détection `;`/`,`/tab partagée lecture/écriture/export. Analyse : `docs/features/xlsx-editor-completeness.md` |
| *BUG-099* | [🔵 MINEUR] Sonde de perte plafonnée (8 Mo, budget global) : risque de perte **silencieuse** des valeurs calculées en cache au-delà du budget | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/xlsx_reader.py` (`_MAX_PROBE_BYTES`, `_has_cached_formulas`, `inspect_workbook`) | Constituer un classeur dont le XML de feuille dépasse 8 Mo **avant** la première formule cachée, puis l'éditer : la garde 409 `xlsx_lossy_content` ne se déclenche pas | `backend/xlsx_reader.py` : budget **par feuille** (4 Mo) + plafond global (32 Mo) ; `_scan_cached_formulas()` renvoie `(found, unverified)` et `inspect_workbook()` ajoute `cached_values_unverified` (libellé i18n FR/EN). Contre-preuve : budget épuisé → signalé au lieu de `[]`. Tests `TestXlsxCachedValueProbe` (3) | Défaut #156 A4, **analyse statique (non reproduit)**. Le budget est partagé entre toutes les feuilles : au-delà, `cached_values` n'est pas détecté et l'écriture détruit ces valeurs sans avertissement (risque n°1 de #153). Traitement prévu : budget par feuille + signal d'incertitude pour que la garde reste prudente. Analyse : `docs/features/xlsx-editor-completeness.md` |
| *BUG-100* | Retour à l'accueil incomplet : le clic sur le titre perd la section « Raccourcis & Astuces » et laisse la recherche globale ainsi que le filtre de la sidebar actifs | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/viewer.js` (`showWelcome`), `frontend/js/legacy.js` (`goHome`) | Ouvrir un fichier (le dashboard est détruit), puis cliquer le titre ObsiGate | `viewer.js` : `#quick-help` capturé au chargement du module et réinjecté dans le template reconstruit de `showWelcome()` ; `legacy.js::goHome` clique `#search-clear-btn` (chips + barre de résultats) et `#sidebar-filter-clear-btn` avant d'afficher l'accueil | **Cause racine :** `showWelcome()` reconstruit `#dashboard-home` depuis un template qui n'embarque pas le bloc `#quick-help` présent dans `index.html` — il ne disparaissait qu'après la première reconstruction (ouverture de fichier, puis retour). Livré avec #158 A3 |
| *BUG-101* | Raccourcis d'onglets déclarés deux fois : un seul `Ctrl+W` fermait deux onglets (le second fermait celui ré-activé par la fermeture précédente) et `Ctrl+Tab` sautait un onglet | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/ui.js` (bloc « Keyboard shortcuts for tabs » dupliqué en fin de fichier) | Ouvrir deux onglets puis `Ctrl+W` une fois | Suppression du second bloc `document.addEventListener("keydown", …)` dupliqué (fin de `ui.js`) — un seul listener reste | Exposé par #158 : le second onglet n'était plus jamais vide auparavant (la 2ᵉ fermeture tombait sur `_activeTabId === null` et devenait sans effet). Vérifié : repro Playwright (`closes: ["fichier", "nav"]` avant → `["fichier"]` après) + suite E2E |
| *BUG-102* | Menu contextuel qui se referme ~10 ms après son ouverture : la reflow des icônes (remplacement `<i>` → `<svg>` par lucide) déclenche un événement `scroll` capturé par `ContextMenuManager` qui referme le menu | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/ui.js` (`ContextMenuManager.init/show`) | Clic droit sur un fichier de la page de navigation (ou de l'arbre) juste après un rendu d'icônes | `show()` mémorise `_shownAt` ; le listener `scroll` (capture) ignore un déclenchement dans les 250 ms suivant l'ouverture | Exposé par #158 A5 (test E2E `nav-tab.spec.js` : menu résolu « hidden » alors que 6 items étaient construits). Vérifié : instrumentation Playwright (display block conservé après le scroll) + suite E2E |
| *BUG-103* | Mobile : le bas du sidebar de navigation est masqué par la barre d'outils du bas (`z-index` 200 < 900) | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/style.css` (règle `.sidebar` du bloc `@media (max-width: 768px)`) | Vue mobile (≤768 px), sidebar ouvert depuis « Explorateur » | Passé à `z-index: 950` (au-dessus de `.mobile-toolbar`, 900) | Exposé après #158 (retour utilisateur). Vérifié : `elementFromPoint` Playwright (393×851) + test source `mobile sidebar over toolbar` (`unit.test.mjs`) |
| *BUG-104* | Desktop : toutes les commandes Tauri invoquées depuis la page backend (`http://127.0.0.1`) rejetées « not allowed by ACL » — le bouton « Choisir mon dossier » du wizard ne fait rien | 🟢 corrigé | P1 | 🖥️ desktop | IA | `desktop/permissions/commands.toml` (nouveau), `desktop/capabilities/default.json`, `desktop/build.rs` | Installeur 2.49.2 : clic sur « Choisir mon dossier » → aucune fenêtre ; console `[desktop] invoke(pick_vault_folder) failed: Command … not allowed by ACL` (capturée via CDP, WebView2 `--remote-debugging-port`) | Manifeste de permissions applicatives créé (`allow-app-commands`, 19 commandes) et référencé par la capability : Tauri v2 ACL gate toute commande invoquée depuis une origine *remote*, y compris celles de l'`invoke_handler` | `cargo test` 25 passed (nouveau garde-fou `test_frontend_invokes_are_acl_allowed`) + vérif live CDP : `get_version`→2.49.2, clic → fenêtre native « Select Folder », parcours complet ajout de vault terminé |
| *BUG-105* | Desktop : crash 2-15 s après l'ouverture de la fenêtre (`APPCRASH c0000374`, heap corruption détectée dans ntdll) — absent de v2.0.0, apparu en 2.49.2 | 🟢 corrigé | P1 | 🖥️ desktop | IA | `desktop/src/jumplist.rs` (`set_link_title`) | Installeur 2.49.2 : fenêtre visible quelques secondes puis fermeture (3/3), Event Log Windows `APPCRASH obsigate-desktop.exe … c0000374`, rien dans stderr | `set_link_title` (#77) construisait un `PROPVARIANT VT_LPWSTR` pointant un buffer heap Rust et le confiait au property store du shell, qui le libère avec l'allocateur Windows → corruption du tas ; fonction supprimée (`SetDescription` fournit déjà le libellé) | Bisect 3 états (jumplist off = stable, titre off = stable, d'origine = crash) ; run 9 min sans crash + sortie propre `EXIT=0`, zéro événement WER (avant : 3 crashes ≤20 s) |
| | | | | | | | | | |
### TODOs techniques (améliorations / nouvelles tâches)
@@ -311,6 +316,12 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-09-30 | #156 A8-A14 (P2 + P3) — clôture du backlog | Fonctionnalité (sans nouveau défaut) | `backend/services/mutations.py`, `backend/routers/files_read.py`, `backend/routers/files_write.py`, `backend/schemas.py`, `backend/xlsx_reader.py`, `backend/openapi_docs.py`, `backend/tools/{spreadsheets,schemas,labels}.py`, `frontend/js/viewer.js`, `frontend/js/xlsx/command-bar.js`, `frontend/locales/{fr,en}.json`, `frontend/style.css`, `tests/test_xlsx_styles.py`, `tests/test_xlsx_viewer.py`, `tests/test_spreadsheet_tools.py`, `tests/test_xlsx_formats.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md`, `README.md`, `README.fr.md` | **P2 + P3 livrés, backlog #156 clôturé** — (A8) **mise en forme en écriture** : bouton Mise en forme (gras/italique/souligné, alignements, couleurs via sélecteur natif, formats de nombre, fusion/défusion, volets figés, largeur/hauteur) et nouvelle route `PUT …/xlsx/style` (`mutate_xlsx_style` : verrou, backup, swap atomique, garde de perte, `If-Match`) ; (A9) décision « pas de moteur de formule » **annoncée dans l'UI** ; (A10) undo/redo unifié avec piles par fichier conservées au re-rendu ; (A11) export de la sélection + Markdown + HTML + impression et recherche sur **toutes** les feuilles (`n/m · k feuilles`) ; (A12) concurrence optimiste `If-Match` → **409** `conflict`/`stale_revision`, retry qui relit ; (A13) cache de `read_workbook_meta()` par `(chemin, mtime_ns, taille)` (LRU 8) ; (A14) outils IA `.xlsm`/`.csv` + `search_workbook`, `analyze_range`, `edit_xlsx_structure` (confirmation conservée). Vérifié : suite **1525 passed / 6 skipped**, ruff 0, mypy 0 (102 fichiers), JSDOM `xlsx-viewer.test.mjs` 107/107, validate-imports + unit, i18n parity 2355/2355, E2E `xlsx` 10/10 + `Split View` 37 + `XSS` 2. | ✅ livré |
| 2026-09-29 | #156 (audit), BUG-096 → BUG-099 | Enregistrement (audit statique + 1 repro JSDOM) | `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md` (nouveau), `docs/ISSUES_TODOLIST.md`, `CHANGELOG.md` | **Audit de complétude de l'éditeur Excel → ouverture de l'item #156** (P0 défauts · P1 presse-papiers/clavier · P2 mise en forme/calcul/undo · P3 sortie/robustesse/perf) avec fiche dédiée. **4 défauts** enregistrés : BUG-096 (enregistrement `.csv` en `TypeError`, **reproduit en JSDOM** — `Cannot read properties of undefined (reading 'name')`, aucun `PUT …/csv/save`), BUG-097 (lazy-load `.xlsm` → 415), BUG-098 (délimiteur CSV `,` figé vs export `;`), BUG-099 (sonde de perte plafonnée à 8 Mo, risque théorique). Manques fonctionnels recensés : presse-papiers de plage, clavier complet, zone Nom éditable, mise en forme en écriture, calcul, undo/redo unifié, export/impression, concurrence optimiste, cache des métadonnées, outils IA `.xlsm`/`.csv`. **Aucun code modifié** (documentation seule). | 🔴 ouvert (à traiter) |
| 2026-10-02 | #158 (A1-A3), BUG-100 | Fonctionnalité + correction | `frontend/js/navfacets.js` (nouveau), `frontend/js/vaulthome.js`, `frontend/js/ui.js`, `frontend/js/sidebar.js`, `frontend/js/viewer.js`, `frontend/js/legacy.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `backend/services/vaults.py`, `backend/schemas.py`, `tests/test_nav_files.py` (nouveau), `tests/frontend/navfacets.test.mjs` (nouveau), `tests/frontend/unit.test.mjs`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **A1** : `TabManager.openNav(vault, dir)` — un onglet de navigation par vault, ouvert à chaque clic répertoire de l'arbre (mode focus et mode All) sans fermer les onglets de fichiers ; la ligne vault reste un expandeur. **A2** : la vue liste les sous-répertoires cliquables et embarque le panneau de facettes **Vaults · Tags · Extensions** (mêmes classes que la page de recherche), les tris **Pertinence / Date** et le bouton **Sauver** ; `GET /api/vault/{v}/files` expose désormais les `tags` indexés (facettes calculées côté client). **A3 / BUG-100** : `showWelcome()` réinjecte `#quick-help` et `goHome()` efface recherche globale + filtre sidebar. Tests : `test_nav_files.py` 3 passed, `navfacets.test.mjs` 10/10, `unit.test.mjs` 12/12, `validate-imports` 41 modules, ruff/mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-02 | #158 (A4-A6) | Fonctionnalité | `frontend/js/vaulthome.js`, `frontend/js/ui.js`, `frontend/js/pane-manager.js`, `frontend/js/legacy.js`, `frontend/js/sidebar.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/e2e/nav-tab.spec.js` (nouveau), `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/navigation-tab-158.md` | **A4** : `TabManager.openHome()` — le clic sur le titre ouvre la page d'accueil dans un **onglet Accueil** (icône Maison, `common.home` FR/EN) remonté en position 0 à chaque fois ; `deactivate()` conserve cet onglet actif, `pane-manager.js` a son propre `openHome` pour le split. **A5** : menus contextuels répertoires **et** fichiers de la page de navigation via `ContextMenuManager.show()` (avec `stopPropagation()`, le handler global referme sinon le menu) — listes vérifiées identiques à la sidebar ; la ligne d'un vault rouvre la page de navigation. **A6** : icône des répertoires à `var(--accent)` (couleur de l'arbre). **Correctif annexé** : l'effacement du filtre sidebar au retour à l'accueil n'est déclenché que s'il était actif (sinon `restoreSidebarTree()` repliait l'arbre). Vérifié : Playwright jetable 16/16, `nav-tab.spec.js` 4 tests, validate-imports/unit/i18n parity verts. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-02 | BUG-101 | Correction | `frontend/js/ui.js` | **BUG-101 — le bloc « Keyboard shortcuts for tabs » existait en double dans `ui.js`** (fin de fichier) : un seul `Ctrl+W` déclenchait **deux** `close()`, le second refermant l'onglet ré-activé par le premier — observé avec #158 quand un onglet navigation survit à la fermeture du fichier ; `Ctrl+Tab` sautait également un onglet. Bloc dupliqué supprimé (un seul listener). Vérifié : repro Playwright (`close()` appelé 2× puis 1×) et suite E2E. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-02 | BUG-102 | Correction | `frontend/js/ui.js` | **BUG-102 — menu contextuel refermé par sa propre ouverture** : la reflow des icônes lucide à l'affichage déclenche un `scroll` capturé qui refermait le menu ~10 ms après son apparition (constaté sur les fichiers de la page de navigation, #158 A5). `show()` mémorise `_shownAt` et le listener `scroll` ignore un déclenchement dans les 250 ms. Vérifié : instrumentation Playwright + suite E2E (`nav-tab.spec.js`). | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-02 | BUG-103 | Correction | `frontend/style.css` | **BUG-103 — sidebar mobile sous la barre d'outils du bas** : en mobile le `.sidebar` (`z-index: 200`) passait sous `.mobile-toolbar` (`z-index: 900`, 64 px de haut), masquant les dernières entrées de l'arbre ; passé à `950`. Vérifié : `elementFromPoint` Playwright (393×851) + test source `mobile sidebar over toolbar` (`unit.test.mjs`). | 🟢 corrigé |
| 2026-10-02 | BUG-104 | Correction | `desktop/permissions/commands.toml` (nouveau), `desktop/capabilities/default.json`, `desktop/build.rs` | **BUG-104 — commandes Tauri rejetées par l'ACL depuis la page backend** : la fenêtre redirige vers `http://127.0.0.1:<port>`, origine *remote* pour Tauri v2, et aucune commande applicative n'était déclarée → `Command … not allowed by ACL` sur `pick_vault_folder`, `get_wizard_state`, etc. ; les erreurs sont avalées par `desktop.js:invoke()` → bouton du wizard silencieusement inopérant depuis #77. Correctif : manifeste `desktop/permissions/commands.toml` (`allow-app-commands`, 19 commandes) référencé par la capability + `rerun-if-changed=permissions` dans `build.rs`. Vérifié : `cargo test` 25 passed (nouveau garde-fou ACL), CDP live : `get_version` OK, clic → fenêtre native « Select Folder », ajout de vault complété de bout en bout. | 🟢 corrigé |
| 2026-10-02 | BUG-105 | Correction | `desktop/src/jumplist.rs` | **BUG-105 — crash heap (c0000374) à l'ouverture du desktop** : `set_link_title` (#77) passait un `PROPVARIANT VT_LPWSTR` pointant un buffer heap Rust au property store du shell, qui le libère avec l'allocateur Windows → `STATUS_HEAP_CORRUPTION` détectée dans ntdll ≤20 s après le lancement (3/3, Event Log WER, même empreinte de pile). Fonction supprimée : le libellé des entrées vient de `SetDescription`. Vérifié : bisect sur 3 états de build, run 9 min `EXIT=0`, zéro événement WER. | 🟢 corrigé |
---
+9 -2
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.48.0 | **Dernière mise à jour :** 2026-10-02
> **Version :** 2.49.4 | **Dernière mise à jour :** 2026-10-02
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
@@ -161,6 +161,13 @@
- [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)`).
---
@@ -332,7 +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) + retour Home complet (**BUG-100**) | 2.48.0 | [features/navigation-tab-158.md](./features/navigation-tab-158.md) |
| 158 | Navigation — clic répertoire → **onglet de navigation** (sous-répertoires, facettes Vaults/Tags/Extensions, tri Pertinence/Date, Sauver), **onglet Accueil**, menus contextuels + retour Home complet (**BUG-100** → **BUG-102**) | 2.48.0→2.49.0 | [features/navigation-tab-158.md](./features/navigation-tab-158.md) |
---
+18 -5
View File
@@ -23,11 +23,20 @@ ne reste « actif », donc un clic suivant ré-éffectue réellement l'onglet (a
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), 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). La **ligne d'un vault** garde son rôle d'expansion seul : un onglet de navigation à
l'ouverture d'un vault casserait les compteurs d'onglets et « fermer le dernier onglet →
dashboard » (13 tests E2E `split-view*`).
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) ·
@@ -57,3 +66,7 @@ repli `obsigate_facets_collapsed`, filtrage **local** (pas de nouvelle requête)
- `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.
+8 -2
View File
@@ -292,8 +292,9 @@ function goHome() {
// 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) sideClear.click();
if (sideClear && sideInput && sideInput.value) sideClear.click();
const searchInput = document.getElementById("search-input");
if (searchInput) searchInput.value = "";
@@ -306,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();
}
}
// =========================================================================
+23
View File
@@ -105,6 +105,20 @@ function createPaneTabManager(paneId) {
this.activate(tabId);
},
async openHome(name = "Home") {
const tabId = "home";
const idx = this._tabs.findIndex((x) => x.id === tabId);
if (idx > 0) {
const [tab] = this._tabs.splice(idx, 1);
this._tabs.unshift(tab);
} else if (idx === -1) {
this._tabs.unshift({ id: tabId, vault: null, path: "", name, icon: "home", home: true });
this._tabCache[tabId] = { home: true, vault: null, path: "", title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon: "home" };
}
this._renderTabs();
this.activate(tabId);
},
async openPersistent(vault, path) {
const tabId = `${vault}::${path}`;
const previewTab = this._tabs.find(t => t.id === tabId && t.preview);
@@ -166,6 +180,15 @@ function createPaneTabManager(paneId) {
const cache = this._tabCache[tabId];
if (!cache) return;
// Onglet Accueil (#158) — la page d'accueil vit dans l'onglet
if (cache.home) {
state.currentVault = null;
state.currentPath = null;
this._showDashboard();
if (history.pushState) history.pushState(null, '', '#');
return;
}
state.currentVault = cache.vault;
state.currentPath = cache.path;
// syncActiveFileTreeItem is imported below to avoid circular deps
+2 -2
View File
@@ -144,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();
@@ -241,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();
+48 -30
View File
@@ -1776,7 +1776,12 @@ export const ContextMenuManager = {
if (e.key === 'Escape') this.hide();
});
document.addEventListener('scroll', () => this.hide(), true);
document.addEventListener('scroll', () => {
// Un scroll déclenché juste après l'ouverture vient de la reflow des
// icônes (lucide) : on ne referme pas le menu qui vient d'apparaître.
if (performance.now() - (this._shownAt || 0) < 250) return;
this.hide();
}, true);
},
show(x, y, vault, path, type, isReadonly) {
@@ -1822,6 +1827,7 @@ export const ContextMenuManager = {
}
this._menu.classList.add('active');
this._shownAt = performance.now();
const rect = this._menu.getBoundingClientRect();
const viewportWidth = window.innerWidth;
@@ -2133,9 +2139,38 @@ export const TabManager = {
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. */
* 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();
},
@@ -2160,6 +2195,17 @@ 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;
@@ -2556,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 ----
+15 -2
View File
@@ -19,7 +19,7 @@
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';
@@ -394,11 +394,18 @@ function renderResults() {
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" style="width:14px;height:14px;opacity:0.6;flex-shrink:0"></i>
<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);
@@ -474,6 +481,12 @@ function buildFileItem(f, locale) {
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;
}
+1
View File
@@ -344,6 +344,7 @@
"common.previous": "Previous",
"common.refresh": "Refresh",
"common.rename": "Rename",
"common.home": "Home",
"common.root": "(root)",
"common.save": "Save",
"common.search": "Search",
+1
View File
@@ -344,6 +344,7 @@
"common.previous": "Précédent",
"common.refresh": "Rafraîchir",
"common.rename": "Renommer",
"common.home": "Accueil",
"common.root": "(racine)",
"common.save": "Enregistrer",
"common.search": "Rechercher",
+8 -1
View File
@@ -4038,7 +4038,9 @@ body.resizing-v {
top: 0;
left: 0;
bottom: 0;
z-index: 200;
/* Par-dessus la barre d'outils mobile (.mobile-toolbar, z-index 900)
pour ne pas masquer le bas du sidebar — BUG-103 */
z-index: 950;
width: 280px !important;
min-width: 280px !important;
max-width: 85vw !important;
@@ -5905,6 +5907,11 @@ body.resizing-v {
cursor: pointer;
}
/* Icône des répertoires : même couleur que l'arbre de la sidebar (.tree-item .icon) */
.vault-home-dirs .icon {
color: var(--accent);
}
.vault-home-item:hover {
background: var(--bg-secondary);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.48.0",
"version": "2.49.4",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
"main": "patch.js",
"directories": {
+106
View File
@@ -0,0 +1,106 @@
/**
* E2E — Page de navigation & onglet Accueil (#158).
*
* Couvre :
* - clic sur la racine d'un vault -> page de navigation (onglet dédié)
* - icône des répertoires à la couleur de l'arbre de la sidebar
* - menu contextuel des répertoires et des fichiers identique à la sidebar
* - clic sur le titre -> onglet Accueil (icône Maison) épinglé en tête de barre
*/
import { test, expect } from '@playwright/test';
async function boot(page) {
await page.goto('/');
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
await page.waitForSelector('#dashboard-home, #search-input', { timeout: 15000 });
}
const menuItems = (page) =>
page.$$eval('#context-menu .context-menu-item', (els) => els.map((e) => e.textContent.trim()))
.catch(() => []);
test.describe('Page de navigation (#158)', () => {
test.beforeEach(async ({ page }) => {
await boot(page);
});
test("clic sur la racine d'un vault ouvre la page de navigation", async ({ page }) => {
const vault = await page.locator('.vault-item').first().getAttribute('data-vault');
await page.locator('.vault-item').first().click();
await page.waitForSelector('#vault-home .vault-home-breadcrumb', { timeout: 15000 });
await expect(page.locator(`#tab-list .tab-item[data-tab-id="nav::${vault}"]`)).toHaveCount(1);
// Le fil d'Ariane est à la racine du vault
await expect(page.locator('#vault-home .vault-home-breadcrumb-item')).toHaveCount(1);
});
test("répertoires de la vue : icône à la couleur de la sidebar + menu contextuel", async ({ page }) => {
await page.locator('.vault-item').first().click();
await page.waitForSelector('#vault-home .vault-home-dirs .vault-home-recent-item', { timeout: 15000 });
// Couleur identique à l'icône de l'arbre (.tree-item .icon -> var(--accent))
const colors = await page.evaluate(() => {
const cs = (el) => (el ? getComputedStyle(el).color : null);
return {
nav: cs(document.querySelector('#vault-home .vault-home-dirs .vault-home-recent-item svg')),
side: cs(document.querySelector('.tree-item[data-type="directory"] svg')),
};
});
expect(colors.nav).toBe(colors.side);
expect(colors.nav).toBeTruthy();
// Menu contextuel : page de navigation vs sidebar
await page.locator('#vault-home .vault-home-dirs .vault-home-recent-item').first().click({ button: 'right' });
await page.waitForSelector('#context-menu', { timeout: 5000 });
const navMenu = await menuItems(page);
expect(navMenu.length).toBeGreaterThan(0);
await page.keyboard.press('Escape');
await page.locator('.tree-item[data-type="directory"]').first().click({ button: 'right' });
await page.waitForSelector('#context-menu', { timeout: 5000 });
const sideMenu = await menuItems(page);
await page.keyboard.press('Escape');
expect(navMenu).toEqual(sideMenu);
});
test('fichiers de la vue : menu contextuel identique à la sidebar', async ({ page }) => {
await page.locator('.vault-item').first().click();
await page.waitForSelector('#vault-home .search-result-item', { timeout: 15000 });
await page.locator('#vault-home .search-result-item').first().click({ button: 'right' });
await page.waitForSelector('#context-menu', { timeout: 5000 });
const navMenu = await menuItems(page);
expect(navMenu.length).toBeGreaterThan(0);
await page.keyboard.press('Escape');
await page.locator('.tree-item[data-type="file"]').first().click({ button: 'right' });
await page.waitForSelector('#context-menu', { timeout: 5000 });
const sideMenu = await menuItems(page);
await page.keyboard.press('Escape');
expect(navMenu).toEqual(sideMenu);
});
test('titre -> onglet Accueil (Maison) épinglé en tête de barre', async ({ page }) => {
// Un fichier ouvert pour vérifier que l'onglet Accueil passe devant
await page.locator('.vault-item').first().click();
await page.waitForSelector('.tree-item[data-type="file"]', { timeout: 15000 });
await page.locator('.tree-item[data-type="file"]').first().dblclick();
await page.waitForTimeout(500);
await page.locator('#header-logo').click();
await page.waitForSelector('#dashboard-home', { timeout: 10000 });
const ids = await page.$$eval('#tab-list .tab-item', (els) => els.map((e) => e.dataset.tabId));
expect(ids[0]).toBe('home');
expect(ids.length).toBeGreaterThan(1);
await expect(page.locator('#tab-list .tab-item.active')).toHaveAttribute('data-tab-id', 'home');
// Icône Maison + libellé traduit
const iconClass = await page.locator('#tab-list .tab-item').first().locator('.tab-icon').getAttribute('class');
expect(iconClass).toContain('home');
const label = (await page.locator('#tab-list .tab-item').first().locator('.tab-name').textContent()) || '';
expect(['Accueil', 'Home']).toContain(label.trim());
// La page d'accueil est affichée (avec sa section Raccourcis & Astuces)
await expect(page.locator('#dashboard-home #quick-help')).toHaveCount(1);
});
});
+16 -12
View File
@@ -160,8 +160,9 @@ test.describe('Split View Matrix — Boutons & menu contextuel', () => {
await flyout.click();
await page.waitForTimeout(600);
// Pane 0 (single) holds both files now; empty source pane auto-collapsed
// (+ onglet navigation ouvert au clic sur la racine du vault — #158)
await expect(page.locator('.pane-container')).toHaveCount(0);
await expect(page.locator('#tab-list .tab-item')).toHaveCount(2);
await expect(page.locator('#tab-list .tab-item')).toHaveCount(3);
});
});
@@ -205,10 +206,10 @@ test.describe('Split View Matrix — Fermeture', () => {
test('X button on tab closes it', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await openFile(page, 'TestVault', 'note2.md');
await expect(page.locator('.tab-item')).toHaveCount(2);
await expect(page.locator('.tab-item')).toHaveCount(3); // + onglet navigation (#158)
await page.locator('.tab-item').first().locator('.tab-close').click();
await page.waitForTimeout(300);
await expect(page.locator('.tab-item')).toHaveCount(1);
await expect(page.locator('.tab-item')).toHaveCount(2);
});
test('double-click on tab closes it', async ({ page }) => {
@@ -216,12 +217,15 @@ test.describe('Split View Matrix — Fermeture', () => {
await openFile(page, 'TestVault', 'note2.md');
await page.locator('.tab-item').first().dblclick();
await page.waitForTimeout(300);
await expect(page.locator('.tab-item')).toHaveCount(1);
await expect(page.locator('.tab-item')).toHaveCount(2); // reste : onglet navigation (#158)
});
test('Ctrl+W closes active tab (single pane)', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await page.keyboard.press('Control+w');
await openFile(page, 'TestVault', 'note1.md'); // ouvre aussi l'onglet navigation (#158)
await page.keyboard.press('Control+w'); // ferme l'onglet fichier
await page.waitForTimeout(400);
await expect(page.locator('.tab-item')).toHaveCount(1);
await page.keyboard.press('Control+w'); // ferme l'onglet navigation
await page.waitForTimeout(400);
await expect(page.locator('.tab-item')).toHaveCount(0);
await expect(page.locator('#dashboard-home')).toBeVisible();
@@ -233,7 +237,7 @@ test.describe('Split View Matrix — Fermeture', () => {
await page.locator('.tab-item').first().click({ button: 'right' });
await page.locator('#tab-context-menu [data-action="close"]').click();
await page.waitForTimeout(300);
await expect(page.locator('.tab-item')).toHaveCount(1);
await expect(page.locator('.tab-item')).toHaveCount(2); // reste navigation + fichier (#158)
});
test('context menu Fermer les autres keeps one', async ({ page }) => {
@@ -243,7 +247,7 @@ test.describe('Split View Matrix — Fermeture', () => {
await openFile(page, 'TestVault', 'Accueil.md');
await page.waitForTimeout(300);
const tabs = page.locator('.tab-item');
await expect(tabs).toHaveCount(3);
await expect(tabs).toHaveCount(5); // accueil + navigation + 3 fichiers (#158)
await tabs.nth(1).click({ button: 'right' });
await page.locator('#tab-context-menu [data-action="closeOthers"]').click();
await page.waitForTimeout(300);
@@ -256,7 +260,7 @@ test.describe('Split View Matrix — Fermeture', () => {
await page.locator('#header-logo').click(); await page.waitForTimeout(300);
await openFile(page, 'TestVault', 'Accueil.md');
await page.waitForTimeout(300);
await expect(page.locator('.tab-item')).toHaveCount(3);
await expect(page.locator('.tab-item')).toHaveCount(5); // accueil + navigation + 3 fichiers (#158)
await page.locator('.tab-item').first().click({ button: 'right' });
await page.locator('#tab-context-menu [data-action="closeRight"]').click();
await page.waitForTimeout(300);
@@ -286,8 +290,8 @@ test.describe('Split View Matrix — Fermeture', () => {
await pane1.locator('.tab-item').first().locator('.tab-close').click({ force: true });
await page.waitForTimeout(700);
await expect(page.locator('.pane-container')).toHaveCount(0);
// pane 0 file is still open in single mode
await expect(page.locator('#tab-list .tab-item')).toHaveCount(1);
// pane 0 file + onglet navigation still open in single mode (#158)
await expect(page.locator('#tab-list .tab-item')).toHaveCount(2);
});
test('closing pane 0 of 2 focuses neighbour then collapses correctly', async ({ page }) => {
@@ -376,7 +380,7 @@ test.describe('Split View Matrix — Edge cases', () => {
// dblclick note1 again
await page.locator('.tree-item[data-path="note1.md"]').dblclick();
await page.waitForTimeout(400);
await expect(page.locator('.tab-item')).toHaveCount(2);
await expect(page.locator('.tab-item')).toHaveCount(3); // + onglet navigation (#158)
// it should be the active one
const active = page.locator('.tab-item.active');
await expect(active).toHaveCount(1);
+9 -8
View File
@@ -79,7 +79,7 @@ test.describe('Split View — Basic Operations', () => {
const tabBar = page.locator('#tab-bar');
await expect(tabBar).toBeVisible();
const tabs = page.locator('.tab-item');
await expect(tabs).toHaveCount(1);
await expect(tabs).toHaveCount(2); // navigation (racine vault) + fichier (#158)
// Content area should show the file
const content = page.locator('#content-area');
await expect(content).not.toBeEmpty();
@@ -91,7 +91,7 @@ test.describe('Split View — Basic Operations', () => {
await page.waitForTimeout(300);
await openFile(page, 'TestVault', 'note2.md');
const tabs = page.locator('.tab-item');
await expect(tabs).toHaveCount(2);
await expect(tabs).toHaveCount(4); // accueil + navigation + 2 fichiers (#158)
});
test('split right via keyboard shortcut', async ({ page }) => {
@@ -192,7 +192,7 @@ test.describe('Split View — Tab Drag & Drop', () => {
// Source pane should have 1 tab, target should have 1 tab
const pane0Tabs = page.locator('.pane-container').nth(0).locator('.tab-item');
const pane1Tabs = page.locator('.pane-container').nth(1).locator('.tab-item');
await expect(pane0Tabs).toHaveCount(1);
await expect(pane0Tabs).toHaveCount(2); // navigation + fichier (#158)
await expect(pane1Tabs).toHaveCount(1);
});
@@ -343,12 +343,13 @@ test.describe('Split View — Regression', () => {
});
test('closing last tab shows dashboard', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await openFile(page, 'TestVault', 'note1.md'); // ouvre aussi l'onglet navigation (#158)
// Close tab via middle-click
const tab = page.locator('.tab-item').first();
await tab.click({ button: 'middle' });
await page.waitForTimeout(500);
// Close tabs via middle-click until none remains (dernier onglet -> dashboard)
while (await page.locator('.tab-item').count()) {
await page.locator('.tab-item').first().click({ button: 'middle' });
await page.waitForTimeout(300);
}
// Dashboard should be visible
const dashboard = page.locator('#dashboard-home');
+10 -6
View File
@@ -42,19 +42,23 @@ function test(label, fn) {
/** Body of the first @media (max-width: 768px) block whose body contains `needle`. */
function mobileBlockContaining(needle) {
const marker = "@media (max-width: 768px)";
// Les commentaires sont ignorés : un sélecteur cité dans une annotation
// (ex. « .mobile-toolbar » dans un commentaire de style.css) ne doit pas
// faire rétrograder ce helper sur le mauvais bloc mobile (BUG-103/CI #842).
const source = css.replace(/\/\*[\s\S]*?\*\//g, "");
let idx = 0;
while ((idx = css.indexOf(marker, idx)) !== -1) {
const open = css.indexOf("{", idx);
while ((idx = source.indexOf(marker, idx)) !== -1) {
const open = source.indexOf("{", idx);
let depth = 0;
let end = open;
for (; end < css.length; end++) {
if (css[end] === "{") depth++;
else if (css[end] === "}") {
for (; end < source.length; end++) {
if (source[end] === "{") depth++;
else if (source[end] === "}") {
depth--;
if (depth === 0) break;
}
}
const body = css.slice(open + 1, end);
const body = source.slice(open + 1, end);
if (body.includes(needle)) return body;
idx = end + 1;
}
+17
View File
@@ -307,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;
@@ -324,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) {