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 `
  • - -
  • `; - }).join(''); - list.querySelectorAll('.home-recents__item').forEach(btn => { - btn.addEventListener('click', () => openPath(btn.dataset.path, btn.dataset.mode)); - }); -} - -function showHome() { - viewReader.classList.add('hidden'); - viewHome.classList.remove('hidden'); -} - -function showReader() { - viewHome.classList.add('hidden'); - viewReader.classList.remove('hidden'); -} - -function escapeHtml(str) { - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - -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
  • ${escapeHtml(label)}
  • `; - } else { - html += `\n${escapeHtml(label)}`; - } - }); - - Object.keys(node._dirs).sort().forEach((dirName) => { - const child = node._dirs[dirName]; - const isOpen = currentRel && currentRel.split(sep).includes(dirName); - const openAttr = isOpen ? ' open' : ''; - - const homeFile = child._files.find((f) => /^home\.md$/i.test(f.split(sep).pop())); - let folderLabel; - if (homeFile) { - const abs = prefix + homeFile; - const cls = homeFile === currentRel ? ' active' : ''; - folderLabel = `${escapeHtml(dirName)}`; - } else { - folderLabel = `${escapeHtml(dirName)}`; - } - - const arrow = `β–Ά`; - - const childNode = { - _files: homeFile ? child._files.filter((f) => f !== homeFile) : child._files, - _dirs: child._dirs, - }; - - html += `\n`; - }); - - return html; -} - -function buildSidebar(baseDir, files, currentFile) { - const sep = baseDir.includes('\\') ? '\\' : '/'; - const dirName = baseDir.split(sep).pop(); - const prefix = baseDir.endsWith(sep) ? baseDir : baseDir + sep; - const toRel = abs => abs.startsWith(prefix) ? abs.slice(prefix.length) : abs; - const currentRel = currentFile ? toRel(currentFile) : null; - const relPaths = files.map(toRel); - const tree = buildTree(relPaths, sep); - - let html = ` - `; - return html; -} - -function renderSidebar(baseDir, files, currentFile) { - sidebar.innerHTML = buildSidebar(baseDir, files, currentFile); - sidebar.querySelectorAll('a[data-path]').forEach(a => { - a.addEventListener('click', e => { - e.preventDefault(); - loadPage(a.dataset.path); - }); - }); -} - -async function loadPage(filePath) { - try { - const html = await window.__TAURI__.core.invoke('convert_file', { path: filePath }); - content.innerHTML = html; - - const basename = filePath.split('/').pop().split('\\').pop(); - const title = basename.replace(/\.md$/i, ''); - await win.setTitle(title); - titlebarTitle.textContent = title; - - if (currentMode === 'dir' || currentMode === 'file') { - sidebar.querySelectorAll('a').forEach(a => { - a.classList.toggle('active', a.dataset.path === filePath); - }); - const active = sidebar.querySelector('a[data-path].active'); - if (active) { - let el = active.parentElement; - while (el && el !== sidebar) { - if (el.tagName === 'DETAILS') el.open = true; - el = el.parentElement; - } - } - } - } catch (err) { - content.innerHTML = `

    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 `
  • + +
  • `; + }).join(''); + list.querySelectorAll('.home-recents__item').forEach(btn => { + btn.addEventListener('click', () => onOpen(btn.dataset.path, btn.dataset.mode)); + }); +} diff --git a/src/ui/reader.js b/src/ui/reader.js new file mode 100644 index 0000000..9b74170 --- /dev/null +++ b/src/ui/reader.js @@ -0,0 +1,33 @@ +import { convertFile } from '../services/markdown.js'; + +const win = window.__TAURI__.window.getCurrentWindow(); +const titlebarTitle = document.getElementById('titlebar-title'); +const content = document.querySelector('#content'); + +export async function loadPage(filePath, mode, sidebarEl) { + try { + const html = await convertFile(filePath); + content.innerHTML = html; + + const basename = filePath.split('/').pop().split('\\').pop(); + const title = basename.replace(/\.md$/i, ''); + await win.setTitle(title); + titlebarTitle.textContent = title; + + if (mode === 'dir' || mode === 'file') { + sidebarEl.querySelectorAll('a').forEach(a => { + a.classList.toggle('active', a.dataset.path === filePath); + }); + const active = sidebarEl.querySelector('a[data-path].active'); + if (active) { + let el = active.parentElement; + while (el && el !== sidebarEl) { + if (el.tagName === 'DETAILS') el.open = true; + el = el.parentElement; + } + } + } + } catch (err) { + content.innerHTML = `

    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
  • ${escapeHtml(label)}
  • `; + } else { + html += `\n${escapeHtml(label)}`; + } + }); + + Object.keys(node._dirs).sort().forEach((dirName) => { + const child = node._dirs[dirName]; + const isOpen = currentRel && currentRel.split(sep).includes(dirName); + const openAttr = isOpen ? ' open' : ''; + + const homeFile = child._files.find((f) => /^home\.md$/i.test(f.split(sep).pop())); + let folderLabel; + if (homeFile) { + const abs = prefix + homeFile; + const cls = homeFile === currentRel ? ' active' : ''; + folderLabel = `${escapeHtml(dirName)}`; + } else { + folderLabel = `${escapeHtml(dirName)}`; + } + + const arrow = `β–Ά`; + + const childNode = { + _files: homeFile ? child._files.filter((f) => f !== homeFile) : child._files, + _dirs: child._dirs, + }; + + html += `\n`; + }); + + return html; +} + +function buildSidebar(baseDir, files, currentFile) { + const sep = baseDir.includes('\\') ? '\\' : '/'; + const dirName = baseDir.split(sep).pop(); + const prefix = baseDir.endsWith(sep) ? baseDir : baseDir + sep; + const toRel = abs => abs.startsWith(prefix) ? abs.slice(prefix.length) : abs; + const currentRel = currentFile ? toRel(currentFile) : null; + const relPaths = files.map(toRel); + const tree = buildTree(relPaths, sep); + + let html = ` + `; + return html; +} + +export function renderSidebar(baseDir, files, currentFile, onNavigate) { + const sidebarEl = document.querySelector('#sidebar'); + sidebarEl.innerHTML = buildSidebar(baseDir, files, currentFile); + sidebarEl.querySelectorAll('a[data-path]').forEach(a => { + a.addEventListener('click', e => { + e.preventDefault(); + onNavigate(a.dataset.path); + }); + }); +} diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..75eecc0 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,7 @@ +export function escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +}