diff --git a/src-tauri/src/infrastructure/notify_watcher.rs b/src-tauri/src/infrastructure/notify_watcher.rs index a528cb0..b263820 100644 --- a/src-tauri/src/infrastructure/notify_watcher.rs +++ b/src-tauri/src/infrastructure/notify_watcher.rs @@ -64,3 +64,44 @@ fn is_relevant_path(path: &Path) -> bool { } path.extension().is_some_and(|e| e == "md") } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn relevant_path_md_file() { + assert!(is_relevant_path(Path::new("/docs/notes.md"))); + } + + #[test] + fn relevant_path_nested_md() { + assert!(is_relevant_path(Path::new("/wiki/section/page.md"))); + } + + #[test] + fn irrelevant_path_txt_extension() { + assert!(!is_relevant_path(Path::new("/docs/readme.txt"))); + } + + #[test] + fn irrelevant_path_no_extension() { + assert!(!is_relevant_path(Path::new("/docs/makefile"))); + } + + #[test] + fn irrelevant_path_hidden_file() { + assert!(!is_relevant_path(Path::new("/docs/.hidden.md"))); + } + + #[test] + fn irrelevant_path_hidden_dir() { + assert!(!is_relevant_path(Path::new("/docs/.git/file.md"))); + } + + #[test] + fn irrelevant_path_hidden_dir_at_root() { + assert!(!is_relevant_path(Path::new(".hidden/file.md"))); + } +} diff --git a/src/main.js b/src/main.js index e88df80..22f248a 100644 --- a/src/main.js +++ b/src/main.js @@ -1,7 +1,6 @@ -let currentPath = null; -let currentMode = null; // 'file' | 'dir' +import { showHome, openPath } from './router.js'; +import { initCssModal } from './ui/css-modal.js'; -const titlebarTitle = document.getElementById('titlebar-title'); const win = window.__TAURI__.window.getCurrentWindow(); document.getElementById('titlebar').addEventListener('mousedown', (e) => { @@ -16,239 +15,6 @@ document.getElementById('titlebar-maximize').addEventListener('click', async () }); document.getElementById('titlebar-close').addEventListener('click', () => win.close()); -const viewHome = document.getElementById('view-home'); -const viewReader = document.getElementById('view-reader'); -const sidebar = document.querySelector('#sidebar'); -const content = document.querySelector('#content'); - -const RECENTS_KEY = 'pena_recents'; -const RECENTS_MAX = 8; - -function loadRecents() { - try { return JSON.parse(localStorage.getItem(RECENTS_KEY) ?? '[]'); } - catch { return []; } -} - -function saveRecent(path, mode) { - const name = path.replace(/\\/g, '/').split('/').pop().replace(/\.md$/i, ''); - const parent = path.replace(/\\/g, '/').split('/').slice(0, -1).join('/'); - const recents = loadRecents().filter(r => r.path !== path); - recents.unshift({ path, mode, name, parent }); - localStorage.setItem(RECENTS_KEY, JSON.stringify(recents.slice(0, RECENTS_MAX))); - renderRecents(); -} - -function renderRecents() { - const recents = loadRecents(); - const container = document.getElementById('home-recents'); - const list = document.getElementById('home-recents-list'); - if (recents.length === 0) { - container.classList.add('hidden'); - return; - } - container.classList.remove('hidden'); - list.innerHTML = recents.map(r => { - const icon = r.mode === 'dir' ? 'π' : 'π'; - const escaped = escapeHtml(r.path); - return `
Impossible de charger le fichier : ${err}
`; - } -} - -async function openPath(path, mode) { - currentPath = path; - currentMode = mode; - saveRecent(path, mode); - - if (mode === 'file') { - showReader(); - const sep = path.includes('\\') ? '\\' : '/'; - const parentDir = path.split(sep).slice(0, -1).join(sep); - try { - const files = await window.__TAURI__.core.invoke('list_md_files', { dir: parentDir }); - sidebar.classList.remove('hidden'); - content.style.marginLeft = ''; - renderSidebar(parentDir, files, path); - } catch { - sidebar.innerHTML = ''; - sidebar.classList.add('hidden'); - content.style.marginLeft = '0'; - } - await loadPage(path); - return; - } - - // mode === 'dir' - sidebar.classList.remove('hidden'); - content.style.marginLeft = ''; - - try { - const files = await window.__TAURI__.core.invoke('list_md_files', { dir: path }); - showReader(); - - const sep = path.includes('\\') ? '\\' : '/'; - const home = files.find(f => { - const name = f.split(sep).pop(); - return name === 'Home.md' || name === 'home.md'; - }); - - const firstFile = home ?? files[0]; - renderSidebar(path, files, firstFile); - await loadPage(firstFile); - } catch (err) { - showReader(); - content.innerHTML = `Impossible d'ouvrir le dossier : ${err}
`; - } -} - -renderRecents(); - document.getElementById('btn-open-file').addEventListener('click', async () => { const selected = await window.__TAURI__.dialog.open({ multiple: false, @@ -264,81 +30,7 @@ document.getElementById('btn-open-dir').addEventListener('click', async () => { await openPath(selected, 'dir'); }); -document.getElementById('btn-back').addEventListener('click', () => { - currentPath = null; - currentMode = null; - sidebar.innerHTML = ''; - win.setTitle('Pena β Markdown Viewer'); - titlebarTitle.textContent = 'Pena β Markdown Viewer'; - showHome(); -}); +document.getElementById('btn-back').addEventListener('click', showHome); -// ββ CSS personnalisΓ© ββ -const CSS_TABS = ['general', 'police', 'contenu', 'avance']; -let activeTab = CSS_TABS[0]; - -function cssKey(tab) { return `pena_css_${tab}`; } - -function loadTabCss(tab) { return localStorage.getItem(cssKey(tab)) ?? ''; } - -function saveTabCss(tab, css) { - if (css.trim()) localStorage.setItem(cssKey(tab), css); - else localStorage.removeItem(cssKey(tab)); -} - -function buildCombinedCss() { - return CSS_TABS.map(t => loadTabCss(t)).filter(Boolean).join('\n'); -} - -function applyCustomCss() { - let styleEl = document.getElementById('pena-custom-style'); - if (!styleEl) { - styleEl = document.createElement('style'); - styleEl.id = 'pena-custom-style'; - document.head.appendChild(styleEl); - } - styleEl.textContent = buildCombinedCss(); -} - -applyCustomCss(); - -const cssModalOverlay = document.getElementById('css-modal-overlay'); -const cssEditor = document.getElementById('css-editor'); - -function openCssModal() { - cssEditor.value = loadTabCss(activeTab); - cssModalOverlay.classList.remove('hidden'); -} - -function closeCssModal() { - cssModalOverlay.classList.add('hidden'); -} - -document.getElementById('btn-customize-css').addEventListener('click', openCssModal); -document.getElementById('btn-css-close').addEventListener('click', closeCssModal); -document.getElementById('btn-css-cancel').addEventListener('click', closeCssModal); - -cssModalOverlay.addEventListener('click', (e) => { - if (e.target === cssModalOverlay) closeCssModal(); -}); - -document.getElementById('css-tabs').addEventListener('click', (e) => { - const tab = e.target.closest('.css-modal__tab'); - if (!tab) return; - saveTabCss(activeTab, cssEditor.value); - activeTab = tab.dataset.tab; - document.querySelectorAll('.css-modal__tab').forEach(t => t.classList.toggle('active', t === tab)); - cssEditor.value = loadTabCss(activeTab); -}); - -document.getElementById('btn-css-apply').addEventListener('click', () => { - saveTabCss(activeTab, cssEditor.value); - applyCustomCss(); - closeCssModal(); -}); - -document.getElementById('btn-css-reset').addEventListener('click', () => { - CSS_TABS.forEach(t => localStorage.removeItem(cssKey(t))); - cssEditor.value = ''; - applyCustomCss(); -}); +initCssModal(); +showHome(); diff --git a/src/router.js b/src/router.js new file mode 100644 index 0000000..d3f5bad --- /dev/null +++ b/src/router.js @@ -0,0 +1,74 @@ +import { appState } from './state/app-state.js'; +import { listMdFiles } from './services/files.js'; +import { renderSidebar } from './ui/sidebar.js'; +import { loadPage } from './ui/reader.js'; +import { saveRecent, renderRecents } from './ui/home.js'; + +const viewHome = document.getElementById('view-home'); +const viewReader = document.getElementById('view-reader'); +const sidebarEl = document.querySelector('#sidebar'); +const content = document.querySelector('#content'); +const win = window.__TAURI__.window.getCurrentWindow(); +const titlebarTitle = document.getElementById('titlebar-title'); + +export function showHome() { + appState.currentPath = null; + appState.currentMode = null; + sidebarEl.innerHTML = ''; + win.setTitle('Pena β Markdown Viewer'); + titlebarTitle.textContent = 'Pena β Markdown Viewer'; + viewReader.classList.add('hidden'); + viewHome.classList.remove('hidden'); + renderRecents(openPath); +} + +export function showReader() { + viewHome.classList.add('hidden'); + viewReader.classList.remove('hidden'); +} + +export async function openPath(path, mode) { + appState.currentPath = path; + appState.currentMode = mode; + saveRecent(path, mode); + + if (mode === 'file') { + showReader(); + const sep = path.includes('\\') ? '\\' : '/'; + const parentDir = path.split(sep).slice(0, -1).join(sep); + try { + const files = await listMdFiles(parentDir); + sidebarEl.classList.remove('hidden'); + content.style.marginLeft = ''; + renderSidebar(parentDir, files, path, filePath => loadPage(filePath, appState.currentMode, sidebarEl)); + } catch { + sidebarEl.innerHTML = ''; + sidebarEl.classList.add('hidden'); + content.style.marginLeft = '0'; + } + await loadPage(path, appState.currentMode, sidebarEl); + return; + } + + // mode === 'dir' + sidebarEl.classList.remove('hidden'); + content.style.marginLeft = ''; + + try { + const files = await listMdFiles(path); + showReader(); + + const sep = path.includes('\\') ? '\\' : '/'; + const home = files.find(f => { + const name = f.split(sep).pop(); + return name === 'Home.md' || name === 'home.md'; + }); + + const firstFile = home ?? files[0]; + renderSidebar(path, files, firstFile, filePath => loadPage(filePath, appState.currentMode, sidebarEl)); + await loadPage(firstFile, appState.currentMode, sidebarEl); + } catch (err) { + showReader(); + content.innerHTML = `Impossible d'ouvrir le dossier : ${err}
`; + } +} diff --git a/src/services/files.js b/src/services/files.js new file mode 100644 index 0000000..050af1d --- /dev/null +++ b/src/services/files.js @@ -0,0 +1,3 @@ +export function listMdFiles(dir) { + return window.__TAURI__.core.invoke('list_md_files', { dir }); +} diff --git a/src/services/markdown.js b/src/services/markdown.js new file mode 100644 index 0000000..40addf7 --- /dev/null +++ b/src/services/markdown.js @@ -0,0 +1,7 @@ +export function renderMarkdown(content) { + return window.__TAURI__.core.invoke('render_markdown', { content }); +} + +export function convertFile(path) { + return window.__TAURI__.core.invoke('convert_file', { path }); +} diff --git a/src/services/watcher.js b/src/services/watcher.js new file mode 100644 index 0000000..c24247d --- /dev/null +++ b/src/services/watcher.js @@ -0,0 +1,11 @@ +export function startWatch(path) { + return window.__TAURI__.core.invoke('start_watch', { path }); +} + +export function stopWatch() { + return window.__TAURI__.core.invoke('stop_watch'); +} + +export function onFileChanged(callback) { + return window.__TAURI__.event.listen('file-changed', callback); +} diff --git a/src/state/app-state.js b/src/state/app-state.js new file mode 100644 index 0000000..a81822b --- /dev/null +++ b/src/state/app-state.js @@ -0,0 +1 @@ +export const appState = { currentPath: null, currentMode: null }; diff --git a/src/ui/css-modal.js b/src/ui/css-modal.js new file mode 100644 index 0000000..6b8971f --- /dev/null +++ b/src/ui/css-modal.js @@ -0,0 +1,66 @@ +const CSS_TABS = ['general', 'police', 'contenu', 'avance']; +let activeTab = CSS_TABS[0]; + +function cssKey(tab) { return `pena_css_${tab}`; } + +function loadTabCss(tab) { return localStorage.getItem(cssKey(tab)) ?? ''; } + +function saveTabCss(tab, css) { + if (css.trim()) localStorage.setItem(cssKey(tab), css); + else localStorage.removeItem(cssKey(tab)); +} + +function applyCustomCss() { + let styleEl = document.getElementById('pena-custom-style'); + if (!styleEl) { + styleEl = document.createElement('style'); + styleEl.id = 'pena-custom-style'; + document.head.appendChild(styleEl); + } + styleEl.textContent = CSS_TABS.map(t => loadTabCss(t)).filter(Boolean).join('\n'); +} + +export function initCssModal() { + applyCustomCss(); + + const cssModalOverlay = document.getElementById('css-modal-overlay'); + const cssEditor = document.getElementById('css-editor'); + + function openCssModal() { + cssEditor.value = loadTabCss(activeTab); + cssModalOverlay.classList.remove('hidden'); + } + + function closeCssModal() { + cssModalOverlay.classList.add('hidden'); + } + + document.getElementById('btn-customize-css').addEventListener('click', openCssModal); + document.getElementById('btn-css-close').addEventListener('click', closeCssModal); + document.getElementById('btn-css-cancel').addEventListener('click', closeCssModal); + + cssModalOverlay.addEventListener('click', (e) => { + if (e.target === cssModalOverlay) closeCssModal(); + }); + + document.getElementById('css-tabs').addEventListener('click', (e) => { + const tab = e.target.closest('.css-modal__tab'); + if (!tab) return; + saveTabCss(activeTab, cssEditor.value); + activeTab = tab.dataset.tab; + document.querySelectorAll('.css-modal__tab').forEach(t => t.classList.toggle('active', t === tab)); + cssEditor.value = loadTabCss(activeTab); + }); + + document.getElementById('btn-css-apply').addEventListener('click', () => { + saveTabCss(activeTab, cssEditor.value); + applyCustomCss(); + closeCssModal(); + }); + + document.getElementById('btn-css-reset').addEventListener('click', () => { + CSS_TABS.forEach(t => localStorage.removeItem(cssKey(t))); + cssEditor.value = ''; + applyCustomCss(); + }); +} diff --git a/src/ui/home.js b/src/ui/home.js new file mode 100644 index 0000000..3b29a94 --- /dev/null +++ b/src/ui/home.js @@ -0,0 +1,44 @@ +import { escapeHtml } from '../utils.js'; + +const RECENTS_KEY = 'pena_recents'; +const RECENTS_MAX = 8; + +export function loadRecents() { + try { return JSON.parse(localStorage.getItem(RECENTS_KEY) ?? '[]'); } + catch { return []; } +} + +export function saveRecent(path, mode) { + const name = path.replace(/\\/g, '/').split('/').pop().replace(/\.md$/i, ''); + const parent = path.replace(/\\/g, '/').split('/').slice(0, -1).join('/'); + const recents = loadRecents().filter(r => r.path !== path); + recents.unshift({ path, mode, name, parent }); + localStorage.setItem(RECENTS_KEY, JSON.stringify(recents.slice(0, RECENTS_MAX))); +} + +export function renderRecents(onOpen) { + const recents = loadRecents(); + const container = document.getElementById('home-recents'); + const list = document.getElementById('home-recents-list'); + if (recents.length === 0) { + container.classList.add('hidden'); + return; + } + container.classList.remove('hidden'); + list.innerHTML = recents.map(r => { + const icon = r.mode === 'dir' ? 'π' : 'π'; + const escaped = escapeHtml(r.path); + return `Impossible de charger le fichier : ${err}
`; + } +} diff --git a/src/ui/sidebar.js b/src/ui/sidebar.js new file mode 100644 index 0000000..f947d66 --- /dev/null +++ b/src/ui/sidebar.js @@ -0,0 +1,92 @@ +import { escapeHtml } from '../utils.js'; + +function buildTree(relPaths, sep) { + const tree = { _files: [], _dirs: {} }; + relPaths.forEach((rel) => { + const parts = rel.split(sep); + let node = tree; + for (let i = 0; i < parts.length - 1; i++) { + const dir = parts[i]; + if (!node._dirs[dir]) node._dirs[dir] = { _files: [], _dirs: {} }; + node = node._dirs[dir]; + } + node._files.push(rel); + }); + return tree; +} + +function renderTree(node, prefix, sep, currentRel, depth) { + let html = ''; + const depthClass = depth > 0 ? ` class="nav-depth-${Math.min(depth, 3)}"` : ''; + + node._files.slice().sort().forEach((rel) => { + const label = rel.split(sep).pop().replace(/\.md$/i, '').replace(/-/g, ' '); + const abs = prefix + rel; + const cls = rel === currentRel ? ' active' : ''; + if (depth === 0) { + html += `\n