20 Commits

Author SHA1 Message Date
Gato 61fb56fa14 chore: icônes de bundle, packaging Flatpak et bump version 0.1.2
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 06:17:56 +02:00
Gato 8039a3545d feat: rechargement automatique des documents et sidebar redimensionnable
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 06:17:12 +02:00
Gato fe0476218d feat: export systematique de pena.flatpak lors du build
Le mode par defaut installe (--user) ET exporte le bundle pena.flatpak a cote
du script. Le flag --bundle devient --no-install (bundle seul, alias conserve).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 08:11:27 +02:00
Gato 1220ce1bb0 fix: tri naturel de la sidebar et police uniforme dossiers/fichiers
Les entrees a prefixe numerique etaient triees lexicographiquement (10 avant 2).
Passage a un tri naturel via Intl.Collator numeric. Les libelles de dossiers
adoptent desormais la meme taille et graisse que les fichiers (chevron seul
marqueur visuel).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 07:54:19 +02:00
Gato 4bb2c6cc77 feat: build flatpak dans distrobox ubuntu-rust + sync version metainfo
Le build cargo tauri echouait sur l hote atomique (libs GTK/WebKit absentes) ;
il tourne desormais dans le distrobox ubuntu-rust. La version applicative est
lue depuis tauri.conf.json et repercutee dans le metainfo AppStream.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 07:37:17 +02:00
Gato 2841881a01 feat: script de packaging Flatpak
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 07:02:32 +02:00
Gato 8b0f128313 (feat) copy code content
Signed-off-by: Gato <cedric@goutailler-olivier.fr>
2026-06-22 21:33:38 +02:00
Gato 2d9a1e64f9 fix: inclure le CSS syntect statiquement dans style.css
L'injection dynamique via invoke() échouait silencieusement.
Le CSS base16-ocean.dark est maintenant scopé (.syntax-highlighting .keyword
etc.) et inclus directement dans style.css — aucun appel runtime requis.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 15:49:26 +02:00
Gato ebb648bb2d fix: isoler le style du code inline pour ne pas affecter les blocs pre
.content code dans les thèmes ciblait aussi le <code> dans <pre>,
écrasant les couleurs syntect par héritage et ajoutant une bordure
indésirable. Changé en .content :not(pre) > code + reset explicite
dans style.css (border: none, color: inherit).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 15:44:00 +02:00
Gato df899bd1a8 feat: coloration syntaxique via CSS classes (themeable)
Remplace les styles inline syntect par des classes CSS. Le CSS est
généré côté Rust (base16-ocean.dark) et injecté au démarrage via
get_syntax_highlight_css. Les thèmes peuvent surcharger .syntax-highlighting
et les classes de tokens. Supprime le lien CDN highlight.js inutilisé.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 15:36:08 +02:00
Gato 75c85e7e0a fix: corriger les classes CSS mortes de la sidebar dans les thèmes
Les thèmes référençaient d'anciennes classes (sidebar__menu__*__link)
jamais générées par le JS. Remplacement par les vraies classes nav-tree
(nav-root-link, nav-folder-link, nav-file-link) et ajout du fond
sidebar et des états actifs configurables par thème.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 15:20:55 +02:00
Gato e34528c886 feat: thèmes rapides gérés par le backend via fichiers CSS
Les thèmes ne sont plus codés en dur dans le JS. Chaque thème est
un fichier CSS dans src-tauri/resources/themes/, embarqué à la
compilation via include_str!. Le backend expose list_themes et
get_theme_css comme commandes Tauri ; le frontend charge et met en
cache le CSS à la demande.

Ajout du thème "Défaut" (basé sur Shell Indigo) avec commentaires
sur chaque règle et rendu complet des tableaux (en-têtes en
majuscules, bordure extérieure, séparateurs verticaux, lignes
alternées) aligné sur MarkdownRender-nodejs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 15:04:11 +02:00
Gato 8b39c09484 feat: ajout des badges thèmes rapides dans le modal CSS
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 14:39:24 +02:00
Gato b8ba15d832 feat: navigation entre documents via les liens Markdown
Intercepte les clics sur les liens dans le contenu rendu : les liens
internes (.md) chargent le document dans l'app, les liens HTTP s'ouvrent
dans le navigateur système. Restructure aussi la sidebar avec un footer
fixe pour les boutons Personnaliser/Accueil.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 14:35:50 +02:00
Gato d47e93b3c3 docs: règle architecture Clean Architecture pour Pena-tauri 2026-06-19 14:27:25 +02:00
Gato cdafffaa70 refactor: migration architecture clean (Rust + JS)
Côté JS : décompose main.js en modules ES6 (state, services, ui, router)
pour séparer les responsabilités et faciliter la maintenance.
Côté Rust : ajoute 7 tests sur is_relevant_path (notify_watcher)
pour couvrir les cas cachés/non-md ; couverture totale 77 % > seuil 60 %.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 14:23:30 +02:00
Gato 6ae7a434c4 refactor: migration Clean Architecture — module commands avec render et watch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 14:13:30 +02:00
Gato 9eae97f1d8 feat: module application avec render_service et file_service
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 14:09:08 +02:00
Gato 7d2f56a364 refactor: module infrastructure avec comrak_renderer, file_repository, notify_watcher
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 14:06:43 +02:00
Gato ec239bb42e feat: module domain avec trait MarkdownRenderer et RenderOptions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 14:01:41 +02:00
104 changed files with 5732 additions and 707 deletions
+52
View File
@@ -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/`
+8
View File
@@ -1,2 +1,10 @@
src-tauri/target/
src-tauri/gen/
# Artefacts de build Flatpak
flatpak/.build/
flatpak/.repo/
flatpak/.flatpak-builder/
flatpak/pena.deb
flatpak/icon.png
flatpak/*.flatpak
+144
View File
@@ -0,0 +1,144 @@
#!/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 export du fichier pena.flatpak
#
# Le fichier `pena.flatpak` est TOUJOURS exporté à côté du script.
#
# Usage :
# ./build-flatpak.sh # build + installation (--user) + pena.flatpak
# ./build-flatpak.sh --no-install # build + pena.flatpak seul (pas d'installation)
# ./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_INSTALL=1
BUILD_DEB=1
for arg in "$@"; do
case "$arg" in
--no-install|--bundle) DO_INSTALL=0 ;; # --bundle : alias rétrocompatible
--no-deb) BUILD_DEB=0 ;;
-h|--help) sed -n '2,17p' "$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_INSTALL" -eq 1 ]]; then
FB_ARGS+=(--install)
fi
"${FLATPAK_BUILDER[@]}" "${FB_ARGS[@]}" "$BUILD_DIR" "$MANIFEST"
# --- 3. Export du fichier .flatpak (toujours) --------------------------------
# Le bundle autonome est systématiquement exporté à côté du manifeste.
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"
if [[ "$DO_INSTALL" -eq 1 ]]; then
echo ">> Pena $VERSION installé (--user) et bundle exporté : $OUT"
echo ">> Lancer avec : flatpak run $APP_ID"
else
echo ">> Bundle exporté : $OUT"
echo ">> Installer avec : flatpak install --user $OUT"
fi
echo ">> Terminé."
+25
View File
@@ -0,0 +1,25 @@
<?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.2" date="2026-07-05"/>
<release version="0.1.0" date="2026-06-23"/>
</releases>
</component>
+49
View File
@@ -0,0 +1,49 @@
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
- type: file
path: icon.png
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 (128x128) fournie séparément à côté du manifeste.
install -Dm644 icon.png /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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

+1 -1
View File
@@ -2445,7 +2445,7 @@ dependencies = [
[[package]]
name = "pena-taury"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"comrak",
"notify",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "pena-taury"
version = "0.1.0"
version = "0.1.2"
edition = "2021"
[lib]
@@ -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"
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 B

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 928 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because it is too large Load Diff
+9
View File
@@ -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; }
+255
View File
@@ -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);
}
+3
View File
@@ -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; }
+6
View File
@@ -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); }
+65
View File
@@ -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);
}
}
+3
View File
@@ -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());
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod render;
pub mod theme;
pub mod watch;
+116
View File
@@ -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());
}
}
+54
View File
@@ -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());
}
}
+54
View File
@@ -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;
}
-204
View File
@@ -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());
}
}
+27
View File
@@ -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);
}
}
+5
View File
@@ -0,0 +1,5 @@
mod markdown;
pub mod theme;
#[allow(unused_imports)]
pub use markdown::{MarkdownRenderer, RenderOptions};
+39
View File
@@ -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());
}
}
+4
View File
@@ -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
View File
@@ -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");
-109
View File
@@ -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;
}
+8 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Pena",
"version": "0.1.0",
"version": "0.1.2",
"identifier": "com.pena.app",
"build": {
"frontendDist": "../src"
@@ -24,6 +24,12 @@
"bundle": {
"active": false,
"targets": "all",
"icon": []
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

+4
View File
@@ -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

+30 -11
View File
@@ -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,7 +19,10 @@
</div>
</div>
<div id="app-content">
<div id="view-home">
<img class="home-logo" src="assets/icon.png" alt="Pena" />
<h1 class="home-title">Pena</h1>
<div class="home-buttons">
<button id="btn-open-file">Ouvrir un fichier Markdown</button>
@@ -33,24 +35,37 @@
</div>
<div id="view-reader" class="hidden">
<aside id="sidebar" class="sidebar">
<!-- contenu généré par JS -->
</aside>
<div class="sidebar">
<aside id="sidebar" class="sidebar__scroll">
<!-- contenu généré par JS -->
</aside>
<div class="sidebar__footer">
<button id="btn-customize-css" class="btn-customize-css">
<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 id="sidebar-resizer" class="sidebar__resizer" title="Redimensionner"></div>
</div>
<div id="content" class="content">
<!-- contenu généré par JS -->
</div>
<button id="btn-customize-css" class="btn-customize-css">
<span class="btn-customize-css__icon"></span>
Personnaliser le CSS
</button>
<button id="btn-back" class="btn-back">← Accueil</button>
</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 +75,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 */&#10;/* Exemple : .content { font-size: 18px; } */"></textarea>
</div>
+8 -313
View File
@@ -1,7 +1,7 @@
let currentPath = null;
let currentMode = null; // 'file' | 'dir'
import { showHome, openPath, initWatcher } from './router.js';
import { initCssModal } from './ui/css-modal.js';
import { initSidebarResize } from './ui/sidebar-resize.js';
const titlebarTitle = document.getElementById('titlebar-title');
const win = window.__TAURI__.window.getCurrentWindow();
document.getElementById('titlebar').addEventListener('mousedown', (e) => {
@@ -16,239 +16,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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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 +31,9 @@ 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();
initSidebarResize();
await initWatcher();
showHome();
+127
View File
@@ -0,0 +1,127 @@
import { appState } from './state/app-state.js';
import { listMdFiles } from './services/files.js';
import { startWatch, stopWatch, onFileChanged } from './services/watcher.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');
const navigate = filePath => loadPage(filePath, appState.currentMode, sidebarEl);
export function showHome() {
stopWatch();
appState.currentPath = null;
appState.currentMode = null;
appState.baseDir = null;
appState.currentFile = null;
appState.files = [];
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);
appState.baseDir = parentDir;
appState.files = files;
sidebarEl.classList.remove('hidden');
content.style.marginLeft = '';
renderSidebar(parentDir, files, path, navigate);
} catch {
// Le dossier parent est illisible : on surveille au moins le fichier ouvert.
appState.baseDir = path;
appState.files = [];
sidebarEl.innerHTML = '';
sidebarEl.classList.add('hidden');
content.style.marginLeft = '0';
}
await loadPage(path, appState.currentMode, sidebarEl);
startWatch(appState.baseDir);
return;
}
// mode === 'dir'
sidebarEl.classList.remove('hidden');
content.style.marginLeft = '';
try {
const files = await listMdFiles(path);
appState.baseDir = path;
appState.files = files;
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, navigate);
await loadPage(firstFile, appState.currentMode, sidebarEl);
startWatch(path);
} catch (err) {
showReader();
content.innerHTML = `<p class="error">Impossible d'ouvrir le dossier : ${err}</p>`;
}
}
// Compare deux listes de fichiers indépendamment de l'ordre.
function sameFiles(a, b) {
if (a.length !== b.length) return false;
const key = arr => arr.slice().sort().join('\n');
return key(a) === key(b);
}
// Réaction à un changement filesystem signalé par le backend (événement débouncé,
// filtré sur les fichiers .md).
async function handleFileChanged(changedPath) {
if (!appState.baseDir || !appState.currentMode) return;
// Rafraîchir la sidebar uniquement si l'arborescence a changé (ajout/suppression),
// pour ne pas replier les dossiers ouverts à chaque simple modification de contenu.
try {
const files = await listMdFiles(appState.baseDir);
if (!sameFiles(files, appState.files)) {
appState.files = files;
renderSidebar(appState.baseDir, files, appState.currentFile, navigate);
}
} catch {
// baseDir supprimé/illisible : on garde la sidebar en l'état.
}
// Recharger le document affiché si c'est lui qui vient d'être modifié.
if (changedPath === appState.currentFile) {
await loadPage(appState.currentFile, appState.currentMode, sidebarEl);
}
}
// À appeler une fois au démarrage : enregistre le listener global de changements.
export function initWatcher() {
return onFileChanged(event => {
handleFileChanged(event.payload.path);
});
}
+3
View File
@@ -0,0 +1,3 @@
export function listMdFiles(dir) {
return window.__TAURI__.core.invoke('list_md_files', { dir });
}
+7
View File
@@ -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 });
}
+9
View File
@@ -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 });
}
+11
View File
@@ -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);
}
+12
View File
@@ -0,0 +1,12 @@
export const appState = {
currentPath: null,
currentMode: null,
// Dossier réellement surveillé (racine de la sidebar) : le dossier ouvert en
// mode « dir », ou le dossier parent du fichier ouvert en mode « file ».
baseDir: null,
// Fichier actuellement affiché dans le lecteur.
currentFile: null,
// Dernière liste de fichiers .md connue de `baseDir`, pour détecter les
// ajouts/suppressions et ne rafraîchir la sidebar que si l'arborescence change.
files: [],
};
+185 -42
View File
@@ -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; }
+121
View File
@@ -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();
});
}
+44
View File
@@ -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));
});
}

Some files were not shown because too many files have changed in this diff Show More