feat: sidebar de navigation mode dossier + permission set-title
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Generated
+5042
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "pena-taury"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "pena_taury_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-fs = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
comrak = { version = "0.28", features = ["syntect"] }
|
||||
syntect = "5"
|
||||
notify = "7"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"identifier": "default",
|
||||
"description": "Permissions par défaut — lecture de fichiers et ouverture de dialogue",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:path:default",
|
||||
"core:event:default",
|
||||
"core:window:default",
|
||||
"core:window:allow-set-title",
|
||||
"core:app:default",
|
||||
"core:webview:default",
|
||||
"fs:read-all",
|
||||
"dialog:allow-open"
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 105 B |
@@ -0,0 +1,204 @@
|
||||
use comrak::plugins::syntect::SyntectAdapterBuilder;
|
||||
use comrak::{markdown_to_html_with_plugins, Options, Plugins};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn render_markdown(content: String) -> String {
|
||||
let adapter = SyntectAdapterBuilder::new()
|
||||
.theme("base16-ocean.dark")
|
||||
.build();
|
||||
let options = Options::default();
|
||||
let mut plugins = Plugins::default();
|
||||
plugins.render.codefence_syntax_highlighter = Some(&adapter);
|
||||
markdown_to_html_with_plugins(&content, &options, &plugins)
|
||||
}
|
||||
|
||||
pub fn convert_file(path: String) -> Result<String, String> {
|
||||
let content = fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
||||
|
||||
let adapter = SyntectAdapterBuilder::new()
|
||||
.theme("InspiredGitHub")
|
||||
.build();
|
||||
|
||||
let mut options = Options::default();
|
||||
options.extension.table = true;
|
||||
options.extension.strikethrough = true;
|
||||
options.extension.autolink = true;
|
||||
options.extension.tasklist = true;
|
||||
|
||||
let mut plugins = Plugins::default();
|
||||
plugins.render.codefence_syntax_highlighter = Some(&adapter);
|
||||
|
||||
Ok(markdown_to_html_with_plugins(&content, &options, &plugins))
|
||||
}
|
||||
|
||||
pub(crate) fn collect_md_files(dir: &Path, result: &mut Vec<String>) -> std::io::Result<()> {
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name();
|
||||
if name.to_string_lossy().starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_md_files(&path, result)?;
|
||||
} else if path.extension().is_some_and(|e| e == "md") {
|
||||
if let Some(s) = path.to_str() {
|
||||
result.push(s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_md_files(dir: String) -> Result<Vec<String>, String> {
|
||||
let path = Path::new(&dir);
|
||||
if !path.is_dir() {
|
||||
return Err(format!("{dir} n'est pas un dossier"));
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
collect_md_files(path, &mut files).map_err(|e| e.to_string())?;
|
||||
files.sort();
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn tmpdir(name: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_markdown_heading() {
|
||||
let html = render_markdown("# Hello\n".to_string());
|
||||
assert!(html.contains("<h1>"));
|
||||
assert!(html.contains("Hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_markdown_code_block() {
|
||||
let html = render_markdown("```rust\nfn main() {}\n```\n".to_string());
|
||||
assert!(html.contains("<code") || html.contains("<pre>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_markdown_empty() {
|
||||
let html = render_markdown(String::new());
|
||||
assert!(!html.contains("<h1>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_file_success() {
|
||||
let dir = tmpdir("pena_convert_success");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("test.md");
|
||||
fs::write(&file, "# Title\n\nParagraph.").unwrap();
|
||||
let result = convert_file(file.to_string_lossy().to_string());
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().contains("<h1>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_file_with_table() {
|
||||
let dir = tmpdir("pena_convert_table");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("table.md");
|
||||
fs::write(&file, "| A | B |\n|---|---|\n| 1 | 2 |").unwrap();
|
||||
let result = convert_file(file.to_string_lossy().to_string());
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().contains("<table>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_file_not_found() {
|
||||
let result = convert_file("/nonexistent/path/does/not/exist.md".to_string());
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_md_flat() {
|
||||
let dir = tmpdir("pena_collect_flat");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(dir.join("a.md"), "").unwrap();
|
||||
fs::write(dir.join("b.md"), "").unwrap();
|
||||
fs::write(dir.join("c.txt"), "").unwrap();
|
||||
let mut result = Vec::new();
|
||||
collect_md_files(&dir, &mut result).unwrap();
|
||||
result.sort();
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
assert_eq!(result.len(), 2);
|
||||
assert!(result[0].ends_with("a.md"));
|
||||
assert!(result[1].ends_with("b.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_md_recursive() {
|
||||
let dir = tmpdir("pena_collect_recursive");
|
||||
let sub = dir.join("docs");
|
||||
fs::create_dir_all(&sub).unwrap();
|
||||
fs::write(dir.join("Home.md"), "").unwrap();
|
||||
fs::write(sub.join("Page.md"), "").unwrap();
|
||||
let mut result = Vec::new();
|
||||
collect_md_files(&dir, &mut result).unwrap();
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
assert_eq!(result.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_md_ignores_hidden_dir() {
|
||||
let dir = tmpdir("pena_collect_hidden_dir");
|
||||
let hidden = dir.join(".hidden");
|
||||
fs::create_dir_all(&hidden).unwrap();
|
||||
fs::write(dir.join("visible.md"), "").unwrap();
|
||||
fs::write(hidden.join("secret.md"), "").unwrap();
|
||||
let mut result = Vec::new();
|
||||
collect_md_files(&dir, &mut result).unwrap();
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert!(result[0].ends_with("visible.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_md_ignores_hidden_file() {
|
||||
let dir = tmpdir("pena_collect_hidden_file");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(dir.join("visible.md"), "").unwrap();
|
||||
fs::write(dir.join(".hidden.md"), "").unwrap();
|
||||
let mut result = Vec::new();
|
||||
collect_md_files(&dir, &mut result).unwrap();
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert!(result[0].ends_with("visible.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_md_nonexistent_dir() {
|
||||
let mut result = Vec::new();
|
||||
let err = collect_md_files(Path::new("/nonexistent/path/for/pena"), &mut result);
|
||||
assert!(err.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_md_files_sorted() {
|
||||
let dir = tmpdir("pena_list_sorted");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(dir.join("z.md"), "").unwrap();
|
||||
fs::write(dir.join("a.md"), "").unwrap();
|
||||
fs::write(dir.join("m.md"), "").unwrap();
|
||||
let result = list_md_files(dir.to_string_lossy().to_string()).unwrap();
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
assert_eq!(result.len(), 3);
|
||||
assert!(result[0] < result[1] && result[1] < result[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_md_files_not_a_dir() {
|
||||
let result = list_md_files("/nonexistent/does/not/exist".to_string());
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
mod core;
|
||||
mod watcher;
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[tauri::command]
|
||||
fn render_markdown(content: String) -> String {
|
||||
core::render_markdown(content)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn convert_file(path: String) -> Result<String, String> {
|
||||
core::convert_file(path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_md_files(dir: String) -> Result<Vec<String>, String> {
|
||||
core::list_md_files(dir)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(watcher::WatcherState(Mutex::new(None)))
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
render_markdown,
|
||||
convert_file,
|
||||
list_md_files,
|
||||
watcher::start_watch,
|
||||
watcher::stop_watch,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("Erreur lors du démarrage de l'application Tauri");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
pena_taury_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc::RecvTimeoutError;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
pub struct WatcherState(pub Mutex<Option<RecommendedWatcher>>);
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
struct FileChangedPayload {
|
||||
path: String,
|
||||
}
|
||||
|
||||
fn is_relevant_path(path: &Path) -> bool {
|
||||
if path
|
||||
.components()
|
||||
.any(|c| c.as_os_str().to_string_lossy().starts_with('.'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
path.extension().is_some_and(|e| e == "md")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn start_watch(
|
||||
app: AppHandle,
|
||||
path: String,
|
||||
state: State<WatcherState>,
|
||||
) -> Result<(), String> {
|
||||
// Drop the previous watcher — disconnects the channel and stops the background thread
|
||||
{
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
*guard = None;
|
||||
}
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel::<notify::Result<notify::Event>>();
|
||||
|
||||
let mut watcher = notify::recommended_watcher(move |res| {
|
||||
let _ = tx.send(res);
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let watch_path = PathBuf::from(&path);
|
||||
let mode = if watch_path.is_dir() {
|
||||
RecursiveMode::Recursive
|
||||
} else {
|
||||
RecursiveMode::NonRecursive
|
||||
};
|
||||
|
||||
watcher
|
||||
.watch(&watch_path, mode)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Debounce thread: collects events per path, emits after 80ms of silence
|
||||
std::thread::spawn(move || {
|
||||
let mut pending: HashMap<PathBuf, Instant> = HashMap::new();
|
||||
|
||||
loop {
|
||||
match rx.recv_timeout(Duration::from_millis(10)) {
|
||||
Ok(Ok(event)) => {
|
||||
if matches!(
|
||||
event.kind,
|
||||
EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
|
||||
) {
|
||||
for p in event.paths {
|
||||
if is_relevant_path(&p) {
|
||||
pending.insert(p, Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => {}
|
||||
Err(RecvTimeoutError::Timeout) => {}
|
||||
Err(RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
let ready: Vec<PathBuf> = pending
|
||||
.iter()
|
||||
.filter(|(_, t)| now.duration_since(**t) >= Duration::from_millis(80))
|
||||
.map(|(p, _)| p.clone())
|
||||
.collect();
|
||||
|
||||
for p in ready {
|
||||
pending.remove(&p);
|
||||
let _ = app.emit(
|
||||
"file-changed",
|
||||
FileChangedPayload {
|
||||
path: p.to_string_lossy().to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
*guard = Some(watcher);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn stop_watch(state: State<WatcherState>) {
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
*guard = None;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Pena",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.pena.app",
|
||||
"build": {
|
||||
"frontendDist": "../src"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Pena — Markdown Viewer",
|
||||
"width": 1280,
|
||||
"height": 800
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": false,
|
||||
"targets": "all",
|
||||
"icon": []
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user