diff --git a/frontend/js/pane-manager.js b/frontend/js/pane-manager.js index 2e9324d..2509e1b 100644 --- a/frontend/js/pane-manager.js +++ b/frontend/js/pane-manager.js @@ -2,7 +2,15 @@ * PaneManager — Multi-pane split view for ObsiGate (v2.0.0-dev) * Supports 1–4 panes in CSS grid, split right/down, resize, drag tabs between panes. * Integrates with existing TabManager singleton. + * + * Architecture: + * Single-pane (default): uses existing #tab-bar, #content-area — zero DOM changes. + * Multi-pane: builds a .pane-grid inside .content-wrapper, each pane gets its + * own tab-bar-N and content-area-N DOM elements. + * TabManager remains the singleton source of truth for open tabs. + * PaneManager tracks per-pane active tab and delegates rendering. */ +import { state } from './state.js'; const PANE_MAX = 4; @@ -12,6 +20,9 @@ const PaneManager = { _paneCounter: 1, layout: 'horizontal', + // ------------------------------------------------------------------ + // Init / Restore + // ------------------------------------------------------------------ init() { const saved = localStorage.getItem('obsigate-panes'); if (saved) { @@ -22,44 +33,91 @@ const PaneManager = { this._paneCounter = data.panes.length; this._buildGrid(data.panes.length); data.panes.forEach((p, i) => { - this.panes[i].activeTab = p.activeTab; - this.panes[i].width = p.width; + if (this.panes[i]) { + this.panes[i].activeTab = p.activeTab; + this.panes[i].width = p.width; + } }); - this.activePaneId = data.panes[0].id; + this.activePaneId = data.panes.length > 0 ? this.panes[0].id : 0; this._applyResize(); + this._highlightActive(); return; } - } catch (e) { /* ignore */ } + } catch (e) { /* ignore corrupt state */ } } }, + // ------------------------------------------------------------------ + // DOM Helpers — return the active pane's DOM elements + // ------------------------------------------------------------------ + getActiveContentArea() { + if (this.panes.length < 2) return document.getElementById('content-area'); + return document.getElementById('content-area-' + this.activePaneId) + || document.getElementById('content-area-0'); + }, + + getActiveTabBar() { + if (this.panes.length < 2) return document.getElementById('tab-bar'); + return document.getElementById('tab-bar-' + this.activePaneId) + || document.getElementById('tab-bar-0'); + }, + + getActiveTabList() { + if (this.panes.length < 2) return document.getElementById('tab-list'); + return document.getElementById('tab-list-' + this.activePaneId) + || document.getElementById('tab-list-0'); + }, + + // ------------------------------------------------------------------ + // Build / Destroy pane grid + // ------------------------------------------------------------------ _buildGrid(n) { const wrapper = document.querySelector('.content-wrapper'); const origTabBar = document.getElementById('tab-bar'); const origContent = document.getElementById('content-area'); if (!wrapper || !origContent || n < 2) return; + // Save dashboard before clearing + const dashboard = document.getElementById('dashboard-home'); + // Create grid container const grid = document.createElement('div'); grid.className = 'pane-grid'; - if (n === 2) grid.style.gridTemplateColumns = this.layout === 'horizontal' ? '1fr 4px 1fr' : '1fr'; - if (n >= 3) grid.style.gridTemplateColumns = '1fr 4px 1fr'; - if (n === 4) grid.style.gridTemplateRows = '1fr 4px 1fr'; + if (n === 2) { + grid.style.gridTemplateColumns = this.layout === 'horizontal' + ? '1fr 4px 1fr' : '1fr'; + if (this.layout === 'vertical') { + grid.style.gridTemplateRows = '1fr 4px 1fr'; + } + } else if (n === 3) { + grid.style.gridTemplateColumns = '1fr 4px 1fr'; + grid.style.gridTemplateRows = '1fr 4px 1fr'; + // 3 panes: first two in top row, third spans bottom row + } + if (n === 4) { + grid.style.gridTemplateColumns = '1fr 4px 1fr'; + grid.style.gridTemplateRows = '1fr 4px 1fr'; + } wrapper.innerHTML = ''; wrapper.appendChild(grid); + this.panes = []; + for (let i = 0; i < Math.min(n, PANE_MAX); i++) { const pane = this._createPane(i, grid, i === 0 ? origTabBar : null, i === 0 ? origContent : null); + if (dashboard && i === 0 && pane) { + // Re-attach dashboard to first pane's content area + const contentArea = document.getElementById('content-area-0') || origContent; + if (contentArea && !contentArea.querySelector('#dashboard-home')) { + contentArea.innerHTML = ''; + contentArea.appendChild(dashboard); + dashboard.style.display = ''; + } + } this.panes.push(pane); if (i < n - 1) { - const handle = document.createElement('div'); - handle.className = 'pane-resize-handle'; - if (this.layout === 'horizontal') handle.style.cursor = 'col-resize'; - else handle.style.cursor = 'row-resize'; - handle.addEventListener('mousedown', (e) => this._onResizeStart(e, i, i + 1)); - handle.addEventListener('dblclick', () => this._resetResize(i, i + 1)); - grid.appendChild(handle); + this._addResizeHandle(grid, i, i + 1); } } this.activePaneId = 0; @@ -76,6 +134,10 @@ const PaneManager = { container.appendChild(reuseTabBar); container.appendChild(reuseContent); reuseContent.id = 'content-area-0'; + reuseTabBar.id = 'tab-bar-0'; + // Rename tab-list too + const tl = reuseTabBar.querySelector('.tab-list') || reuseTabBar.querySelector('#tab-list'); + if (tl) tl.id = 'tab-list-0'; } else { const tabBar = document.createElement('div'); tabBar.className = 'tab-bar pane-tab-bar'; @@ -84,29 +146,52 @@ const PaneManager = { const content = document.createElement('main'); content.className = 'content-area pane-content'; content.id = 'content-area-' + idx; + content.setAttribute('aria-label', 'Contenu panneau ' + (idx + 1)); container.appendChild(tabBar); container.appendChild(content); } container.addEventListener('click', () => { - this.activePaneId = idx; - this._highlightActive(); + if (this.activePaneId !== idx) { + this.activePaneId = idx; + this._highlightActive(); + // Sync global state with this pane's active tab + const pane = this.panes[idx]; + if (pane && pane.activeTab) { + const parts = pane.activeTab.split('::'); + if (parts.length === 2) { + state.currentVault = parts[0]; + state.currentPath = parts[1]; + } + } + // Re-render if needed — delegate to TabManager + if (typeof window.TabManager !== 'undefined' && window.TabManager.activatePaneTab) { + window.TabManager.activatePaneTab(idx); + } + } }); grid.appendChild(container); - return { id: idx, element: container, width: 100 / this.panes.length + '%' }; + return { id: idx, element: container, width: null, activeTab: null }; }, - _highlightActive() { - this.panes.forEach((p, i) => { - p.element.classList.toggle('pane-active', i === this.activePaneId); - }); + _addResizeHandle(grid, leftIdx, rightIdx) { + const handle = document.createElement('div'); + handle.className = 'pane-resize-handle'; + if (this.layout === 'horizontal') handle.style.cursor = 'col-resize'; + else handle.style.cursor = 'row-resize'; + handle.addEventListener('mousedown', (e) => this._onResizeStart(e, leftIdx, rightIdx)); + handle.addEventListener('dblclick', () => this._resetResize(leftIdx, rightIdx)); + grid.appendChild(handle); }, + // ------------------------------------------------------------------ + // Split actions + // ------------------------------------------------------------------ splitRight() { if (this.panes.length >= PANE_MAX) return; + this.layout = 'horizontal'; if (this.panes.length === 0) { - // First split: promote single pane to grid this._buildGrid(2); } else { this._addPane(); @@ -129,23 +214,35 @@ const PaneManager = { const grid = document.querySelector('.pane-grid'); if (!grid) return; const n = this.panes.length + 1; + if (n === 3) { + grid.style.gridTemplateColumns = '1fr 4px 1fr'; + grid.style.gridTemplateRows = '1fr 4px 1fr'; + } + if (n === 4) { + grid.style.gridTemplateColumns = '1fr 4px 1fr'; + grid.style.gridTemplateRows = '1fr 4px 1fr'; + } const handle = document.createElement('div'); handle.className = 'pane-resize-handle'; + handle.style.cursor = this.layout === 'horizontal' ? 'col-resize' : 'row-resize'; handle.addEventListener('mousedown', (e) => this._onResizeStart(e, n - 2, n - 1)); + handle.addEventListener('dblclick', () => this._resetResize(n - 2, n - 1)); grid.appendChild(handle); const pane = this._createPane(n - 1, grid, null, null); + pane.activeTab = this.panes.length > 0 ? this.panes[this.activePaneId].activeTab : null; this.panes.push(pane); this.activePaneId = n - 1; this._highlightActive(); - if (n <= 2) grid.style.gridTemplateColumns = '1fr 4px 1fr'; - else if (n === 4) grid.style.gridTemplateRows = '1fr 4px 1fr'; + this._persist(); }, + // ------------------------------------------------------------------ + // Close / collapse + // ------------------------------------------------------------------ closePane(paneId) { if (this.panes.length <= 1) return; const idx = this.panes.findIndex(p => p.id === paneId); if (idx < 0) return; - // Remove pane and its resize handles this.panes[idx].element.remove(); const handles = document.querySelectorAll('.pane-resize-handle'); if (handles[idx]) handles[idx].remove(); @@ -160,20 +257,49 @@ const PaneManager = { this._persist(); }, + closeActivePane() { + if (this.panes.length <= 1) return; + this.closePane(this.activePaneId); + }, + + closeOtherPanes() { + if (this.panes.length <= 1) return; + const keepId = this.activePaneId; + const toClose = this.panes.filter(p => p.id !== keepId); + for (const p of toClose.reverse()) { + this.closePane(p.id); + } + }, + _collapseToSingle() { const grid = document.querySelector('.pane-grid'); if (!grid) return; const wrapper = grid.parentElement; const pane0 = this.panes[0]; wrapper.innerHTML = ''; - const tabBar = pane0.element.querySelector('.tab-bar') || document.getElementById('tab-bar-0'); - const content = pane0.element.querySelector('.content-area') || document.getElementById('content-area-0'); - if (tabBar) wrapper.appendChild(tabBar); - if (content) wrapper.appendChild(content); + const tabBar = (pane0 && pane0.element.querySelector('.tab-bar')) + || document.getElementById('tab-bar-0'); + const content = (pane0 && pane0.element.querySelector('.content-area')) + || document.getElementById('content-area-0'); + if (tabBar) { + tabBar.id = 'tab-bar'; + wrapper.appendChild(tabBar); + } + if (content) { + content.id = 'content-area'; + wrapper.appendChild(content); + } + // Restore tab-list ID + const tl = wrapper.querySelector('.tab-list'); + if (tl) tl.id = 'tab-list'; this.panes = []; this.activePaneId = 0; + this._persist(); }, + // ------------------------------------------------------------------ + // Resize + // ------------------------------------------------------------------ _onResizeStart(e, leftIdx, rightIdx) { e.preventDefault(); const grid = document.querySelector('.pane-grid'); @@ -207,17 +333,29 @@ const PaneManager = { }, _resetResize(leftIdx, rightIdx) { - this.panes[leftIdx].element.style.flexBasis = '50%'; - this.panes[rightIdx].element.style.flexBasis = '50%'; + if (this.panes[leftIdx]) this.panes[leftIdx].element.style.flexBasis = '50%'; + if (this.panes[rightIdx]) this.panes[rightIdx].element.style.flexBasis = '50%'; this._persist(); }, _applyResize() { this.panes.forEach(p => { - if (p.width) p.element.style.flexBasis = p.width; + if (p.width && p.element) p.element.style.flexBasis = p.width; }); }, + // ------------------------------------------------------------------ + // Highlight + // ------------------------------------------------------------------ + _highlightActive() { + this.panes.forEach((p, i) => { + if (p.element) p.element.classList.toggle('pane-active', i === this.activePaneId); + }); + }, + + // ------------------------------------------------------------------ + // Persistence + // ------------------------------------------------------------------ _persist() { if (this.panes.length <= 1) { localStorage.removeItem('obsigate-panes'); @@ -230,21 +368,25 @@ const PaneManager = { localStorage.setItem('obsigate-panes', JSON.stringify(data)); }, - getActiveContentArea() { - if (this.panes.length < 2) return document.getElementById('content-area'); - return document.getElementById('content-area-' + this.activePaneId) || document.getElementById('content-area-0'); - }, - + // ------------------------------------------------------------------ + // Queries + // ------------------------------------------------------------------ isSplit() { return this.panes.length >= 2; }, + + getPaneCount() { + return Math.max(this.panes.length, 1); + }, }; -// Export and auto-init +// ------------------------------------------------------------------ +// Export + auto-init +// ------------------------------------------------------------------ if (typeof window !== 'undefined') { window.PaneManager = PaneManager; document.addEventListener('DOMContentLoaded', () => { - // Register keyboard shortcuts + // Keyboard shortcuts — Ctrl+Alt+\ for split right, Ctrl+Alt+Shift+\ for split down document.addEventListener('keydown', (e) => { if (e.ctrlKey && e.altKey && !e.shiftKey && e.key === '\\') { e.preventDefault(); @@ -254,9 +396,14 @@ if (typeof window !== 'undefined') { e.preventDefault(); PaneManager.splitDown(); } + // Ctrl+Alt+W — close active pane + if (e.ctrlKey && e.altKey && !e.shiftKey && (e.key === 'w' || e.key === 'W') && PaneManager.isSplit()) { + e.preventDefault(); + PaneManager.closeActivePane(); + } }); - // Initialize if saved state exists - setTimeout(() => PaneManager.init(), 500); + // Init if saved state exists + setTimeout(() => PaneManager.init(), 100); }); } diff --git a/frontend/js/ui.js b/frontend/js/ui.js index 0c4ec8e..f76086e 100644 --- a/frontend/js/ui.js +++ b/frontend/js/ui.js @@ -1877,9 +1877,64 @@ export const TabManager = { _tabList: null, _dirtyTabs: new Set(), + // Expose on window for PaneManager cross-module communication + _expose() { + window.TabManager = this; + }, + + // Pane-aware helpers + _getContentArea() { + if (window.PaneManager && window.PaneManager.isSplit()) { + return window.PaneManager.getActiveContentArea() || document.getElementById('content-area'); + } + return document.getElementById('content-area'); + }, + _getTabBar() { + if (window.PaneManager && window.PaneManager.isSplit()) { + return window.PaneManager.getActiveTabBar() || document.getElementById('tab-bar'); + } + return document.getElementById('tab-bar'); + }, + _getTabList() { + if (window.PaneManager && window.PaneManager.isSplit()) { + return window.PaneManager.getActiveTabList() || document.getElementById('tab-list'); + } + return document.getElementById('tab-list'); + }, + init() { - this._tabBar = document.getElementById("tab-bar"); - this._tabList = document.getElementById("tab-list"); + this._tabBar = this._getTabBar(); + this._tabList = this._getTabList(); + this._expose(); + }, + + /** Called by PaneManager when pane focus changes — updates DOM references */ + activatePaneTab(paneId) { + // Update DOM references to the newly active pane + this._tabBar = this._getTabBar(); + this._tabList = this._getTabList(); + // If this pane has a saved active tab, activate it + const pane = window.PaneManager && window.PaneManager.panes + ? window.PaneManager.panes.find(p => p.id === paneId) + : null; + if (pane && pane.activeTab && pane.activeTab !== this._activeTabId) { + this.activate(pane.activeTab); + } + if (!pane || !pane.activeTab) { + // New pane with no tab — show dashboard + this._showDashboard(); + } + }, + + /** Track which pane the active tab belongs to */ + _updatePaneActiveTab() { + if (window.PaneManager && window.PaneManager.isSplit() && this._activeTabId) { + const pane = window.PaneManager.panes.find(p => p.id === window.PaneManager.activePaneId); + if (pane) { + pane.activeTab = this._activeTabId; + window.PaneManager._persist(); + } + } }, /** Open a file as a preview tab (single-click). @@ -1986,7 +2041,7 @@ export const TabManager = { this._activeTabId = tabId; this._renderTabs(); - + this._updatePaneActiveTab(); // Load tab content const cache = this._tabCache[tabId]; if (!cache) return; @@ -1996,7 +2051,7 @@ export const TabManager = { state.currentPath = cache.path; syncActiveFileTreeItem(cache.vault, cache.path); - const area = document.getElementById("content-area"); + const area = this._getContentArea(); if (cache.data) { // Use cached data @@ -2108,7 +2163,7 @@ export const TabManager = { const cache = this._tabCache[this._activeTabId]; if (!cache) return; - const area = document.getElementById("content-area"); + const area = this._getContentArea(); const rendered = document.getElementById("file-rendered-content"); cache.scrollTop = area.scrollTop; @@ -2153,7 +2208,7 @@ export const TabManager = { }, _showDashboard() { - const area = document.getElementById("content-area"); + const area = this._getContentArea(); // Save dashboard DOM before clearing (it may have been removed from DOM by renderFile) let dashboard = document.getElementById("dashboard-home"); if (!dashboard) { @@ -2294,18 +2349,25 @@ export const TabManager = { menu.style.left = x + "px"; menu.style.top = y + "px"; menu.innerHTML = ` +
Diviser à droite
+
Diviser en bas
+
Fermer
Fermer les autres
Fermer à droite
Fermer tout
+ ${window.PaneManager && window.PaneManager.isSplit() ? '
Fermer le panneau
' : ''} `; document.body.appendChild(menu); safeCreateIcons(); menu.addEventListener("click", (e) => { const action = e.target.closest(".context-menu-item")?.dataset.action; - if (action === "close") this.close(tabId); + if (action === "splitRight") { if (window.PaneManager) { window.PaneManager.splitRight(); menu.remove(); return; } } + else if (action === "splitDown") { if (window.PaneManager) { window.PaneManager.splitDown(); menu.remove(); return; } } + else if (action === "closePane") { if (window.PaneManager) { window.PaneManager.closeActivePane(); menu.remove(); return; } } + else if (action === "close") this.close(tabId); else if (action === "closeOthers") this.closeOthers(tabId); else if (action === "closeRight") this.closeRight(tabId); else if (action === "closeAll") this.closeAll(); diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js index 3cd58ed..187fef3 100644 --- a/frontend/js/viewer.js +++ b/frontend/js/viewer.js @@ -11,6 +11,14 @@ import { DashboardStatsWidget, DashboardRecentWidget, DashboardBookmarkWidget, D import { openShareDialog } from './config.js'; import { cacheViewedFile, getCachedFile } from './offline.js'; import { t } from './i18n.js'; + +// Pane-aware content area helper +function getContentArea() { + if (window.PaneManager && window.PaneManager.isSplit()) { + return window.PaneManager.getActiveContentArea(); + } + return document.getElementById('content-area'); +} // --------------------------------------------------------------------------- // Outline/TOC Manager // --------------------------------------------------------------------------- @@ -135,7 +143,7 @@ const OutlineManager = { const heading = document.getElementById(headingId); if (!heading) return; - const contentArea = document.getElementById("content-area"); + const contentArea = getContentArea(); if (!contentArea) return; // Calculate offset for fixed header (if any) @@ -209,7 +217,7 @@ const ScrollSpyManager = { if (!headings || headings.length === 0) return; - const contentArea = document.getElementById("content-area"); + const contentArea = getContentArea(); if (!contentArea) return; const options = { @@ -263,7 +271,7 @@ const ReadingProgressManager = { init() { this.destroy(); - const contentArea = document.getElementById("content-area"); + const contentArea = getContentArea(); if (!contentArea) return; this.scrollHandler = this.throttle(() => { @@ -275,7 +283,7 @@ const ReadingProgressManager = { }, updateProgress() { - const contentArea = document.getElementById("content-area"); + const contentArea = getContentArea(); const progressFill = document.getElementById("reading-progress-fill"); const progressText = document.getElementById("reading-progress-text"); @@ -304,7 +312,7 @@ const ReadingProgressManager = { }, destroy() { - const contentArea = document.getElementById("content-area"); + const contentArea = getContentArea(); if (contentArea && this.scrollHandler) { contentArea.removeEventListener("scroll", this.scrollHandler); } @@ -332,7 +340,7 @@ export async function openFile(vaultName, filePath) { syncActiveFileTreeItem(vaultName, filePath); // Show loading state while fetching - const area = document.getElementById("content-area"); + const area = getContentArea(); area.innerHTML = '
Chargement...
'; try { @@ -400,7 +408,7 @@ async function renderBacklinksPanel(vault, path, container) { } export function renderFile(data) { - const area = document.getElementById("content-area"); + const area = getContentArea(); // Handle PDF files — render in iframe with TOC sidebar if (data.is_pdf) { @@ -1051,7 +1059,7 @@ export function showWelcome() { hideProgressBar(); // Restore or rebuild the dashboard with tabbed sections - const area = document.getElementById("content-area"); + const area = getContentArea(); const home = document.getElementById("dashboard-home"); if (area && !home) { @@ -1244,7 +1252,7 @@ export async function loadSavedSearches() { } export function showLoading() { - const area = document.getElementById("content-area"); + const area = getContentArea(); area.innerHTML = `
diff --git a/frontend/style.css b/frontend/style.css index 0b65ada..de35649 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -1247,6 +1247,93 @@ select { overflow: hidden; } +/* --- Multi-Pane Grid (Split View #75) --- */ +.pane-grid { + flex: 1; + display: grid; + overflow: hidden; + min-width: 0; + min-height: 0; +} + +.pane-container { + display: flex; + flex-direction: column; + overflow: hidden; + min-width: 0; + min-height: 0; + border: 2px solid transparent; + transition: border-color 150ms ease; +} + +.pane-container.pane-active { + border-color: var(--accent); +} + +.pane-container .tab-bar.pane-tab-bar { + flex-shrink: 0; +} + +.pane-container .content-area.pane-content { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: clamp(12px, 2vw, 24px) clamp(12px, 3vw, 24px) 40px; +} + +.pane-resize-handle { + width: 4px; + background: var(--border); + transition: background 150ms ease; + position: relative; + z-index: 2; + flex-shrink: 0; +} + +.pane-resize-handle:hover { + background: var(--accent); +} + +.pane-resize-handle::after { + content: ''; + position: absolute; + top: 10%; + bottom: 10%; + left: 50%; + transform: translateX(-50%); + width: 2px; + background: var(--accent); + opacity: 0; + transition: opacity 150ms ease; +} + +.pane-resize-handle:hover::after { + opacity: 1; +} + +/* Pane active state */ +.pane-container.pane-active { + outline: none; + border: 2px solid var(--accent); +} + +/* Responsive: disable split view below 768px */ +@media (max-width: 768px) { + .pane-grid { + display: flex !important; + flex-direction: column !important; + } + .pane-grid .pane-container:not(:first-child) { + display: none; + } + .pane-grid .pane-resize-handle { + display: none; + } + .pane-container.pane-active { + border-color: transparent; + } +} + /* --- Content Area --- */ .content-area { flex: 1; diff --git a/tests/test_pane_manager.py b/tests/test_pane_manager.py new file mode 100644 index 0000000..bd1c357 --- /dev/null +++ b/tests/test_pane_manager.py @@ -0,0 +1,194 @@ +""" +Tests for the Multi-Pane Split View (#75). + +Since PaneManager is a frontend JS module, these tests verify: + 1. Backend integration: all file/sidebar/vault APIs still work (no regression) + 2. Static file serving: pane-manager.js is served correctly + 3. CSS: style.css includes pane grid classes + 4. HTML: index.html includes the content-wrapper structure needed for panes +""" +import pytest +import re +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent.parent + + +class TestPaneManagerStaticAssets: + """Verify pane-manager.js, style.css, and index.html are properly structured.""" + + def test_pane_manager_js_exists(self): + """PaneManager module file is present.""" + path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" + assert path.exists(), f"pane-manager.js not found at {path}" + + def test_pane_manager_js_exports(self): + """PaneManager exports the expected API.""" + path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" + content = path.read_text() + assert "export default PaneManager" in content + assert "window.PaneManager = PaneManager" in content + + def test_pane_manager_has_split_methods(self): + """PaneManager has splitRight and splitDown methods.""" + path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" + content = path.read_text() + assert "splitRight()" in content + assert "splitDown()" in content + assert "closePane(" in content + assert "closeActivePane()" in content + assert "isSplit()" in content + assert "getActiveContentArea()" in content + + def test_pane_manager_has_keyboard_shortcuts(self): + """Keyboard shortcuts are registered.""" + path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" + content = path.read_text() + assert "ctrlKey" in content + assert '"\\\\"' in content or "'\\\\'" in content # backslash key + + def test_style_css_has_pane_grid_classes(self): + """style.css includes pane grid CSS classes.""" + path = PROJECT_ROOT / "frontend" / "style.css" + content = path.read_text() + assert ".pane-grid" in content + assert ".pane-container" in content + assert ".pane-resize-handle" in content + assert ".pane-active" in content + + def test_style_css_has_responsive_split(self): + """Responsive rules disable split below 768px.""" + path = PROJECT_ROOT / "frontend" / "style.css" + content = path.read_text() + assert "@media (max-width: 768px)" in content + assert ".pane-grid" in content + + def test_viewer_uses_pane_aware_helper(self): + """viewer.js has getContentArea() helper and uses it.""" + path = PROJECT_ROOT / "frontend" / "js" / "viewer.js" + content = path.read_text() + assert "function getContentArea()" in content + assert "window.PaneManager" in content + # Should NOT have direct document.getElementById("content-area") except in fallback + direct_refs = [l for l in content.split("\n") + if 'document.getElementById("content-area")' in l + and "fallback" not in l.lower()] + # Only the fallback inside getContentArea should remain + assert len(direct_refs) <= 2, f"Too many direct content-area refs: {direct_refs}" + + def test_ui_tab_manager_has_pane_helpers(self): + """TabManager in ui.js has _getContentArea, _getTabBar, _getTabList.""" + path = PROJECT_ROOT / "frontend" / "js" / "ui.js" + content = path.read_text() + assert "_getContentArea()" in content + assert "_getTabBar()" in content + assert "_getTabList()" in content + assert "activatePaneTab(" in content + + def test_ui_context_menu_has_split_actions(self): + """Tab context menu includes 'Diviser à droite' and 'Diviser en bas'.""" + path = PROJECT_ROOT / "frontend" / "js" / "ui.js" + content = path.read_text() + assert "Diviser à droite" in content + assert "Diviser en bas" in content + assert "Fermer le panneau" in content + assert 'data-action="splitRight"' in content + assert 'data-action="splitDown"' in content + + def test_html_has_content_wrapper(self): + """index.html has the content-wrapper structure needed for panes.""" + path = PROJECT_ROOT / "frontend" / "index.html" + content = path.read_text() + assert 'class="content-wrapper"' in content + assert 'id="content-area"' in content + assert 'id="tab-bar"' in content + assert 'id="tab-list"' in content + + def test_html_uses_es_modules(self): + """index.html uses ES module scripts for the frontend modules.""" + path = PROJECT_ROOT / "frontend" / "index.html" + content = path.read_text() + # Should load the ES module app.js orchestrator + assert 'type="module"' in content + + def test_brace_balance_pane_manager(self): + """PaneManager JS has balanced braces, parens, brackets.""" + path = PROJECT_ROOT / "frontend" / "js" / "pane-manager.js" + content = path.read_text() + for char, name in [("{", "braces"), ("(", "parens"), ("[", "brackets")]: + close_map = {"{": "}", "(": ")", "[": "]"} + assert content.count(char) == content.count(close_map[char]), \ + f"Unbalanced {name}: {content.count(char)} opens vs {content.count(close_map[char])} closes" + + def test_brace_balance_viewer(self): + """viewer.js has balanced braces, parens, brackets.""" + path = PROJECT_ROOT / "frontend" / "js" / "viewer.js" + content = path.read_text() + for char, name in [("{", "braces"), ("(", "parens"), ("[", "brackets")]: + close_map = {"{": "}", "(": ")", "[": "]"} + assert content.count(char) == content.count(close_map[char]), \ + f"Unbalanced {name}: {content.count(char)} opens vs {content.count(close_map[char])} closes" + + def test_brace_balance_ui(self): + """ui.js has balanced braces, parens, brackets.""" + path = PROJECT_ROOT / "frontend" / "js" / "ui.js" + content = path.read_text() + for char, name in [("{", "braces"), ("(", "parens"), ("[", "brackets")]: + close_map = {"{": "}", "(": ")", "[": "]"} + assert content.count(char) == content.count(close_map[char]), \ + f"Unbalanced {name}: {content.count(char)} opens vs {content.count(close_map[char])} closes" + + +class TestBackendRegression: + """Verify backend APIs still work with frontend changes.""" + + def test_file_view_endpoint(self, client): + """GET /api/file/{vault} still returns expected fields.""" + resp = client.get("/api/file/TestVault", params={"path": "note1.md"}) + assert resp.status_code == 200 + data = resp.json() + assert "title" in data or "path" in data + + def test_vault_list_endpoint(self, client): + """GET /api/vaults still works.""" + resp = client.get("/api/vaults") + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + + def test_search_endpoint(self, client): + """GET /api/search still works.""" + resp = client.get("/api/search?q=test") + assert resp.status_code == 200 + data = resp.json() + assert "results" in data + + def test_sidebar_tree_endpoint(self, client): + """GET /api/sidebar/tree/{vault} still works.""" + resp = client.get("/api/sidebar/tree/vault1") + assert resp.status_code == 200 + + def test_static_css_served(self, client): + """style.css is served correctly.""" + resp = client.get("/static/style.css") + assert resp.status_code == 200 + assert "text/css" in resp.headers.get("content-type", "") + + def test_static_js_served(self, client): + """app.js (ES module) is served correctly.""" + resp = client.get("/static/js/app.js") + assert resp.status_code == 200 + assert "javascript" in resp.headers.get("content-type", "").lower() + + def test_pane_manager_js_served(self, client): + """pane-manager.js is served as static asset.""" + resp = client.get("/static/js/pane-manager.js") + assert resp.status_code == 200 + assert "javascript" in resp.headers.get("content-type", "").lower() + assert "PaneManager" in resp.text + + def test_index_html_served(self, client): + """index.html is served and contains content-wrapper.""" + resp = client.get("/") + # May redirect or return 200 depending on auth + assert resp.status_code in (200, 302, 401)