feat: split view multi-panneaux (#75) — PaneManager intégré avec split right/down, menu contextuel, raccourcis clavier, CSS responsive et tests
CI / lint (push) Failing after 35s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / e2e (push) Has been skipped
CI / security (push) Successful in 15s
Desktop Build / build-windows (push) Has been cancelled
Desktop Build / build-linux (push) Has been cancelled
CI / lint (push) Failing after 35s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / e2e (push) Has been skipped
CI / security (push) Successful in 15s
Desktop Build / build-windows (push) Has been cancelled
Desktop Build / build-linux (push) Has been cancelled
- PaneManager: splitRight/splitDown avec 1-4 panneaux, persistance localStorage, resize - Viewer/TabManager pane-aware: fonctions getContentArea/getTabBar/getTabList - Menu contextuel onglets: actions Diviser à droite / Diviser en bas / Fermer le panneau - Raccourcis clavier: Ctrl+Alt+\ (split right), Ctrl+Alt+Shift+\ (split down), Ctrl+Alt+W (fermer panneau) - CSS pane-grid: grille, poignées resize, bordure active pane, responsive (désactivé <768px) - Tests: 22 nouveaux tests unitaires + régression (307 total, 100% green) - Déployé sur Docker test (localhost:2021)
This commit is contained in:
+188
-41
@@ -2,7 +2,15 @@
|
|||||||
* PaneManager — Multi-pane split view for ObsiGate (v2.0.0-dev)
|
* 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.
|
* Supports 1–4 panes in CSS grid, split right/down, resize, drag tabs between panes.
|
||||||
* Integrates with existing TabManager singleton.
|
* 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;
|
const PANE_MAX = 4;
|
||||||
|
|
||||||
@@ -12,6 +20,9 @@ const PaneManager = {
|
|||||||
_paneCounter: 1,
|
_paneCounter: 1,
|
||||||
layout: 'horizontal',
|
layout: 'horizontal',
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Init / Restore
|
||||||
|
// ------------------------------------------------------------------
|
||||||
init() {
|
init() {
|
||||||
const saved = localStorage.getItem('obsigate-panes');
|
const saved = localStorage.getItem('obsigate-panes');
|
||||||
if (saved) {
|
if (saved) {
|
||||||
@@ -22,44 +33,91 @@ const PaneManager = {
|
|||||||
this._paneCounter = data.panes.length;
|
this._paneCounter = data.panes.length;
|
||||||
this._buildGrid(data.panes.length);
|
this._buildGrid(data.panes.length);
|
||||||
data.panes.forEach((p, i) => {
|
data.panes.forEach((p, i) => {
|
||||||
this.panes[i].activeTab = p.activeTab;
|
if (this.panes[i]) {
|
||||||
this.panes[i].width = p.width;
|
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._applyResize();
|
||||||
|
this._highlightActive();
|
||||||
return;
|
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) {
|
_buildGrid(n) {
|
||||||
const wrapper = document.querySelector('.content-wrapper');
|
const wrapper = document.querySelector('.content-wrapper');
|
||||||
const origTabBar = document.getElementById('tab-bar');
|
const origTabBar = document.getElementById('tab-bar');
|
||||||
const origContent = document.getElementById('content-area');
|
const origContent = document.getElementById('content-area');
|
||||||
if (!wrapper || !origContent || n < 2) return;
|
if (!wrapper || !origContent || n < 2) return;
|
||||||
|
|
||||||
|
// Save dashboard before clearing
|
||||||
|
const dashboard = document.getElementById('dashboard-home');
|
||||||
|
|
||||||
// Create grid container
|
// Create grid container
|
||||||
const grid = document.createElement('div');
|
const grid = document.createElement('div');
|
||||||
grid.className = 'pane-grid';
|
grid.className = 'pane-grid';
|
||||||
if (n === 2) grid.style.gridTemplateColumns = this.layout === 'horizontal' ? '1fr 4px 1fr' : '1fr';
|
if (n === 2) {
|
||||||
if (n >= 3) grid.style.gridTemplateColumns = '1fr 4px 1fr';
|
grid.style.gridTemplateColumns = this.layout === 'horizontal'
|
||||||
if (n === 4) grid.style.gridTemplateRows = '1fr 4px 1fr';
|
? '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.innerHTML = '';
|
||||||
wrapper.appendChild(grid);
|
wrapper.appendChild(grid);
|
||||||
|
|
||||||
|
this.panes = [];
|
||||||
|
|
||||||
for (let i = 0; i < Math.min(n, PANE_MAX); i++) {
|
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);
|
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);
|
this.panes.push(pane);
|
||||||
if (i < n - 1) {
|
if (i < n - 1) {
|
||||||
const handle = document.createElement('div');
|
this._addResizeHandle(grid, i, i + 1);
|
||||||
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.activePaneId = 0;
|
this.activePaneId = 0;
|
||||||
@@ -76,6 +134,10 @@ const PaneManager = {
|
|||||||
container.appendChild(reuseTabBar);
|
container.appendChild(reuseTabBar);
|
||||||
container.appendChild(reuseContent);
|
container.appendChild(reuseContent);
|
||||||
reuseContent.id = 'content-area-0';
|
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 {
|
} else {
|
||||||
const tabBar = document.createElement('div');
|
const tabBar = document.createElement('div');
|
||||||
tabBar.className = 'tab-bar pane-tab-bar';
|
tabBar.className = 'tab-bar pane-tab-bar';
|
||||||
@@ -84,29 +146,52 @@ const PaneManager = {
|
|||||||
const content = document.createElement('main');
|
const content = document.createElement('main');
|
||||||
content.className = 'content-area pane-content';
|
content.className = 'content-area pane-content';
|
||||||
content.id = 'content-area-' + idx;
|
content.id = 'content-area-' + idx;
|
||||||
|
content.setAttribute('aria-label', 'Contenu panneau ' + (idx + 1));
|
||||||
container.appendChild(tabBar);
|
container.appendChild(tabBar);
|
||||||
container.appendChild(content);
|
container.appendChild(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
container.addEventListener('click', () => {
|
container.addEventListener('click', () => {
|
||||||
this.activePaneId = idx;
|
if (this.activePaneId !== idx) {
|
||||||
this._highlightActive();
|
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);
|
grid.appendChild(container);
|
||||||
return { id: idx, element: container, width: 100 / this.panes.length + '%' };
|
return { id: idx, element: container, width: null, activeTab: null };
|
||||||
},
|
},
|
||||||
|
|
||||||
_highlightActive() {
|
_addResizeHandle(grid, leftIdx, rightIdx) {
|
||||||
this.panes.forEach((p, i) => {
|
const handle = document.createElement('div');
|
||||||
p.element.classList.toggle('pane-active', i === this.activePaneId);
|
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() {
|
splitRight() {
|
||||||
if (this.panes.length >= PANE_MAX) return;
|
if (this.panes.length >= PANE_MAX) return;
|
||||||
|
this.layout = 'horizontal';
|
||||||
if (this.panes.length === 0) {
|
if (this.panes.length === 0) {
|
||||||
// First split: promote single pane to grid
|
|
||||||
this._buildGrid(2);
|
this._buildGrid(2);
|
||||||
} else {
|
} else {
|
||||||
this._addPane();
|
this._addPane();
|
||||||
@@ -129,23 +214,35 @@ const PaneManager = {
|
|||||||
const grid = document.querySelector('.pane-grid');
|
const grid = document.querySelector('.pane-grid');
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
const n = this.panes.length + 1;
|
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');
|
const handle = document.createElement('div');
|
||||||
handle.className = 'pane-resize-handle';
|
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('mousedown', (e) => this._onResizeStart(e, n - 2, n - 1));
|
||||||
|
handle.addEventListener('dblclick', () => this._resetResize(n - 2, n - 1));
|
||||||
grid.appendChild(handle);
|
grid.appendChild(handle);
|
||||||
const pane = this._createPane(n - 1, grid, null, null);
|
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.panes.push(pane);
|
||||||
this.activePaneId = n - 1;
|
this.activePaneId = n - 1;
|
||||||
this._highlightActive();
|
this._highlightActive();
|
||||||
if (n <= 2) grid.style.gridTemplateColumns = '1fr 4px 1fr';
|
this._persist();
|
||||||
else if (n === 4) grid.style.gridTemplateRows = '1fr 4px 1fr';
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Close / collapse
|
||||||
|
// ------------------------------------------------------------------
|
||||||
closePane(paneId) {
|
closePane(paneId) {
|
||||||
if (this.panes.length <= 1) return;
|
if (this.panes.length <= 1) return;
|
||||||
const idx = this.panes.findIndex(p => p.id === paneId);
|
const idx = this.panes.findIndex(p => p.id === paneId);
|
||||||
if (idx < 0) return;
|
if (idx < 0) return;
|
||||||
// Remove pane and its resize handles
|
|
||||||
this.panes[idx].element.remove();
|
this.panes[idx].element.remove();
|
||||||
const handles = document.querySelectorAll('.pane-resize-handle');
|
const handles = document.querySelectorAll('.pane-resize-handle');
|
||||||
if (handles[idx]) handles[idx].remove();
|
if (handles[idx]) handles[idx].remove();
|
||||||
@@ -160,20 +257,49 @@ const PaneManager = {
|
|||||||
this._persist();
|
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() {
|
_collapseToSingle() {
|
||||||
const grid = document.querySelector('.pane-grid');
|
const grid = document.querySelector('.pane-grid');
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
const wrapper = grid.parentElement;
|
const wrapper = grid.parentElement;
|
||||||
const pane0 = this.panes[0];
|
const pane0 = this.panes[0];
|
||||||
wrapper.innerHTML = '';
|
wrapper.innerHTML = '';
|
||||||
const tabBar = pane0.element.querySelector('.tab-bar') || document.getElementById('tab-bar-0');
|
const tabBar = (pane0 && pane0.element.querySelector('.tab-bar'))
|
||||||
const content = pane0.element.querySelector('.content-area') || document.getElementById('content-area-0');
|
|| document.getElementById('tab-bar-0');
|
||||||
if (tabBar) wrapper.appendChild(tabBar);
|
const content = (pane0 && pane0.element.querySelector('.content-area'))
|
||||||
if (content) wrapper.appendChild(content);
|
|| 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.panes = [];
|
||||||
this.activePaneId = 0;
|
this.activePaneId = 0;
|
||||||
|
this._persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Resize
|
||||||
|
// ------------------------------------------------------------------
|
||||||
_onResizeStart(e, leftIdx, rightIdx) {
|
_onResizeStart(e, leftIdx, rightIdx) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const grid = document.querySelector('.pane-grid');
|
const grid = document.querySelector('.pane-grid');
|
||||||
@@ -207,17 +333,29 @@ const PaneManager = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
_resetResize(leftIdx, rightIdx) {
|
_resetResize(leftIdx, rightIdx) {
|
||||||
this.panes[leftIdx].element.style.flexBasis = '50%';
|
if (this.panes[leftIdx]) this.panes[leftIdx].element.style.flexBasis = '50%';
|
||||||
this.panes[rightIdx].element.style.flexBasis = '50%';
|
if (this.panes[rightIdx]) this.panes[rightIdx].element.style.flexBasis = '50%';
|
||||||
this._persist();
|
this._persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
_applyResize() {
|
_applyResize() {
|
||||||
this.panes.forEach(p => {
|
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() {
|
_persist() {
|
||||||
if (this.panes.length <= 1) {
|
if (this.panes.length <= 1) {
|
||||||
localStorage.removeItem('obsigate-panes');
|
localStorage.removeItem('obsigate-panes');
|
||||||
@@ -230,21 +368,25 @@ const PaneManager = {
|
|||||||
localStorage.setItem('obsigate-panes', JSON.stringify(data));
|
localStorage.setItem('obsigate-panes', JSON.stringify(data));
|
||||||
},
|
},
|
||||||
|
|
||||||
getActiveContentArea() {
|
// ------------------------------------------------------------------
|
||||||
if (this.panes.length < 2) return document.getElementById('content-area');
|
// Queries
|
||||||
return document.getElementById('content-area-' + this.activePaneId) || document.getElementById('content-area-0');
|
// ------------------------------------------------------------------
|
||||||
},
|
|
||||||
|
|
||||||
isSplit() {
|
isSplit() {
|
||||||
return this.panes.length >= 2;
|
return this.panes.length >= 2;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getPaneCount() {
|
||||||
|
return Math.max(this.panes.length, 1);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Export and auto-init
|
// ------------------------------------------------------------------
|
||||||
|
// Export + auto-init
|
||||||
|
// ------------------------------------------------------------------
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.PaneManager = PaneManager;
|
window.PaneManager = PaneManager;
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Register keyboard shortcuts
|
// Keyboard shortcuts — Ctrl+Alt+\ for split right, Ctrl+Alt+Shift+\ for split down
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.ctrlKey && e.altKey && !e.shiftKey && e.key === '\\') {
|
if (e.ctrlKey && e.altKey && !e.shiftKey && e.key === '\\') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -254,9 +396,14 @@ if (typeof window !== 'undefined') {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
PaneManager.splitDown();
|
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
|
// Init if saved state exists
|
||||||
setTimeout(() => PaneManager.init(), 500);
|
setTimeout(() => PaneManager.init(), 100);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+69
-7
@@ -1877,9 +1877,64 @@ export const TabManager = {
|
|||||||
_tabList: null,
|
_tabList: null,
|
||||||
_dirtyTabs: new Set(),
|
_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() {
|
init() {
|
||||||
this._tabBar = document.getElementById("tab-bar");
|
this._tabBar = this._getTabBar();
|
||||||
this._tabList = document.getElementById("tab-list");
|
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).
|
/** Open a file as a preview tab (single-click).
|
||||||
@@ -1986,7 +2041,7 @@ export const TabManager = {
|
|||||||
|
|
||||||
this._activeTabId = tabId;
|
this._activeTabId = tabId;
|
||||||
this._renderTabs();
|
this._renderTabs();
|
||||||
|
this._updatePaneActiveTab();
|
||||||
// Load tab content
|
// Load tab content
|
||||||
const cache = this._tabCache[tabId];
|
const cache = this._tabCache[tabId];
|
||||||
if (!cache) return;
|
if (!cache) return;
|
||||||
@@ -1996,7 +2051,7 @@ export const TabManager = {
|
|||||||
state.currentPath = cache.path;
|
state.currentPath = cache.path;
|
||||||
syncActiveFileTreeItem(cache.vault, cache.path);
|
syncActiveFileTreeItem(cache.vault, cache.path);
|
||||||
|
|
||||||
const area = document.getElementById("content-area");
|
const area = this._getContentArea();
|
||||||
|
|
||||||
if (cache.data) {
|
if (cache.data) {
|
||||||
// Use cached data
|
// Use cached data
|
||||||
@@ -2108,7 +2163,7 @@ export const TabManager = {
|
|||||||
const cache = this._tabCache[this._activeTabId];
|
const cache = this._tabCache[this._activeTabId];
|
||||||
if (!cache) return;
|
if (!cache) return;
|
||||||
|
|
||||||
const area = document.getElementById("content-area");
|
const area = this._getContentArea();
|
||||||
const rendered = document.getElementById("file-rendered-content");
|
const rendered = document.getElementById("file-rendered-content");
|
||||||
|
|
||||||
cache.scrollTop = area.scrollTop;
|
cache.scrollTop = area.scrollTop;
|
||||||
@@ -2153,7 +2208,7 @@ export const TabManager = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
_showDashboard() {
|
_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)
|
// Save dashboard DOM before clearing (it may have been removed from DOM by renderFile)
|
||||||
let dashboard = document.getElementById("dashboard-home");
|
let dashboard = document.getElementById("dashboard-home");
|
||||||
if (!dashboard) {
|
if (!dashboard) {
|
||||||
@@ -2294,18 +2349,25 @@ export const TabManager = {
|
|||||||
menu.style.left = x + "px";
|
menu.style.left = x + "px";
|
||||||
menu.style.top = y + "px";
|
menu.style.top = y + "px";
|
||||||
menu.innerHTML = `
|
menu.innerHTML = `
|
||||||
|
<div class="context-menu-item" data-action="splitRight"><i data-lucide="layout-panel-right" class="icon"></i><span>Diviser à droite</span></div>
|
||||||
|
<div class="context-menu-item" data-action="splitDown"><i data-lucide="layout-panel-bottom" class="icon"></i><span>Diviser en bas</span></div>
|
||||||
|
<div class="context-menu-separator"></div>
|
||||||
<div class="context-menu-item" data-action="close"><i data-lucide="x" class="icon"></i><span>Fermer</span></div>
|
<div class="context-menu-item" data-action="close"><i data-lucide="x" class="icon"></i><span>Fermer</span></div>
|
||||||
<div class="context-menu-item" data-action="closeOthers"><i data-lucide="x-circle" class="icon"></i><span>Fermer les autres</span></div>
|
<div class="context-menu-item" data-action="closeOthers"><i data-lucide="x-circle" class="icon"></i><span>Fermer les autres</span></div>
|
||||||
<div class="context-menu-item" data-action="closeRight"><i data-lucide="arrow-right-circle" class="icon"></i><span>Fermer à droite</span></div>
|
<div class="context-menu-item" data-action="closeRight"><i data-lucide="arrow-right-circle" class="icon"></i><span>Fermer à droite</span></div>
|
||||||
<div class="context-menu-separator"></div>
|
<div class="context-menu-separator"></div>
|
||||||
<div class="context-menu-item" data-action="closeAll"><i data-lucide="trash-2" class="icon"></i><span>Fermer tout</span></div>
|
<div class="context-menu-item" data-action="closeAll"><i data-lucide="trash-2" class="icon"></i><span>Fermer tout</span></div>
|
||||||
|
${window.PaneManager && window.PaneManager.isSplit() ? '<div class="context-menu-separator"></div><div class="context-menu-item" data-action="closePane"><i data-lucide="panel-right-close" class="icon"></i><span>Fermer le panneau</span></div>' : ''}
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(menu);
|
document.body.appendChild(menu);
|
||||||
safeCreateIcons();
|
safeCreateIcons();
|
||||||
|
|
||||||
menu.addEventListener("click", (e) => {
|
menu.addEventListener("click", (e) => {
|
||||||
const action = e.target.closest(".context-menu-item")?.dataset.action;
|
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 === "closeOthers") this.closeOthers(tabId);
|
||||||
else if (action === "closeRight") this.closeRight(tabId);
|
else if (action === "closeRight") this.closeRight(tabId);
|
||||||
else if (action === "closeAll") this.closeAll();
|
else if (action === "closeAll") this.closeAll();
|
||||||
|
|||||||
+17
-9
@@ -11,6 +11,14 @@ import { DashboardStatsWidget, DashboardRecentWidget, DashboardBookmarkWidget, D
|
|||||||
import { openShareDialog } from './config.js';
|
import { openShareDialog } from './config.js';
|
||||||
import { cacheViewedFile, getCachedFile } from './offline.js';
|
import { cacheViewedFile, getCachedFile } from './offline.js';
|
||||||
import { t } from './i18n.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
|
// Outline/TOC Manager
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -135,7 +143,7 @@ const OutlineManager = {
|
|||||||
const heading = document.getElementById(headingId);
|
const heading = document.getElementById(headingId);
|
||||||
if (!heading) return;
|
if (!heading) return;
|
||||||
|
|
||||||
const contentArea = document.getElementById("content-area");
|
const contentArea = getContentArea();
|
||||||
if (!contentArea) return;
|
if (!contentArea) return;
|
||||||
|
|
||||||
// Calculate offset for fixed header (if any)
|
// Calculate offset for fixed header (if any)
|
||||||
@@ -209,7 +217,7 @@ const ScrollSpyManager = {
|
|||||||
|
|
||||||
if (!headings || headings.length === 0) return;
|
if (!headings || headings.length === 0) return;
|
||||||
|
|
||||||
const contentArea = document.getElementById("content-area");
|
const contentArea = getContentArea();
|
||||||
if (!contentArea) return;
|
if (!contentArea) return;
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
@@ -263,7 +271,7 @@ const ReadingProgressManager = {
|
|||||||
init() {
|
init() {
|
||||||
this.destroy();
|
this.destroy();
|
||||||
|
|
||||||
const contentArea = document.getElementById("content-area");
|
const contentArea = getContentArea();
|
||||||
if (!contentArea) return;
|
if (!contentArea) return;
|
||||||
|
|
||||||
this.scrollHandler = this.throttle(() => {
|
this.scrollHandler = this.throttle(() => {
|
||||||
@@ -275,7 +283,7 @@ const ReadingProgressManager = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
updateProgress() {
|
updateProgress() {
|
||||||
const contentArea = document.getElementById("content-area");
|
const contentArea = getContentArea();
|
||||||
const progressFill = document.getElementById("reading-progress-fill");
|
const progressFill = document.getElementById("reading-progress-fill");
|
||||||
const progressText = document.getElementById("reading-progress-text");
|
const progressText = document.getElementById("reading-progress-text");
|
||||||
|
|
||||||
@@ -304,7 +312,7 @@ const ReadingProgressManager = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
const contentArea = document.getElementById("content-area");
|
const contentArea = getContentArea();
|
||||||
if (contentArea && this.scrollHandler) {
|
if (contentArea && this.scrollHandler) {
|
||||||
contentArea.removeEventListener("scroll", this.scrollHandler);
|
contentArea.removeEventListener("scroll", this.scrollHandler);
|
||||||
}
|
}
|
||||||
@@ -332,7 +340,7 @@ export async function openFile(vaultName, filePath) {
|
|||||||
syncActiveFileTreeItem(vaultName, filePath);
|
syncActiveFileTreeItem(vaultName, filePath);
|
||||||
|
|
||||||
// Show loading state while fetching
|
// Show loading state while fetching
|
||||||
const area = document.getElementById("content-area");
|
const area = getContentArea();
|
||||||
area.innerHTML = '<div class="loading-indicator"><div class="loading-spinner"></div><div>Chargement...</div></div>';
|
area.innerHTML = '<div class="loading-indicator"><div class="loading-spinner"></div><div>Chargement...</div></div>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -400,7 +408,7 @@ async function renderBacklinksPanel(vault, path, container) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function renderFile(data) {
|
export function renderFile(data) {
|
||||||
const area = document.getElementById("content-area");
|
const area = getContentArea();
|
||||||
|
|
||||||
// Handle PDF files — render in iframe with TOC sidebar
|
// Handle PDF files — render in iframe with TOC sidebar
|
||||||
if (data.is_pdf) {
|
if (data.is_pdf) {
|
||||||
@@ -1051,7 +1059,7 @@ export function showWelcome() {
|
|||||||
hideProgressBar();
|
hideProgressBar();
|
||||||
|
|
||||||
// Restore or rebuild the dashboard with tabbed sections
|
// Restore or rebuild the dashboard with tabbed sections
|
||||||
const area = document.getElementById("content-area");
|
const area = getContentArea();
|
||||||
const home = document.getElementById("dashboard-home");
|
const home = document.getElementById("dashboard-home");
|
||||||
|
|
||||||
if (area && !home) {
|
if (area && !home) {
|
||||||
@@ -1244,7 +1252,7 @@ export async function loadSavedSearches() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function showLoading() {
|
export function showLoading() {
|
||||||
const area = document.getElementById("content-area");
|
const area = getContentArea();
|
||||||
area.innerHTML = `
|
area.innerHTML = `
|
||||||
<div class="loading-indicator">
|
<div class="loading-indicator">
|
||||||
<div class="loading-spinner"></div>
|
<div class="loading-spinner"></div>
|
||||||
|
|||||||
@@ -1247,6 +1247,93 @@ select {
|
|||||||
overflow: hidden;
|
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 --- */
|
||||||
.content-area {
|
.content-area {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user