Compare commits
17 Commits
2df558e10f
..
0.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 1220ce1bb0 | |||
| 4bb2c6cc77 | |||
| 2841881a01 | |||
| 8b0f128313 | |||
| 2d9a1e64f9 | |||
| ebb648bb2d | |||
| df899bd1a8 | |||
| 75c85e7e0a | |||
| e34528c886 | |||
| 8b39c09484 | |||
| b8ba15d832 | |||
| d47e93b3c3 | |||
| cdafffaa70 | |||
| 6ae7a434c4 | |||
| 9eae97f1d8 | |||
| 7d2f56a364 | |||
| ec239bb42e |
@@ -0,0 +1,52 @@
|
||||
# Règles — Architecture (Pena-tauri)
|
||||
|
||||
## Principe
|
||||
|
||||
L'architecture est **Clean Architecture**. Toute nouvelle fonctionnalité doit respecter ce découpage sans exception.
|
||||
|
||||
## Structure Rust (`src-tauri/src/`)
|
||||
|
||||
```
|
||||
domain/ — entités et traits purs, zéro dépendance externe
|
||||
application/ — services métier, dépend uniquement de domain/
|
||||
infrastructure/ — implémentations concrètes des traits de domain/
|
||||
commands/ — points d'entrée Tauri (#[tauri::command]), orchestrent application/
|
||||
```
|
||||
|
||||
## Structure JS (`src/`)
|
||||
|
||||
```
|
||||
services/ — appels Tauri (invoke), logique de données
|
||||
state/ — état global de l'application
|
||||
ui/ — composants d'affichage, sans logique métier
|
||||
```
|
||||
|
||||
## Règles de dépendance
|
||||
|
||||
| Couche | Peut dépendre de | Ne peut PAS dépendre de |
|
||||
|---|---|---|
|
||||
| `domain` | rien | `application`, `infrastructure`, `commands` |
|
||||
| `application` | `domain` | `infrastructure`, `commands` |
|
||||
| `infrastructure` | `domain` | `application`, `commands` |
|
||||
| `commands` | `application`, `domain` | `infrastructure` directement |
|
||||
| `ui/` (JS) | `services/`, `state/` | rien d'externe hors Tauri |
|
||||
| `services/` (JS) | invoke Tauri | `ui/` |
|
||||
|
||||
## Ce qui est interdit
|
||||
|
||||
- Instancier une implémentation concrète (`ComrakRenderer`, `FileRepository`, etc.) depuis `application/` ou `commands/` — passer par injection de dépendance ou par le point d'assemblage (`lib.rs`).
|
||||
- Mettre de la logique métier dans `commands/` ou `ui/`.
|
||||
- Mettre des appels Tauri (`invoke`) dans `ui/` — les déléguer à `services/`.
|
||||
- Créer un module hors de ces quatre couches Rust sans justification explicite.
|
||||
|
||||
## Point d'assemblage
|
||||
|
||||
`lib.rs` est le seul endroit où les implémentations concrètes sont instanciées et câblées aux services.
|
||||
|
||||
## Nouveau code
|
||||
|
||||
Avant d'ajouter un fichier, se poser la question : **dans quelle couche appartient cette responsabilité ?**
|
||||
- Règle métier / contrat → `domain/`
|
||||
- Orchestration d'un cas d'usage → `application/`
|
||||
- Accès I/O, filesystem, bibliothèque tierce → `infrastructure/`
|
||||
- Commande exposée à Tauri → `commands/`
|
||||
@@ -1,2 +1,9 @@
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
|
||||
# Artefacts de build Flatpak
|
||||
flatpak/.build/
|
||||
flatpak/.repo/
|
||||
flatpak/.flatpak-builder/
|
||||
flatpak/pena.deb
|
||||
flatpak/*.flatpak
|
||||
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Construit un paquet Flatpak de Pena à partir du .deb généré par Tauri.
|
||||
#
|
||||
# Étapes :
|
||||
# 1. `cargo tauri build --bundles deb` -> produit le .deb autonome
|
||||
# 2. Copie du .deb à côté du manifeste sous le nom `pena.deb`
|
||||
# 3. `flatpak-builder` construit le bundle dans la sandbox GNOME
|
||||
# 4. Installation locale (--user) et/ou export en fichier .flatpak
|
||||
#
|
||||
# Usage :
|
||||
# ./build-flatpak.sh # build + installation locale (--user)
|
||||
# ./build-flatpak.sh --bundle # build + export d'un fichier Pena.flatpak
|
||||
# ./build-flatpak.sh --no-deb # réutilise le .deb déjà présent (build rapide)
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
APP_ID="com.pena.app"
|
||||
RUNTIME_VERSION="48"
|
||||
RUST_CONTAINER="ubuntu-rust" # distrobox contenant cargo-tauri + libs GTK/WebKit
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")" # .../Pena-tauri
|
||||
MANIFEST="$SCRIPT_DIR/${APP_ID}.yml"
|
||||
BUILD_DIR="$SCRIPT_DIR/.build" # arbre de build flatpak-builder
|
||||
REPO_DIR="$SCRIPT_DIR/.repo" # dépôt ostree temporaire
|
||||
DEB_TARGET="$SCRIPT_DIR/pena.deb"
|
||||
TAURI_CONF="$PROJECT_DIR/src-tauri/tauri.conf.json"
|
||||
METAINFO="$SCRIPT_DIR/${APP_ID}.metainfo.xml"
|
||||
|
||||
DO_BUNDLE=0
|
||||
BUILD_DEB=1
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--bundle) DO_BUNDLE=1 ;;
|
||||
--no-deb) BUILD_DEB=0 ;;
|
||||
-h|--help) sed -n '2,16p' "$0"; exit 0 ;;
|
||||
*) echo "Argument inconnu : $arg" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- Vérification des outils -------------------------------------------------
|
||||
need() { command -v "$1" >/dev/null 2>&1 || { echo "Outil manquant : $1" >&2; exit 1; }; }
|
||||
need flatpak
|
||||
|
||||
# flatpak-builder : commande native, sinon le Flatpak org.flatpak.Builder
|
||||
# (cas des distributions atomiques type Bazzite/Silverblue).
|
||||
if command -v flatpak-builder >/dev/null 2>&1; then
|
||||
FLATPAK_BUILDER=(flatpak-builder)
|
||||
elif flatpak info org.flatpak.Builder >/dev/null 2>&1; then
|
||||
FLATPAK_BUILDER=(flatpak run org.flatpak.Builder)
|
||||
else
|
||||
echo "flatpak-builder est requis. Installez-le :" >&2
|
||||
echo " flatpak install -y flathub org.flatpak.Builder" >&2
|
||||
echo " ou sur Fedora classique : sudo dnf install flatpak-builder" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Remote flathub (scope --user) -------------------------------------------
|
||||
# flatpak-builder est lancé en --user et installe les dépendances (runtime/SDK
|
||||
# via --install-deps-from=flathub) dans cette même installation. Le remote
|
||||
# flathub doit donc exister au niveau --user, ce qui n'est pas le cas par
|
||||
# défaut sur les distributions atomiques (où flathub est un remote système).
|
||||
echo ">> Vérification du remote flathub (--user)…"
|
||||
flatpak remote-add --if-not-exists --user flathub \
|
||||
https://flathub.org/repo/flathub.flatpakrepo
|
||||
|
||||
# --- 0. Version : source de vérité = tauri.conf.json -------------------------
|
||||
# On lit la version applicative depuis tauri.conf.json et on la répercute dans
|
||||
# le metainfo AppStream (release) afin que le Flatpak porte toujours la
|
||||
# dernière version déclarée dans les sources.
|
||||
need jq
|
||||
VERSION="$(jq -r '.version' "$TAURI_CONF")"
|
||||
if [[ -z "$VERSION" || "$VERSION" == "null" ]]; then
|
||||
echo "Impossible de lire la version dans $TAURI_CONF" >&2
|
||||
exit 1
|
||||
fi
|
||||
TODAY="$(date -u +%Y-%m-%d)"
|
||||
echo ">> Version applicative : $VERSION (build du $TODAY)"
|
||||
|
||||
# Met à jour (ou insère) l'entrée <release> du metainfo pour cette version.
|
||||
if grep -q "version=\"$VERSION\"" "$METAINFO"; then
|
||||
echo ">> Release $VERSION déjà présente dans le metainfo."
|
||||
else
|
||||
echo ">> Ajout de la release $VERSION au metainfo…"
|
||||
sed -i \
|
||||
"s#<releases>#<releases>\n <release version=\"$VERSION\" date=\"$TODAY\"/>#" \
|
||||
"$METAINFO"
|
||||
fi
|
||||
|
||||
# --- 1. Build du .deb via Tauri ---------------------------------------------
|
||||
if [[ "$BUILD_DEB" -eq 1 ]]; then
|
||||
# La compilation Tauri exige les libs de dev GTK/WebKit, absentes de l'hôte
|
||||
# (distribution atomique). On build donc dans le distrobox `$RUST_CONTAINER`,
|
||||
# qui embarque cargo-tauri et ces libs. flatpak-builder reste sur l'hôte.
|
||||
need distrobox
|
||||
echo ">> Build du paquet .deb dans le distrobox $RUST_CONTAINER…"
|
||||
distrobox enter "$RUST_CONTAINER" -- \
|
||||
bash -lc "cd '$PROJECT_DIR' && cargo tauri build --bundles deb"
|
||||
fi
|
||||
|
||||
DEB_SRC="$(find "$PROJECT_DIR/src-tauri/target/release/bundle/deb" -name '*.deb' 2>/dev/null | sort | tail -n1 || true)"
|
||||
if [[ -z "$DEB_SRC" ]]; then
|
||||
echo "Aucun .deb trouvé. Lancez le script sans --no-deb." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo ">> Utilisation du .deb : $DEB_SRC"
|
||||
cp -f "$DEB_SRC" "$DEB_TARGET"
|
||||
|
||||
# Icône installée dans le Flatpak (128x128) depuis les sources du projet.
|
||||
cp -f "$PROJECT_DIR/src-tauri/icons/128x128.png" "$SCRIPT_DIR/icon.png"
|
||||
|
||||
# --- 2. Build Flatpak --------------------------------------------------------
|
||||
# Construit l'app dans un dépôt OSTree local ($REPO_DIR) ; les dépendances
|
||||
# manquantes (runtime/SDK) sont récupérées depuis flathub.
|
||||
echo ">> Construction du Flatpak…"
|
||||
rm -rf "$BUILD_DIR"
|
||||
FB_ARGS=(--force-clean --user --install-deps-from=flathub --repo="$REPO_DIR")
|
||||
if [[ "$DO_BUNDLE" -eq 0 ]]; then
|
||||
FB_ARGS+=(--install)
|
||||
fi
|
||||
"${FLATPAK_BUILDER[@]}" "${FB_ARGS[@]}" "$BUILD_DIR" "$MANIFEST"
|
||||
|
||||
# --- 3. Export d'un fichier .flatpak (optionnel) -----------------------------
|
||||
if [[ "$DO_BUNDLE" -eq 1 ]]; then
|
||||
OUT="$SCRIPT_DIR/Pena.flatpak"
|
||||
echo ">> Export du bundle : $OUT"
|
||||
flatpak build-bundle "$REPO_DIR" "$OUT" "$APP_ID" \
|
||||
--runtime-repo=https://flathub.org/repo/flathub.flatpakrepo
|
||||
echo ">> Nettoyage du dépôt OSTree temporaire…"
|
||||
rm -rf "$REPO_DIR"
|
||||
echo ">> Installer avec : flatpak install --user $OUT"
|
||||
else
|
||||
echo ">> Pena $VERSION installé. Lancer avec : flatpak run $APP_ID"
|
||||
fi
|
||||
|
||||
echo ">> Terminé."
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>com.pena.app</id>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
<name>Pena</name>
|
||||
<summary>Lecteur de fichiers Markdown</summary>
|
||||
<description>
|
||||
<p>
|
||||
Pena est une application de bureau pour lire des fichiers Markdown.
|
||||
La conversion Markdown vers HTML est réalisée côté Rust (comrak), avec
|
||||
coloration syntaxique des blocs de code et plusieurs thèmes de lecture.
|
||||
</p>
|
||||
</description>
|
||||
<launchable type="desktop-id">com.pena.app.desktop</launchable>
|
||||
<categories>
|
||||
<category>Utility</category>
|
||||
<category>TextEditor</category>
|
||||
</categories>
|
||||
<content_rating type="oars-1.1"/>
|
||||
<releases>
|
||||
<release version="0.1.0" date="2026-06-23"/>
|
||||
</releases>
|
||||
</component>
|
||||
@@ -0,0 +1,48 @@
|
||||
id: com.pena.app
|
||||
runtime: org.gnome.Platform
|
||||
runtime-version: '48'
|
||||
sdk: org.gnome.Sdk
|
||||
command: pena
|
||||
|
||||
finish-args:
|
||||
# Affichage
|
||||
- --socket=wayland
|
||||
- --socket=fallback-x11
|
||||
- --device=dri
|
||||
- --share=ipc
|
||||
# Lecture des fichiers Markdown ouverts par l'utilisateur
|
||||
- --filesystem=home
|
||||
|
||||
modules:
|
||||
- name: pena
|
||||
buildsystem: simple
|
||||
sources:
|
||||
- type: file
|
||||
path: pena.deb
|
||||
- type: file
|
||||
path: com.pena.app.metainfo.xml
|
||||
build-commands:
|
||||
- |
|
||||
set -eux
|
||||
# Extraction du .deb produit par `cargo tauri build`
|
||||
ar -x pena.deb
|
||||
tar -xf data.tar.gz
|
||||
|
||||
# Binaire principal -> /app/bin/pena (correspond à `command:`)
|
||||
bin="$(find usr/bin -maxdepth 1 -type f | head -n1)"
|
||||
install -Dm755 "$bin" /app/bin/pena
|
||||
|
||||
# Fichier .desktop : renommé en <app-id>.desktop (exigence Flatpak)
|
||||
desktop="$(find usr/share/applications -name '*.desktop' | head -n1)"
|
||||
install -Dm644 "$desktop" /app/share/applications/com.pena.app.desktop
|
||||
sed -i \
|
||||
-e 's/^Exec=.*/Exec=pena/' \
|
||||
-e 's/^Icon=.*/Icon=com.pena.app/' \
|
||||
/app/share/applications/com.pena.app.desktop
|
||||
|
||||
# Icône : on prend la plus grande disponible
|
||||
icon="$(find usr/share/icons -name '*.png' | sort | tail -n1)"
|
||||
install -Dm644 "$icon" /app/share/icons/hicolor/128x128/apps/com.pena.app.png
|
||||
|
||||
# Métadonnées AppStream
|
||||
install -Dm644 com.pena.app.metainfo.xml /app/share/metainfo/com.pena.app.metainfo.xml
|
||||
@@ -17,5 +17,5 @@ tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
comrak = { version = "0.28", features = ["syntect"] }
|
||||
syntect = "5"
|
||||
syntect = { version = "5", features = ["yaml-load"] }
|
||||
notify = "7"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
.content { background: #1e1e2e; color: #cdd6f4; }
|
||||
.content h1, .content h2, .content h3, .content h4, .content h5, .content h6 { color: #cdd6f4; }
|
||||
.content p, .content ul li, .content ol li { color: #cdd6f4; }
|
||||
.content a { color: #89b4fa; }
|
||||
.content a:visited { color: #cba6f7; }
|
||||
.content code { background: #313244; color: #cba6f7; }
|
||||
.content pre { background: #313244; }
|
||||
.content blockquote { border-color: #6c7086; color: #a6adc8; }
|
||||
.content table td, .content table th { color: #cdd6f4; }
|
||||
@@ -0,0 +1,255 @@
|
||||
/* ─────────────────────────────────────────────
|
||||
Thème par défaut — basé sur Shell Indigo
|
||||
Modifiez les valeurs pour personnaliser
|
||||
l'apparence de Pena.
|
||||
───────────────────────────────────────────── */
|
||||
|
||||
|
||||
/* ── Page ──────────────────────────────────── */
|
||||
|
||||
/* Couleur de fond de la fenêtre (visible autour du contenu) */
|
||||
body {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
/* Couleur de surbrillance lors d'une sélection de texte */
|
||||
::selection {
|
||||
background: #c7d2fe;
|
||||
}
|
||||
|
||||
|
||||
/* ── Zone de contenu ────────────────────────── */
|
||||
|
||||
/* Fond et couleur de texte principale de la zone de lecture */
|
||||
.content {
|
||||
background: #ffffff;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
|
||||
/* ── Liens ──────────────────────────────────── */
|
||||
|
||||
/* Lien normal (non visité) */
|
||||
.content a {
|
||||
color: #4f46e5;
|
||||
border-bottom: 1px solid #c7d2fe;
|
||||
}
|
||||
|
||||
/* Lien au survol de la souris */
|
||||
.content a:hover {
|
||||
color: #3730a3;
|
||||
border-bottom-color: #4f46e5;
|
||||
}
|
||||
|
||||
/* Lien déjà visité */
|
||||
.content a:visited {
|
||||
color: #6d28d9;
|
||||
}
|
||||
|
||||
/* Lien déjà visité au survol */
|
||||
.content a:visited:hover {
|
||||
color: #3730a3;
|
||||
}
|
||||
|
||||
|
||||
/* ── Titres ─────────────────────────────────── */
|
||||
|
||||
/* Couleur commune à tous les niveaux de titre (h1 à h6) */
|
||||
.content h1,
|
||||
.content h2,
|
||||
.content h3,
|
||||
.content h4,
|
||||
.content h5,
|
||||
.content h6 {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
|
||||
/* ── Texte courant ──────────────────────────── */
|
||||
|
||||
/* Paragraphes */
|
||||
.content p {
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* Éléments de liste (à puces et numérotées) */
|
||||
.content ul li,
|
||||
.content ol li {
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* Texte en gras */
|
||||
.content strong {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
/* Ligne de séparation horizontale (---) */
|
||||
.content hr {
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
|
||||
/* ── Code ───────────────────────────────────── */
|
||||
|
||||
/* Code inline (entre backticks simples) */
|
||||
.content :not(pre) > code {
|
||||
background: #f1f5f9;
|
||||
color: #4f46e5;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
/* Bloc de code sans coloration syntaxique */
|
||||
.content pre:not(.syntax-highlighting) {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1e293b;
|
||||
}
|
||||
|
||||
/* Bloc de code avec coloration syntaxique */
|
||||
.content .syntax-highlighting {
|
||||
border: 1px solid #1e293b;
|
||||
}
|
||||
|
||||
/* Texte dans un bloc sans coloration */
|
||||
.content pre:not(.syntax-highlighting) code {
|
||||
background: none;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
|
||||
/* ── Citation ───────────────────────────────── */
|
||||
|
||||
/* Bloc de citation (> texte) */
|
||||
.content blockquote {
|
||||
border-color: #818cf8;
|
||||
background: #f5f3ff;
|
||||
color: #4338ca;
|
||||
}
|
||||
|
||||
|
||||
/* ── Tableaux ───────────────────────────────── */
|
||||
|
||||
/* Conteneur : bordure extérieure et coins arrondis */
|
||||
.content table {
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* Ligne d'en-tête : fond gris clair */
|
||||
.content thead {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Cellule d'en-tête : texte en majuscules espacées, séparateur vertical */
|
||||
.content table th {
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.77rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #334155;
|
||||
border-bottom: 2px solid #4f46e5;
|
||||
border-right: 1px solid #cbd5e1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Pas de bordure droite sur la dernière colonne d'en-tête */
|
||||
.content table th:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* Cellule de données : padding, séparateurs horizontaux et verticaux */
|
||||
.content table td {
|
||||
padding: 0.6rem 1rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
/* Pas de bordure droite sur la dernière colonne */
|
||||
.content table td:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* Pas de bordure basse sur la dernière ligne (évite le double bord avec le conteneur) */
|
||||
.content tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Lignes paires : fond légèrement différent pour faciliter la lecture */
|
||||
.content tbody tr:nth-child(even) {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
/* Ligne au survol : mise en évidence */
|
||||
.content tbody tr:hover {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
|
||||
/* ── Barre latérale ─────────────────────────── */
|
||||
|
||||
/* Fond de la barre latérale */
|
||||
.sidebar {
|
||||
background: #1a1b2e;
|
||||
}
|
||||
|
||||
/* Lien racine (premier niveau) au survol */
|
||||
.nav-root-link:hover {
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
/* Lien racine (premier niveau) actif (page ouverte) */
|
||||
.nav-root-link.active {
|
||||
color: #818cf8;
|
||||
background: rgba(129, 140, 248, 0.08);
|
||||
}
|
||||
|
||||
/* Lien de dossier au survol */
|
||||
.nav-folder-link:hover {
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
/* Lien de dossier actif */
|
||||
.nav-folder-link.active {
|
||||
color: #818cf8;
|
||||
background: rgba(129, 140, 248, 0.08);
|
||||
}
|
||||
|
||||
/* Lien de fichier (sous-niveaux) au survol */
|
||||
.nav-file-link:hover {
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
/* Lien de fichier actif (page ouverte) */
|
||||
.nav-file-link.active {
|
||||
color: #818cf8;
|
||||
background: rgba(129, 140, 248, 0.08);
|
||||
}
|
||||
|
||||
|
||||
/* ── Barre de titre ─────────────────────────── */
|
||||
|
||||
/* Fond et bordure inférieure de la barre de titre */
|
||||
#titlebar {
|
||||
background: #1a1b2e;
|
||||
/* border-bottom: 1px solid #1e293b; */
|
||||
}
|
||||
|
||||
/* Texte du titre de la fenêtre */
|
||||
#titlebar-title {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* Boutons de contrôle (réduire, agrandir, fermer) au repos */
|
||||
.titlebar-controls button {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
/* Boutons de contrôle au survol */
|
||||
.titlebar-controls button:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.content a:hover { color: #10b981; }
|
||||
.content table th { border-bottom-color: #10b981; }
|
||||
.content table td { border-bottom-color: rgba(16, 185, 129, 0.25); }
|
||||
@@ -0,0 +1,2 @@
|
||||
.content { font-size: 18px; }
|
||||
.content p { line-height: 1.8; }
|
||||
@@ -0,0 +1,6 @@
|
||||
.content { background: #f4ecd8; }
|
||||
.content h1, .content h2, .content h3, .content h4, .content h5, .content h6 { color: #5c4a1e; }
|
||||
.content p, .content ul li, .content ol li { color: #5c4a1e; }
|
||||
.content a { color: #7a5c2e; }
|
||||
.content code { background: #e8d5b0; color: #5c4a1e; }
|
||||
.content pre { background: #e8d5b0; }
|
||||
@@ -0,0 +1,30 @@
|
||||
body { background: #f1f5f9; }
|
||||
::selection { background: #c7d2fe; }
|
||||
.content { background: #ffffff; color: #1e293b; }
|
||||
.content a { color: #4f46e5; border-bottom: 1px solid #c7d2fe; }
|
||||
.content a:hover { color: #3730a3; border-bottom-color: #4f46e5; }
|
||||
.content a:visited { color: #6d28d9; }
|
||||
.content a:visited:hover { color: #3730a3; }
|
||||
.content h1, .content h2, .content h3, .content h4, .content h5, .content h6 { color: #0f172a; }
|
||||
.content p { color: #1e293b; }
|
||||
.content ul li, .content ol li { color: #1e293b; }
|
||||
.content :not(pre) > code { background: #f1f5f9; color: #4f46e5; border: 1px solid #e2e8f0; }
|
||||
.content pre:not(.syntax-highlighting) { background: #0f172a; border: 1px solid #1e293b; border-radius: 10px; }
|
||||
.content .syntax-highlighting { border: 1px solid #1e293b; border-radius: 10px; }
|
||||
.content pre:not(.syntax-highlighting) code { background: none; color: #e2e8f0; }
|
||||
.content blockquote { border-color: #818cf8; background: #f5f3ff; color: #4338ca; }
|
||||
.content table { color: #374151; }
|
||||
.content table th { border-bottom-color: #4f46e5; color: #334155; }
|
||||
.content table td { border-bottom-color: rgba(79, 70, 229, 0.25); }
|
||||
.content strong { color: #0f172a; }
|
||||
.content hr { border-color: #e2e8f0; }
|
||||
.nav-root-link:hover { color: #818cf8; }
|
||||
.nav-root-link.active { color: #818cf8; background: rgba(129, 140, 248, 0.08); }
|
||||
.nav-folder-link:hover { color: #818cf8; }
|
||||
.nav-folder-link.active { color: #818cf8; background: rgba(129, 140, 248, 0.08); }
|
||||
.nav-file-link:hover { color: #818cf8; }
|
||||
.nav-file-link.active { color: #818cf8; background: rgba(129, 140, 248, 0.08); }
|
||||
#titlebar { background: #0f172a; border-bottom: 1px solid #1e293b; }
|
||||
#titlebar-title { color: rgba(255,255,255,0.5); }
|
||||
.titlebar-controls button { color: rgba(255,255,255,0.45); }
|
||||
.titlebar-controls button:hover { background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.9); }
|
||||
@@ -0,0 +1,65 @@
|
||||
use crate::infrastructure::file_repository;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn list_markdown_files(dir: &str) -> 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();
|
||||
file_repository::collect_md_files(path, &mut files).map_err(|e| e.to_string())?;
|
||||
files.sort();
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn tmpdir(name: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_markdown_files_sorted() {
|
||||
let dir = tmpdir("pena_fs_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_markdown_files(dir.to_str().unwrap()).unwrap();
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
assert_eq!(result.len(), 3);
|
||||
assert!(result[0] < result[1] && result[1] < result[2]);
|
||||
assert!(result[0].ends_with("a.md"));
|
||||
assert!(result[2].ends_with("z.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_markdown_files_ignores_non_md() {
|
||||
let dir = tmpdir("pena_fs_non_md");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(dir.join("doc.md"), "").unwrap();
|
||||
fs::write(dir.join("notes.txt"), "").unwrap();
|
||||
let result = list_markdown_files(dir.to_str().unwrap()).unwrap();
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert!(result[0].ends_with("doc.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_markdown_files_not_a_dir() {
|
||||
let result = list_markdown_files("/nonexistent/pena_fs_dir");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_markdown_files_empty_dir() {
|
||||
let dir = tmpdir("pena_fs_empty");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let result = list_markdown_files(dir.to_str().unwrap()).unwrap();
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
assert_eq!(result.len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod file_service;
|
||||
pub mod render_service;
|
||||
pub mod theme_service;
|
||||
@@ -0,0 +1,58 @@
|
||||
use crate::domain::MarkdownRenderer;
|
||||
use crate::infrastructure::file_repository;
|
||||
|
||||
pub fn render_string(renderer: &dyn MarkdownRenderer, content: &str) -> String {
|
||||
renderer.render(content)
|
||||
}
|
||||
|
||||
pub fn render_file(renderer: &dyn MarkdownRenderer, path: &str) -> Result<String, String> {
|
||||
let content = file_repository::read_file(path)?;
|
||||
Ok(renderer.render(&content))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
struct MockRenderer;
|
||||
|
||||
impl MarkdownRenderer for MockRenderer {
|
||||
fn render(&self, content: &str) -> String {
|
||||
format!("<mock>{content}</mock>")
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_string_delegates_to_renderer() {
|
||||
let renderer = MockRenderer;
|
||||
let result = render_string(&renderer, "hello");
|
||||
assert_eq!(result, "<mock>hello</mock>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_string_empty_content() {
|
||||
let renderer = MockRenderer;
|
||||
let result = render_string(&renderer, "");
|
||||
assert_eq!(result, "<mock></mock>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_file_success() {
|
||||
let dir = std::env::temp_dir().join("pena_rs_render_file");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("test.md");
|
||||
fs::write(&file, "world").unwrap();
|
||||
let renderer = MockRenderer;
|
||||
let result = render_file(&renderer, file.to_str().unwrap());
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
assert_eq!(result.unwrap(), "<mock>world</mock>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_file_not_found() {
|
||||
let renderer = MockRenderer;
|
||||
let result = render_file(&renderer, "/nonexistent/pena_rs_file.md");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use crate::domain::theme::{Theme, ThemeRepository};
|
||||
|
||||
pub fn list_themes(repo: &dyn ThemeRepository) -> Vec<Theme> {
|
||||
repo.list()
|
||||
}
|
||||
|
||||
pub fn get_theme_css(repo: &dyn ThemeRepository, id: &str) -> Option<String> {
|
||||
repo.get_css(id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::theme::{Theme, ThemeRepository};
|
||||
|
||||
struct MockRepo {
|
||||
themes: Vec<(&'static str, &'static str, &'static str)>,
|
||||
}
|
||||
|
||||
impl ThemeRepository for MockRepo {
|
||||
fn list(&self) -> Vec<Theme> {
|
||||
self.themes.iter().map(|(id, label, _)| Theme { id: id.to_string(), label: label.to_string() }).collect()
|
||||
}
|
||||
fn get_css(&self, id: &str) -> Option<String> {
|
||||
self.themes.iter().find(|(i, _, _)| *i == id).map(|(_, _, css)| css.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn mock_repo() -> MockRepo {
|
||||
MockRepo {
|
||||
themes: vec![
|
||||
("dark", "Mode sombre", ".content{background:#000}"),
|
||||
("light", "Clair", ".content{background:#fff}"),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_themes_delegates_to_repo() {
|
||||
let repo = mock_repo();
|
||||
let themes = list_themes(&repo);
|
||||
assert_eq!(themes.len(), 2);
|
||||
assert_eq!(themes[0].id, "dark");
|
||||
assert_eq!(themes[1].label, "Clair");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_theme_css_returns_css_for_known_id() {
|
||||
let repo = mock_repo();
|
||||
let css = get_theme_css(&repo, "dark");
|
||||
assert_eq!(css, Some(".content{background:#000}".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_theme_css_returns_none_for_unknown_id() {
|
||||
let repo = mock_repo();
|
||||
assert!(get_theme_css(&repo, "unknown").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod render;
|
||||
pub mod theme;
|
||||
pub mod watch;
|
||||
@@ -0,0 +1,116 @@
|
||||
use crate::application::{file_service, render_service};
|
||||
use crate::infrastructure::comrak_renderer::{
|
||||
syntax_css_for_theme, ComrakPreviewRenderer, ComrakRenderer,
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn render_markdown(content: String) -> String {
|
||||
let renderer = ComrakPreviewRenderer;
|
||||
render_service::render_string(&renderer, &content)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn convert_file(path: String) -> Result<String, String> {
|
||||
let renderer = ComrakRenderer;
|
||||
render_service::render_file(&renderer, &path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_syntax_highlight_css() -> String {
|
||||
syntax_css_for_theme("base16-ocean.dark")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_md_files(dir: String) -> Result<Vec<String>, String> {
|
||||
file_service::list_markdown_files(&dir)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn tmpdir(name: &str) -> std::path::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("syntax-highlighting"));
|
||||
}
|
||||
|
||||
#[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 get_syntax_highlight_css_returns_non_empty() {
|
||||
let css = get_syntax_highlight_css();
|
||||
assert!(!css.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_syntax_highlight_css_contains_wrapper_class() {
|
||||
let css = get_syntax_highlight_css();
|
||||
assert!(css.contains(".syntax-highlighting {"));
|
||||
}
|
||||
|
||||
#[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,54 @@
|
||||
use crate::application::theme_service;
|
||||
use crate::infrastructure::theme_repository::StaticThemeRepository;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ThemeDto {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_themes() -> Vec<ThemeDto> {
|
||||
let repo = StaticThemeRepository;
|
||||
theme_service::list_themes(&repo)
|
||||
.into_iter()
|
||||
.map(|t| ThemeDto { id: t.id, label: t.label })
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_theme_css(id: String) -> Option<String> {
|
||||
let repo = StaticThemeRepository;
|
||||
theme_service::get_theme_css(&repo, &id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn list_themes_returns_non_empty() {
|
||||
let themes = list_themes();
|
||||
assert!(!themes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_themes_ids_are_non_empty() {
|
||||
for theme in list_themes() {
|
||||
assert!(!theme.id.is_empty());
|
||||
assert!(!theme.label.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_theme_css_known() {
|
||||
assert!(get_theme_css("dark".into()).is_some());
|
||||
assert!(get_theme_css("shell-indigo".into()).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_theme_css_unknown_returns_none() {
|
||||
assert!(get_theme_css("does-not-exist".into()).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use crate::infrastructure::notify_watcher::run_debounce_loop;
|
||||
|
||||
pub struct WatcherState(pub Mutex<Option<RecommendedWatcher>>);
|
||||
|
||||
#[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())?;
|
||||
|
||||
std::thread::spawn(move || {
|
||||
run_debounce_loop(rx, app);
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
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,27 @@
|
||||
#[allow(dead_code)]
|
||||
pub trait MarkdownRenderer: Send + Sync {
|
||||
fn render(&self, content: &str) -> String;
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
#[allow(dead_code)]
|
||||
pub struct RenderOptions {
|
||||
pub tables: bool,
|
||||
pub strikethrough: bool,
|
||||
pub autolink: bool,
|
||||
pub tasklist: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn render_options_default_all_false() {
|
||||
let opts = RenderOptions::default();
|
||||
assert!(!opts.tables);
|
||||
assert!(!opts.strikethrough);
|
||||
assert!(!opts.autolink);
|
||||
assert!(!opts.tasklist);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mod markdown;
|
||||
pub mod theme;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use markdown::{MarkdownRenderer, RenderOptions};
|
||||
@@ -0,0 +1,39 @@
|
||||
pub struct Theme {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
pub trait ThemeRepository: Send + Sync {
|
||||
fn list(&self) -> Vec<Theme>;
|
||||
fn get_css(&self, id: &str) -> Option<String>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct StubRepo;
|
||||
impl ThemeRepository for StubRepo {
|
||||
fn list(&self) -> Vec<Theme> {
|
||||
vec![Theme { id: "a".into(), label: "A".into() }]
|
||||
}
|
||||
fn get_css(&self, id: &str) -> Option<String> {
|
||||
if id == "a" { Some(".x{}".into()) } else { None }
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_fields_accessible() {
|
||||
let t = Theme { id: "dark".into(), label: "Mode sombre".into() };
|
||||
assert_eq!(t.id, "dark");
|
||||
assert_eq!(t.label, "Mode sombre");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_trait_object_works() {
|
||||
let repo: Box<dyn ThemeRepository> = Box::new(StubRepo);
|
||||
assert_eq!(repo.list().len(), 1);
|
||||
assert!(repo.get_css("a").is_some());
|
||||
assert!(repo.get_css("unknown").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
use comrak::plugins::syntect::SyntectAdapterBuilder;
|
||||
use comrak::{markdown_to_html_with_plugins, Options, Plugins};
|
||||
use syntect::highlighting::ThemeSet;
|
||||
use syntect::html::{css_for_theme_with_class_style, ClassStyle};
|
||||
use syntect::parsing::SyntaxDefinition;
|
||||
|
||||
use crate::domain::MarkdownRenderer;
|
||||
|
||||
const TYPESCRIPT_SYNTAX: &str =
|
||||
include_str!("../../resources/syntaxes/TypeScript.sublime-syntax");
|
||||
|
||||
fn build_syntax_set() -> syntect::parsing::SyntaxSet {
|
||||
let mut builder = syntect::parsing::SyntaxSet::load_defaults_nonewlines().into_builder();
|
||||
if let Ok(def) = SyntaxDefinition::load_from_str(TYPESCRIPT_SYNTAX, true, None) {
|
||||
builder.add(def);
|
||||
}
|
||||
builder.build()
|
||||
}
|
||||
|
||||
pub struct ComrakRenderer;
|
||||
pub struct ComrakPreviewRenderer;
|
||||
|
||||
impl MarkdownRenderer for ComrakRenderer {
|
||||
fn render(&self, content: &str) -> String {
|
||||
let adapter = SyntectAdapterBuilder::new()
|
||||
.syntax_set(build_syntax_set())
|
||||
.css()
|
||||
.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);
|
||||
markdown_to_html_with_plugins(content, &options, &plugins)
|
||||
}
|
||||
}
|
||||
|
||||
impl MarkdownRenderer for ComrakPreviewRenderer {
|
||||
fn render(&self, content: &str) -> String {
|
||||
let adapter = SyntectAdapterBuilder::new()
|
||||
.syntax_set(build_syntax_set())
|
||||
.css()
|
||||
.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 syntax_css_for_theme(syntect_theme_name: &str) -> String {
|
||||
let ts = ThemeSet::load_defaults();
|
||||
let theme = match ts.themes.get(syntect_theme_name) {
|
||||
Some(t) => t,
|
||||
None => return String::new(),
|
||||
};
|
||||
match css_for_theme_with_class_style(theme, ClassStyle::Spaced) {
|
||||
Ok(css) => css.replace(".code {", ".syntax-highlighting {"),
|
||||
Err(_) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn render_outputs_syntax_highlighting_class() {
|
||||
let r = ComrakRenderer;
|
||||
let html = r.render("```rust\nfn main() {}\n```\n");
|
||||
assert!(html.contains("syntax-highlighting"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_outputs_classed_spans() {
|
||||
let r = ComrakRenderer;
|
||||
let html = r.render("```rust\nfn main() {}\n```\n");
|
||||
assert!(html.contains("<span class="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typescript_syntax_is_highlighted() {
|
||||
let r = ComrakRenderer;
|
||||
let html = r.render("```typescript\ninterface Foo { id: string; }\n```\n");
|
||||
assert!(html.contains("<span class="), "TypeScript should produce highlighted spans");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syntax_css_known_theme_contains_wrapper() {
|
||||
let css = syntax_css_for_theme("base16-ocean.dark");
|
||||
assert!(!css.is_empty());
|
||||
assert!(css.contains(".syntax-highlighting {"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syntax_css_known_theme_contains_token_rules() {
|
||||
let css = syntax_css_for_theme("base16-ocean.dark");
|
||||
assert!(css.contains("color:") || css.contains("color :"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syntax_css_unknown_theme_returns_empty() {
|
||||
let css = syntax_css_for_theme("nonexistent-theme-xyz");
|
||||
assert!(css.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
pub 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 read_file(path: &str) -> Result<String, String> {
|
||||
fs::read_to_string(path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn tmpdir(name: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(name)
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod comrak_renderer;
|
||||
pub mod file_repository;
|
||||
pub mod notify_watcher;
|
||||
pub mod theme_repository;
|
||||
@@ -0,0 +1,107 @@
|
||||
use notify::EventKind;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc::RecvTimeoutError;
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
struct FileChangedPayload {
|
||||
path: String,
|
||||
}
|
||||
|
||||
pub fn run_debounce_loop(
|
||||
rx: std::sync::mpsc::Receiver<notify::Result<notify::Event>>,
|
||||
app: AppHandle,
|
||||
) {
|
||||
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(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
#[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")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
use crate::domain::theme::{Theme, ThemeRepository};
|
||||
|
||||
const DEFAULT_CSS: &str = include_str!("../../resources/themes/default.css");
|
||||
const DARK_CSS: &str = include_str!("../../resources/themes/dark.css");
|
||||
const SEPIA_CSS: &str = include_str!("../../resources/themes/sepia.css");
|
||||
const LARGE_TEXT_CSS: &str = include_str!("../../resources/themes/large-text.css");
|
||||
const EMERALD_CSS: &str = include_str!("../../resources/themes/emerald.css");
|
||||
const SHELL_INDIGO_CSS: &str = include_str!("../../resources/themes/shell-indigo.css");
|
||||
|
||||
pub struct StaticThemeRepository;
|
||||
|
||||
impl ThemeRepository for StaticThemeRepository {
|
||||
fn list(&self) -> Vec<Theme> {
|
||||
vec![
|
||||
Theme { id: "default".into(), label: "Défaut".into() },
|
||||
Theme { id: "dark".into(), label: "Mode sombre".into() },
|
||||
Theme { id: "sepia".into(), label: "Sépia".into() },
|
||||
Theme { id: "large-text".into(), label: "Grand texte".into() },
|
||||
Theme { id: "emerald".into(), label: "Accent émeraude".into() },
|
||||
Theme { id: "shell-indigo".into(), label: "Shell Indigo".into() },
|
||||
]
|
||||
}
|
||||
|
||||
fn get_css(&self, id: &str) -> Option<String> {
|
||||
match id {
|
||||
"default" => Some(DEFAULT_CSS.to_string()),
|
||||
"dark" => Some(DARK_CSS.to_string()),
|
||||
"sepia" => Some(SEPIA_CSS.to_string()),
|
||||
"large-text" => Some(LARGE_TEXT_CSS.to_string()),
|
||||
"emerald" => Some(EMERALD_CSS.to_string()),
|
||||
"shell-indigo" => Some(SHELL_INDIGO_CSS.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::theme::ThemeRepository;
|
||||
|
||||
#[test]
|
||||
fn list_returns_all_themes() {
|
||||
let repo = StaticThemeRepository;
|
||||
let themes = repo.list();
|
||||
assert_eq!(themes.len(), 6);
|
||||
let ids: Vec<&str> = themes.iter().map(|t| t.id.as_str()).collect();
|
||||
assert!(ids.contains(&"default"));
|
||||
assert!(ids.contains(&"dark"));
|
||||
assert!(ids.contains(&"shell-indigo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_css_known_themes() {
|
||||
let repo = StaticThemeRepository;
|
||||
for id in ["default", "dark", "sepia", "large-text", "emerald", "shell-indigo"] {
|
||||
let css = repo.get_css(id);
|
||||
assert!(css.is_some(), "CSS manquant pour le thème {id}");
|
||||
assert!(!css.unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_css_unknown_returns_none() {
|
||||
let repo = StaticThemeRepository;
|
||||
assert!(repo.get_css("nonexistent").is_none());
|
||||
}
|
||||
}
|
||||
+13
-23
@@ -1,35 +1,25 @@
|
||||
mod core;
|
||||
mod watcher;
|
||||
mod application;
|
||||
mod commands;
|
||||
mod domain;
|
||||
mod infrastructure;
|
||||
|
||||
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)))
|
||||
.manage(commands::watch::WatcherState(Mutex::new(None)))
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
render_markdown,
|
||||
convert_file,
|
||||
list_md_files,
|
||||
watcher::start_watch,
|
||||
watcher::stop_watch,
|
||||
commands::render::render_markdown,
|
||||
commands::render::convert_file,
|
||||
commands::render::list_md_files,
|
||||
commands::render::get_syntax_highlight_css,
|
||||
commands::watch::start_watch,
|
||||
commands::watch::stop_watch,
|
||||
commands::theme::list_themes,
|
||||
commands::theme::get_theme_css,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("Erreur lors du démarrage de l'application Tauri");
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
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,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15.5 8.5H15.51M10.5 7.5H10.51M7.5 11.5H7.51M12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12C21 13.6569 19.6569 15 18 15H17.4C17.0284 15 16.8426 15 16.6871 15.0246C15.8313 15.1602 15.1602 15.8313 15.0246 16.6871C15 16.8426 15 17.0284 15 17.4V18C15 19.6569 13.6569 21 12 21ZM16 8.5C16 8.77614 15.7761 9 15.5 9C15.2239 9 15 8.77614 15 8.5C15 8.22386 15.2239 8 15.5 8C15.7761 8 16 8.22386 16 8.5ZM11 7.5C11 7.77614 10.7761 8 10.5 8C10.2239 8 10 7.77614 10 7.5C10 7.22386 10.2239 7 10.5 7C10.7761 7 11 7.22386 11 7.5ZM8 11.5C8 11.7761 7.77614 12 7.5 12C7.22386 12 7 11.7761 7 11.5C7 11.2239 7.22386 11 7.5 11C7.77614 11 8 11.2239 8 11.5Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 993 B |
+25
-8
@@ -6,8 +6,7 @@
|
||||
<title>Pena — Markdown Viewer</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&family=Roboto+Mono&display=swap">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -20,6 +19,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="app-content">
|
||||
|
||||
<div id="view-home">
|
||||
<h1 class="home-title">Pena</h1>
|
||||
<div class="home-buttons">
|
||||
@@ -33,24 +34,36 @@
|
||||
</div>
|
||||
|
||||
<div id="view-reader" class="hidden">
|
||||
<aside id="sidebar" class="sidebar">
|
||||
<div class="sidebar">
|
||||
<aside id="sidebar" class="sidebar__scroll">
|
||||
<!-- contenu généré par JS -->
|
||||
</aside>
|
||||
<div id="content" class="content">
|
||||
<!-- contenu généré par JS -->
|
||||
</div>
|
||||
<div class="sidebar__footer">
|
||||
<button id="btn-customize-css" class="btn-customize-css">
|
||||
<span class="btn-customize-css__icon">⊕</span>
|
||||
<svg class="btn-customize-css__icon" width="15" height="15" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15.5 8.5H15.51M10.5 7.5H10.51M7.5 11.5H7.51M12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12C21 13.6569 19.6569 15 18 15H17.4C17.0284 15 16.8426 15 16.6871 15.0246C15.8313 15.1602 15.1602 15.8313 15.0246 16.6871C15 16.8426 15 17.0284 15 17.4V18C15 19.6569 13.6569 21 12 21ZM16 8.5C16 8.77614 15.7761 9 15.5 9C15.2239 9 15 8.77614 15 8.5C15 8.22386 15.2239 8 15.5 8C15.7761 8 16 8.22386 16 8.5ZM11 7.5C11 7.77614 10.7761 8 10.5 8C10.2239 8 10 7.77614 10 7.5C10 7.22386 10.2239 7 10.5 7C10.7761 7 11 7.22386 11 7.5ZM8 11.5C8 11.7761 7.77614 12 7.5 12C7.22386 12 7 11.7761 7 11.5C7 11.2239 7.22386 11 7.5 11C7.77614 11 8 11.2239 8 11.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Personnaliser le CSS
|
||||
</button>
|
||||
<button id="btn-back" class="btn-back">← Accueil</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="content" class="content">
|
||||
<!-- contenu généré par JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- #app-content -->
|
||||
|
||||
<!-- Modale CSS -->
|
||||
<div id="css-modal-overlay" class="css-modal-overlay hidden">
|
||||
<div class="css-modal">
|
||||
<div class="css-modal__header">
|
||||
<span class="css-modal__title">CSS personnalisé</span>
|
||||
<span class="css-modal__title">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="vertical-align: middle; margin-right: 6px; opacity: 0.8;">
|
||||
<path d="M15.5 8.5H15.51M10.5 7.5H10.51M7.5 11.5H7.51M12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12C21 13.6569 19.6569 15 18 15H17.4C17.0284 15 16.8426 15 16.6871 15.0246C15.8313 15.1602 15.1602 15.8313 15.0246 16.6871C15 16.8426 15 17.0284 15 17.4V18C15 19.6569 13.6569 21 12 21ZM16 8.5C16 8.77614 15.7761 9 15.5 9C15.2239 9 15 8.77614 15 8.5C15 8.22386 15.2239 8 15.5 8C15.7761 8 16 8.22386 16 8.5ZM11 7.5C11 7.77614 10.7761 8 10.5 8C10.2239 8 10 7.77614 10 7.5C10 7.22386 10.2239 7 10.5 7C10.7761 7 11 7.22386 11 7.5ZM8 11.5C8 11.7761 7.77614 12 7.5 12C7.22386 12 7 11.7761 7 11.5C7 11.2239 7.22386 11 7.5 11C7.77614 11 8 11.2239 8 11.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>CSS personnalisé
|
||||
</span>
|
||||
<button id="btn-css-close" class="css-modal__close">✕</button>
|
||||
</div>
|
||||
<div class="css-modal__tabs" id="css-tabs">
|
||||
@@ -60,6 +73,10 @@
|
||||
<button class="css-modal__tab" data-tab="avance">Développement</button>
|
||||
</div>
|
||||
<div class="css-modal__body">
|
||||
<div class="css-modal__quick-themes">
|
||||
<p class="css-modal__quick-themes-label">Thèmes rapides</p>
|
||||
<div id="quick-themes-list" class="css-modal__quick-themes-list"></div>
|
||||
</div>
|
||||
<p class="css-modal__description">Personnalisez l'apparence de vos pages en modifiant le CSS ci-dessous. Les styles s'appliquent à l'onglet actif.</p>
|
||||
<textarea id="css-editor" class="css-modal__editor" placeholder="/* Entrez votre CSS ici */ /* Exemple : .content { font-size: 18px; } */"></textarea>
|
||||
</div>
|
||||
|
||||
+5
-313
@@ -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 `<li>
|
||||
<button class="home-recents__item" data-path="${escaped}" data-mode="${r.mode}">
|
||||
<span class="home-recents__item__icon">${icon}</span>
|
||||
<span class="home-recents__item__info">
|
||||
<span class="home-recents__item__name">${escapeHtml(r.name)}</span>
|
||||
<span class="home-recents__item__path">${escapeHtml(r.parent)}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>`;
|
||||
}).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, '>')
|
||||
.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<li><a href="#" class="nav-root-link${cls}" data-path="${escapeHtml(abs)}">${escapeHtml(label)}</a></li>`;
|
||||
} else {
|
||||
html += `\n<li${depthClass}><a href="#" class="nav-file-link${cls}" data-path="${escapeHtml(abs)}">${escapeHtml(label)}</a></li>`;
|
||||
}
|
||||
});
|
||||
|
||||
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 = `<a href="#" class="nav-folder-link${cls}" data-path="${escapeHtml(abs)}" onclick="event.stopPropagation()">${escapeHtml(dirName)}</a>`;
|
||||
} else {
|
||||
folderLabel = `<span class="nav-folder-name">${escapeHtml(dirName)}</span>`;
|
||||
}
|
||||
|
||||
const arrow = `<span class="nav-arrow" onclick="event.preventDefault();event.stopPropagation();var d=this.closest('details');d.open=!d.open">▶</span>`;
|
||||
|
||||
const childNode = {
|
||||
_files: homeFile ? child._files.filter((f) => f !== homeFile) : child._files,
|
||||
_dirs: child._dirs,
|
||||
};
|
||||
|
||||
html += `\n<li${depthClass}><details class="nav-folder"${openAttr}>`;
|
||||
html += `\n <summary>${arrow}${folderLabel}</summary>`;
|
||||
html += `\n <ul class="nav-tree">`;
|
||||
html += renderTree(childNode, prefix, sep, currentRel, depth + 1);
|
||||
html += `\n </ul>\n</details></li>`;
|
||||
});
|
||||
|
||||
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 = `<div class="sidebar__title-block">
|
||||
<h1 class="sidebar__title-block__title">${escapeHtml(dirName)}</h1>
|
||||
</div>
|
||||
<ul class="sidebar__menu nav-tree">`;
|
||||
html += renderTree(tree, prefix, sep, currentRel, 0);
|
||||
html += `\n </ul>`;
|
||||
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 = `<p class="error">Impossible de charger le fichier : ${err}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 = `<p class="error">Impossible d'ouvrir le dossier : ${err}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
await initCssModal();
|
||||
showHome();
|
||||
|
||||
@@ -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 = `<p class="error">Impossible d'ouvrir le dossier : ${err}</p>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function listMdFiles(dir) {
|
||||
return window.__TAURI__.core.invoke('list_md_files', { dir });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
const { invoke } = window.__TAURI__.core;
|
||||
|
||||
export function listThemes() {
|
||||
return invoke('list_themes');
|
||||
}
|
||||
|
||||
export function getThemeCss(id) {
|
||||
return invoke('get_theme_css', { id });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const appState = { currentPath: null, currentMode: null };
|
||||
+185
-42
@@ -1,4 +1,5 @@
|
||||
/* ── Reset ── */
|
||||
:root { --sidebar-width: 264px; }
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
::selection { background: #ffc3c3; }
|
||||
html, body { height: 100%; margin: 0; }
|
||||
@@ -8,7 +9,16 @@ body {
|
||||
color: #222;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow-x: hidden;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app-content {
|
||||
position: fixed;
|
||||
top: 36px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── Titlebar ── */
|
||||
@@ -78,11 +88,17 @@ body {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
padding-top: 36px;
|
||||
height: 100%;
|
||||
background: #1a1b2e;
|
||||
}
|
||||
|
||||
.home-logo {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
object-fit: contain;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.home-title {
|
||||
color: #fff;
|
||||
font-size: 4rem;
|
||||
@@ -198,7 +214,7 @@ body {
|
||||
position: fixed;
|
||||
top: 36px;
|
||||
left: 0;
|
||||
width: 264px;
|
||||
width: var(--sidebar-width);
|
||||
height: calc(100vh - 36px);
|
||||
background: #1a1b2e;
|
||||
display: flex;
|
||||
@@ -206,6 +222,39 @@ body {
|
||||
z-index: 99;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Poignée de redimensionnement */
|
||||
.sidebar__resizer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: 100;
|
||||
background: transparent;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.sidebar__resizer:hover,
|
||||
.sidebar__resizer.is-dragging { background: rgba(129, 140, 248, 0.4); }
|
||||
body.is-resizing-sidebar { cursor: col-resize; user-select: none; }
|
||||
|
||||
.sidebar__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar__footer {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px 0 8px;
|
||||
border-top: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
|
||||
.sidebar__title-block {
|
||||
margin: 32px 24px 8px;
|
||||
flex-shrink: 0;
|
||||
@@ -233,7 +282,7 @@ body {
|
||||
.sidebar__menu {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 8px 0 48px 0;
|
||||
padding: 8px 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -290,16 +339,20 @@ body {
|
||||
|
||||
.nav-root-link {
|
||||
display: block;
|
||||
padding: 7px 24px;
|
||||
padding: 6px 24px;
|
||||
color: rgba(255,255,255,0.85);
|
||||
text-decoration: none;
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.nav-root-link:hover { color: #ff5577; }
|
||||
.nav-root-link.active { color: #ff5577; font-weight: 500; }
|
||||
.nav-root-link.active {
|
||||
color: #818cf8;
|
||||
background: rgba(129, 140, 248, 0.08);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-folder { margin: 0; }
|
||||
.nav-folder > summary {
|
||||
@@ -329,33 +382,32 @@ body {
|
||||
.nav-arrow:hover { opacity: 1; background: rgba(255,255,255,0.08); }
|
||||
.nav-folder[open] > summary .nav-arrow { transform: rotate(90deg); opacity: 0.8; }
|
||||
|
||||
/* Les libellés de dossiers partagent la même police que les fichiers :
|
||||
même taille (14px), même graisse (400), sans majuscules. Seule la flèche ▶
|
||||
distingue visuellement un dossier d'un fichier. */
|
||||
.nav-folder-link, .nav-folder-name {
|
||||
flex: 1;
|
||||
display: block;
|
||||
padding: 6px 24px 6px 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: rgba(255,255,255,0.35);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
color: rgba(255,255,255,0.85);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.nav-folder-link:hover { color: rgba(255,255,255,0.85); }
|
||||
.nav-folder-link:hover { color: #ff5577; }
|
||||
.nav-folder-link.active { color: #ff5577; }
|
||||
|
||||
/* Dossiers imbriqués : même style que les fichiers imbriqués (couleur atténuée). */
|
||||
.nav-folder .nav-folder > summary .nav-folder-link,
|
||||
.nav-folder .nav-folder > summary .nav-folder-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: rgba(255,255,255,0.45);
|
||||
color: rgba(255,255,255,0.55);
|
||||
}
|
||||
.nav-folder .nav-folder > summary .nav-folder-link:hover { color: rgba(255,255,255,0.9); }
|
||||
.nav-folder .nav-folder > summary .nav-folder-link:hover { color: #ff5577; }
|
||||
|
||||
.nav-file-link {
|
||||
display: block;
|
||||
@@ -367,24 +419,28 @@ body {
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.nav-file-link:hover { color: #ff5577; }
|
||||
.nav-file-link.active { color: #ff5577; font-weight: 500; }
|
||||
.nav-file-link.active {
|
||||
color: #818cf8;
|
||||
background: rgba(129, 140, 248, 0.08);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-depth-1 > .nav-file-link { padding: 5px 24px 5px 52px; }
|
||||
.nav-depth-1 > .nav-file-link { padding: 6px 24px 6px 52px; }
|
||||
.nav-depth-1 > .nav-folder > summary { padding-left: 24px; }
|
||||
.nav-depth-2 > .nav-file-link { padding: 4px 24px 4px 68px; }
|
||||
.nav-depth-2 > .nav-file-link { padding: 6px 24px 6px 68px; }
|
||||
.nav-depth-2 > .nav-folder > summary { padding-left: 40px; }
|
||||
.nav-depth-3 > .nav-file-link { padding: 4px 24px 4px 84px; }
|
||||
.nav-depth-3 > .nav-file-link { padding: 6px 24px 6px 84px; }
|
||||
.nav-depth-3 > .nav-folder > summary { padding-left: 56px; }
|
||||
|
||||
/* ── Content ── */
|
||||
.content {
|
||||
margin-left: 264px;
|
||||
padding-top: 84px;
|
||||
margin-left: var(--sidebar-width);
|
||||
padding-top: 48px;
|
||||
padding-right: 72px;
|
||||
padding-bottom: 96px;
|
||||
padding-left: 72px;
|
||||
max-width: 1080px;
|
||||
min-height: 100vh;
|
||||
min-height: 100%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@@ -418,8 +474,69 @@ body {
|
||||
border-radius: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.content pre { background: #f4f4f7; padding: 20px 24px; border-radius: 6px; overflow-x: auto; margin: 16px 0; }
|
||||
.content pre code { background: none; padding: 0; font-weight: 400; font-size: 13px; }
|
||||
.content pre { padding: 20px 24px; border-radius: 6px; overflow-x: auto; margin: 16px 0; }
|
||||
.content pre:not(.syntax-highlighting) { background: #f4f4f7; }
|
||||
.content pre code { background: none; padding: 0; font-weight: 400; font-size: 13px; border: none; border-radius: 0; color: inherit; }
|
||||
|
||||
/* ── Bouton « copier » des blocs de code ── */
|
||||
.content .code-block { position: relative; }
|
||||
.content .code-block .copy-code-btn {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
right: 12px;
|
||||
padding: 4px 10px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
color: #c0c5ce;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.content .code-block:hover .copy-code-btn,
|
||||
.content .code-block .copy-code-btn:focus-visible { opacity: 1; }
|
||||
.content .code-block .copy-code-btn:hover { background: rgba(255, 255, 255, 0.18); }
|
||||
.content .code-block .copy-code-btn.copied { color: #a3be8c; border-color: rgba(163, 190, 140, 0.5); }
|
||||
|
||||
/* ── Coloration syntaxique (base16-ocean.dark) ── */
|
||||
.syntax-highlighting {
|
||||
color: #c0c5ce;
|
||||
background-color: #2b303b;
|
||||
}
|
||||
.syntax-highlighting .variable.parameter.function { color: #c0c5ce; }
|
||||
.syntax-highlighting .comment, .syntax-highlighting .punctuation.definition.comment { color: #65737e; }
|
||||
.syntax-highlighting .punctuation.definition.string, .syntax-highlighting .punctuation.definition.variable, .syntax-highlighting .punctuation.definition.parameters, .syntax-highlighting .punctuation.definition.array { color: #c0c5ce; }
|
||||
.syntax-highlighting .keyword.operator { color: #c0c5ce; }
|
||||
.syntax-highlighting .keyword { color: #b48ead; }
|
||||
.syntax-highlighting .variable, .syntax-highlighting .variable.other.dollar.only.js { color: #bf616a; }
|
||||
.syntax-highlighting .entity.name.function, .syntax-highlighting .meta.require, .syntax-highlighting .support.function.any-method, .syntax-highlighting .variable.function { color: #8fa1b3; }
|
||||
.syntax-highlighting .support.class, .syntax-highlighting .entity.name.class, .syntax-highlighting .entity.name.type.class { color: #ebcb8b; }
|
||||
.syntax-highlighting .meta.class { color: #eff1f5; }
|
||||
.syntax-highlighting .keyword.other.special-method { color: #8fa1b3; }
|
||||
.syntax-highlighting .storage { color: #b48ead; }
|
||||
.syntax-highlighting .support.function { color: #96b5b4; }
|
||||
.syntax-highlighting .string, .syntax-highlighting .constant.other.symbol, .syntax-highlighting .entity.other.inherited-class { color: #a3be8c; }
|
||||
.syntax-highlighting .constant.numeric, .syntax-highlighting .constant { color: #d08770; }
|
||||
.syntax-highlighting .entity.name.tag { color: #bf616a; }
|
||||
.syntax-highlighting .entity.other.attribute-name { color: #d08770; }
|
||||
.syntax-highlighting .entity.other.attribute-name.id, .syntax-highlighting .punctuation.definition.entity { color: #8fa1b3; }
|
||||
.syntax-highlighting .meta.selector { color: #b48ead; }
|
||||
.syntax-highlighting .markup.heading .punctuation.definition.heading, .syntax-highlighting .entity.name.section { color: #8fa1b3; }
|
||||
.syntax-highlighting .keyword.other.unit { color: #d08770; }
|
||||
.syntax-highlighting .markup.bold, .syntax-highlighting .punctuation.definition.bold { color: #ebcb8b; font-weight: bold; }
|
||||
.syntax-highlighting .markup.italic, .syntax-highlighting .punctuation.definition.italic { color: #b48ead; font-style: italic; }
|
||||
.syntax-highlighting .markup.raw.inline { color: #a3be8c; }
|
||||
.syntax-highlighting .string.other.link { color: #bf616a; }
|
||||
.syntax-highlighting .meta.separator { color: #c0c5ce; background-color: #4f5b66; }
|
||||
.syntax-highlighting .markup.inserted, .syntax-highlighting .markup.inserted.git_gutter { color: #a3be8c; }
|
||||
.syntax-highlighting .markup.deleted, .syntax-highlighting .markup.deleted.git_gutter { color: #bf616a; }
|
||||
.syntax-highlighting .markup.changed, .syntax-highlighting .markup.changed.git_gutter { color: #b48ead; }
|
||||
.syntax-highlighting .constant.other.color, .syntax-highlighting .string.regexp, .syntax-highlighting .constant.character.escape { color: #96b5b4; }
|
||||
.syntax-highlighting .punctuation.section.embedded, .syntax-highlighting .variable.interpolation { color: #ab7967; }
|
||||
.syntax-highlighting .invalid.illegal { color: #2b303b; background-color: #bf616a; }
|
||||
|
||||
.content blockquote {
|
||||
border-left: 3px solid rgba(34,34,34,0.25);
|
||||
@@ -459,11 +576,8 @@ body {
|
||||
|
||||
/* ── Customize CSS button ── */
|
||||
.btn-customize-css {
|
||||
position: fixed;
|
||||
bottom: 60px;
|
||||
left: 12px;
|
||||
width: 240px;
|
||||
z-index: 100;
|
||||
width: calc(100% - 24px);
|
||||
margin: 4px 12px 0;
|
||||
background: transparent;
|
||||
color: rgba(255,255,255,0.7);
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
@@ -484,7 +598,7 @@ body {
|
||||
border-color: rgba(255,255,255,0.35);
|
||||
background: rgba(255,255,255,0.06);
|
||||
}
|
||||
.btn-customize-css__icon { font-size: 15px; opacity: 0.8; }
|
||||
.btn-customize-css__icon { flex-shrink: 0; opacity: 0.8; }
|
||||
|
||||
/* ── CSS modal ── */
|
||||
.css-modal-overlay {
|
||||
@@ -624,13 +738,43 @@ body {
|
||||
.css-modal__btn--ghost { background: transparent; color: #777; border: 1px solid #ddd; }
|
||||
.css-modal__btn--ghost:hover { background: #f0f0f4; color: #444; }
|
||||
|
||||
/* ── Quick themes ── */
|
||||
.css-modal__quick-themes { flex-shrink: 0; }
|
||||
.css-modal__quick-themes-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #aaa;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.css-modal__quick-themes-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.css-modal__theme-badge {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
font-family: 'Poppins', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: #555;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
.css-modal__theme-badge:hover { border-color: #bbb; color: #222; background: #f8f8f8; }
|
||||
.css-modal__theme-badge.active {
|
||||
border-color: #ff5577;
|
||||
color: #ff5577;
|
||||
background: rgba(255, 85, 119, 0.06);
|
||||
}
|
||||
|
||||
/* ── Back button ── */
|
||||
.btn-back {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 0;
|
||||
width: 264px;
|
||||
z-index: 98;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
color: rgba(255,255,255,0.45);
|
||||
border: none;
|
||||
@@ -641,7 +785,6 @@ body {
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: color 0.2s;
|
||||
z-index: 100;
|
||||
}
|
||||
.btn-back:hover { color: #ff5577; }
|
||||
|
||||
@@ -651,7 +794,7 @@ body {
|
||||
left: -300px;
|
||||
transition: left 0.2s cubic-bezier(0.09, 0.46, 0.45, 0.94);
|
||||
}
|
||||
.content { margin-left: 0; padding: 68px 24px 64px; }
|
||||
.content { margin-left: 0; padding: 32px 24px 64px; }
|
||||
}
|
||||
@media screen and (max-width: 540px) {
|
||||
.content { padding: 68px 16px 48px; }
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { listThemes, getThemeCss } from '../services/themes.js';
|
||||
|
||||
const CSS_TABS = ['general', 'police', 'contenu', 'avance'];
|
||||
let activeTab = CSS_TABS[0];
|
||||
|
||||
const QUICK_THEME_KEY = 'pena_quick_theme';
|
||||
const themeCssCache = new Map();
|
||||
|
||||
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);
|
||||
}
|
||||
const tabCss = CSS_TABS.map(t => loadTabCss(t)).filter(Boolean).join('\n');
|
||||
const activeTheme = localStorage.getItem(QUICK_THEME_KEY);
|
||||
const themeCss = activeTheme ? (themeCssCache.get(activeTheme) ?? '') : '';
|
||||
styleEl.textContent = [tabCss, themeCss].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
function syncThemeBadges() {
|
||||
const activeTheme = localStorage.getItem(QUICK_THEME_KEY);
|
||||
document.querySelectorAll('.css-modal__theme-badge').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.theme === activeTheme);
|
||||
});
|
||||
}
|
||||
|
||||
function buildThemeBadges(themes) {
|
||||
const list = document.getElementById('quick-themes-list');
|
||||
list.innerHTML = '';
|
||||
for (const { id, label } of themes) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'css-modal__theme-badge';
|
||||
btn.dataset.theme = id;
|
||||
btn.textContent = label;
|
||||
btn.addEventListener('click', async () => {
|
||||
const current = localStorage.getItem(QUICK_THEME_KEY);
|
||||
if (current === id) {
|
||||
localStorage.removeItem(QUICK_THEME_KEY);
|
||||
} else {
|
||||
if (!themeCssCache.has(id)) {
|
||||
const css = await getThemeCss(id);
|
||||
if (css) themeCssCache.set(id, css);
|
||||
}
|
||||
localStorage.setItem(QUICK_THEME_KEY, id);
|
||||
}
|
||||
applyCustomCss();
|
||||
syncThemeBadges();
|
||||
});
|
||||
list.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
async function preloadActiveThemeCss() {
|
||||
const activeTheme = localStorage.getItem(QUICK_THEME_KEY);
|
||||
if (activeTheme && !themeCssCache.has(activeTheme)) {
|
||||
const css = await getThemeCss(activeTheme);
|
||||
if (css) themeCssCache.set(activeTheme, css);
|
||||
}
|
||||
}
|
||||
|
||||
export async function initCssModal() {
|
||||
const themes = await listThemes();
|
||||
buildThemeBadges(themes);
|
||||
await preloadActiveThemeCss();
|
||||
applyCustomCss();
|
||||
syncThemeBadges();
|
||||
|
||||
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)));
|
||||
localStorage.removeItem(QUICK_THEME_KEY);
|
||||
cssEditor.value = '';
|
||||
applyCustomCss();
|
||||
syncThemeBadges();
|
||||
});
|
||||
}
|
||||
@@ -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 `<li>
|
||||
<button class="home-recents__item" data-path="${escaped}" data-mode="${r.mode}">
|
||||
<span class="home-recents__item__icon">${icon}</span>
|
||||
<span class="home-recents__item__info">
|
||||
<span class="home-recents__item__name">${escapeHtml(r.name)}</span>
|
||||
<span class="home-recents__item__path">${escapeHtml(r.parent)}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>`;
|
||||
}).join('');
|
||||
list.querySelectorAll('.home-recents__item').forEach(btn => {
|
||||
btn.addEventListener('click', () => onOpen(btn.dataset.path, btn.dataset.mode));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { convertFile } from '../services/markdown.js';
|
||||
|
||||
const win = window.__TAURI__.window.getCurrentWindow();
|
||||
const titlebarTitle = document.getElementById('titlebar-title');
|
||||
const content = document.querySelector('#content');
|
||||
|
||||
function resolveRelativePath(currentFile, href) {
|
||||
const sep = currentFile.includes('\\') ? '\\' : '/';
|
||||
const dir = currentFile.split(sep).slice(0, -1).join(sep);
|
||||
const parts = [...dir.split(sep), ...href.split('/')];
|
||||
const resolved = [];
|
||||
for (const part of parts) {
|
||||
if (part === '..') resolved.pop();
|
||||
else if (part !== '.') resolved.push(part);
|
||||
}
|
||||
return resolved.join(sep);
|
||||
}
|
||||
|
||||
function wireLinks(filePath, mode, sidebarEl) {
|
||||
content.querySelectorAll('a[href]').forEach(a => {
|
||||
const href = a.getAttribute('href');
|
||||
if (!href || href.startsWith('#')) return;
|
||||
|
||||
if (/^https?:\/\//.test(href)) {
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener noreferrer');
|
||||
return;
|
||||
}
|
||||
|
||||
a.addEventListener('click', async e => {
|
||||
e.preventDefault();
|
||||
const resolved = resolveRelativePath(filePath, href);
|
||||
await loadPage(resolved, mode, sidebarEl);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function addCopyButtons() {
|
||||
content.querySelectorAll('pre').forEach(pre => {
|
||||
if (pre.parentElement?.classList.contains('code-block')) return;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'code-block';
|
||||
pre.parentNode.insertBefore(wrapper, pre);
|
||||
wrapper.appendChild(pre);
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'copy-code-btn';
|
||||
btn.textContent = 'Copier';
|
||||
btn.addEventListener('click', async () => {
|
||||
const code = (pre.querySelector('code') ?? pre).textContent;
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
btn.textContent = 'Copié !';
|
||||
btn.classList.add('copied');
|
||||
} catch {
|
||||
btn.textContent = 'Erreur';
|
||||
}
|
||||
setTimeout(() => {
|
||||
btn.textContent = 'Copier';
|
||||
btn.classList.remove('copied');
|
||||
}, 1500);
|
||||
});
|
||||
wrapper.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadPage(filePath, mode, sidebarEl) {
|
||||
try {
|
||||
const html = await convertFile(filePath);
|
||||
content.innerHTML = html;
|
||||
addCopyButtons();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wireLinks(filePath, mode, sidebarEl);
|
||||
} catch (err) {
|
||||
content.innerHTML = `<p class="error">Impossible de charger le fichier : ${err}</p>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { escapeHtml } from '../utils.js';
|
||||
|
||||
// Tri « naturel » : les préfixes numériques sont comparés comme des nombres
|
||||
// (10 vient après 9, pas après 1).
|
||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
|
||||
|
||||
function cleanLabel(name) {
|
||||
return name.replace(/\.md$/i, '').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((a, b) => collator.compare(a.split(sep).pop(), b.split(sep).pop()))
|
||||
.forEach((rel) => {
|
||||
const label = cleanLabel(rel.split(sep).pop());
|
||||
const abs = prefix + rel;
|
||||
const cls = rel === currentRel ? ' active' : '';
|
||||
if (depth === 0) {
|
||||
html += `\n<li><a href="#" class="nav-root-link${cls}" data-path="${escapeHtml(abs)}">${escapeHtml(label)}</a></li>`;
|
||||
} else {
|
||||
html += `\n<li${depthClass}><a href="#" class="nav-file-link${cls}" data-path="${escapeHtml(abs)}">${escapeHtml(label)}</a></li>`;
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(node._dirs).sort((a, b) => collator.compare(a, b)).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 = `<a href="#" class="nav-folder-link${cls}" data-path="${escapeHtml(abs)}" onclick="event.stopPropagation()">${escapeHtml(cleanLabel(dirName))}</a>`;
|
||||
} else {
|
||||
folderLabel = `<span class="nav-folder-name">${escapeHtml(cleanLabel(dirName))}</span>`;
|
||||
}
|
||||
|
||||
const arrow = `<span class="nav-arrow" onclick="event.preventDefault();event.stopPropagation();var d=this.closest('details');d.open=!d.open">▶</span>`;
|
||||
|
||||
const childNode = {
|
||||
_files: homeFile ? child._files.filter((f) => f !== homeFile) : child._files,
|
||||
_dirs: child._dirs,
|
||||
};
|
||||
|
||||
html += `\n<li${depthClass}><details class="nav-folder"${openAttr}>`;
|
||||
html += `\n <summary>${arrow}${folderLabel}</summary>`;
|
||||
html += `\n <ul class="nav-tree">`;
|
||||
html += renderTree(childNode, prefix, sep, currentRel, depth + 1);
|
||||
html += `\n </ul>\n</details></li>`;
|
||||
});
|
||||
|
||||
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 = `<div class="sidebar__title-block">
|
||||
<h1 class="sidebar__title-block__title">${escapeHtml(dirName)}</h1>
|
||||
</div>
|
||||
<ul class="sidebar__menu nav-tree">`;
|
||||
html += renderTree(tree, prefix, sep, currentRel, 0);
|
||||
html += `\n </ul>`;
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
Reference in New Issue
Block a user