4 Commits

Author SHA1 Message Date
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
12 changed files with 3785 additions and 30 deletions
+7
View File
@@ -1,2 +1,9 @@
src-tauri/target/
src-tauri/gen/
# Artefacts de build Flatpak
flatpak/.build/
flatpak/.repo/
flatpak/.flatpak-builder/
flatpak/pena.deb
flatpak/*.flatpak
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env bash
#
# Construit un paquet Flatpak de Pena à partir du .deb généré par Tauri.
#
# Étapes :
# 1. `cargo tauri build --bundles deb` -> produit le .deb autonome
# 2. Copie du .deb à côté du manifeste sous le nom `pena.deb`
# 3. `flatpak-builder` construit le bundle dans la sandbox GNOME
# 4. Installation locale (--user) et/ou export en fichier .flatpak
#
# Usage :
# ./build-flatpak.sh # build + installation locale (--user)
# ./build-flatpak.sh --bundle # build + export d'un fichier Pena.flatpak
# ./build-flatpak.sh --no-deb # réutilise le .deb déjà présent (build rapide)
#
set -euo pipefail
APP_ID="com.pena.app"
RUNTIME_VERSION="48"
RUST_CONTAINER="ubuntu-rust" # distrobox contenant cargo-tauri + libs GTK/WebKit
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")" # .../Pena-tauri
MANIFEST="$SCRIPT_DIR/${APP_ID}.yml"
BUILD_DIR="$SCRIPT_DIR/.build" # arbre de build flatpak-builder
REPO_DIR="$SCRIPT_DIR/.repo" # dépôt ostree temporaire
DEB_TARGET="$SCRIPT_DIR/pena.deb"
TAURI_CONF="$PROJECT_DIR/src-tauri/tauri.conf.json"
METAINFO="$SCRIPT_DIR/${APP_ID}.metainfo.xml"
DO_BUNDLE=0
BUILD_DEB=1
for arg in "$@"; do
case "$arg" in
--bundle) DO_BUNDLE=1 ;;
--no-deb) BUILD_DEB=0 ;;
-h|--help) sed -n '2,16p' "$0"; exit 0 ;;
*) echo "Argument inconnu : $arg" >&2; exit 1 ;;
esac
done
# --- Vérification des outils -------------------------------------------------
need() { command -v "$1" >/dev/null 2>&1 || { echo "Outil manquant : $1" >&2; exit 1; }; }
need flatpak
# flatpak-builder : commande native, sinon le Flatpak org.flatpak.Builder
# (cas des distributions atomiques type Bazzite/Silverblue).
if command -v flatpak-builder >/dev/null 2>&1; then
FLATPAK_BUILDER=(flatpak-builder)
elif flatpak info org.flatpak.Builder >/dev/null 2>&1; then
FLATPAK_BUILDER=(flatpak run org.flatpak.Builder)
else
echo "flatpak-builder est requis. Installez-le :" >&2
echo " flatpak install -y flathub org.flatpak.Builder" >&2
echo " ou sur Fedora classique : sudo dnf install flatpak-builder" >&2
exit 1
fi
# --- Remote flathub (scope --user) -------------------------------------------
# flatpak-builder est lancé en --user et installe les dépendances (runtime/SDK
# via --install-deps-from=flathub) dans cette même installation. Le remote
# flathub doit donc exister au niveau --user, ce qui n'est pas le cas par
# défaut sur les distributions atomiques (où flathub est un remote système).
echo ">> Vérification du remote flathub (--user)…"
flatpak remote-add --if-not-exists --user flathub \
https://flathub.org/repo/flathub.flatpakrepo
# --- 0. Version : source de vérité = tauri.conf.json -------------------------
# On lit la version applicative depuis tauri.conf.json et on la répercute dans
# le metainfo AppStream (release) afin que le Flatpak porte toujours la
# dernière version déclarée dans les sources.
need jq
VERSION="$(jq -r '.version' "$TAURI_CONF")"
if [[ -z "$VERSION" || "$VERSION" == "null" ]]; then
echo "Impossible de lire la version dans $TAURI_CONF" >&2
exit 1
fi
TODAY="$(date -u +%Y-%m-%d)"
echo ">> Version applicative : $VERSION (build du $TODAY)"
# Met à jour (ou insère) l'entrée <release> du metainfo pour cette version.
if grep -q "version=\"$VERSION\"" "$METAINFO"; then
echo ">> Release $VERSION déjà présente dans le metainfo."
else
echo ">> Ajout de la release $VERSION au metainfo…"
sed -i \
"s#<releases>#<releases>\n <release version=\"$VERSION\" date=\"$TODAY\"/>#" \
"$METAINFO"
fi
# --- 1. Build du .deb via Tauri ---------------------------------------------
if [[ "$BUILD_DEB" -eq 1 ]]; then
# La compilation Tauri exige les libs de dev GTK/WebKit, absentes de l'hôte
# (distribution atomique). On build donc dans le distrobox `$RUST_CONTAINER`,
# qui embarque cargo-tauri et ces libs. flatpak-builder reste sur l'hôte.
need distrobox
echo ">> Build du paquet .deb dans le distrobox $RUST_CONTAINER"
distrobox enter "$RUST_CONTAINER" -- \
bash -lc "cd '$PROJECT_DIR' && cargo tauri build --bundles deb"
fi
DEB_SRC="$(find "$PROJECT_DIR/src-tauri/target/release/bundle/deb" -name '*.deb' 2>/dev/null | sort | tail -n1 || true)"
if [[ -z "$DEB_SRC" ]]; then
echo "Aucun .deb trouvé. Lancez le script sans --no-deb." >&2
exit 1
fi
echo ">> Utilisation du .deb : $DEB_SRC"
cp -f "$DEB_SRC" "$DEB_TARGET"
# Icône installée dans le Flatpak (128x128) depuis les sources du projet.
cp -f "$PROJECT_DIR/src-tauri/icons/128x128.png" "$SCRIPT_DIR/icon.png"
# --- 2. Build Flatpak --------------------------------------------------------
# Construit l'app dans un dépôt OSTree local ($REPO_DIR) ; les dépendances
# manquantes (runtime/SDK) sont récupérées depuis flathub.
echo ">> Construction du Flatpak…"
rm -rf "$BUILD_DIR"
FB_ARGS=(--force-clean --user --install-deps-from=flathub --repo="$REPO_DIR")
if [[ "$DO_BUNDLE" -eq 0 ]]; then
FB_ARGS+=(--install)
fi
"${FLATPAK_BUILDER[@]}" "${FB_ARGS[@]}" "$BUILD_DIR" "$MANIFEST"
# --- 3. Export d'un fichier .flatpak (optionnel) -----------------------------
if [[ "$DO_BUNDLE" -eq 1 ]]; then
OUT="$SCRIPT_DIR/Pena.flatpak"
echo ">> Export du bundle : $OUT"
flatpak build-bundle "$REPO_DIR" "$OUT" "$APP_ID" \
--runtime-repo=https://flathub.org/repo/flathub.flatpakrepo
echo ">> Nettoyage du dépôt OSTree temporaire…"
rm -rf "$REPO_DIR"
echo ">> Installer avec : flatpak install --user $OUT"
else
echo ">> Pena $VERSION installé. Lancer avec : flatpak run $APP_ID"
fi
echo ">> Terminé."
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>com.pena.app</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license>
<name>Pena</name>
<summary>Lecteur de fichiers Markdown</summary>
<description>
<p>
Pena est une application de bureau pour lire des fichiers Markdown.
La conversion Markdown vers HTML est réalisée côté Rust (comrak), avec
coloration syntaxique des blocs de code et plusieurs thèmes de lecture.
</p>
</description>
<launchable type="desktop-id">com.pena.app.desktop</launchable>
<categories>
<category>Utility</category>
<category>TextEditor</category>
</categories>
<content_rating type="oars-1.1"/>
<releases>
<release version="0.1.0" date="2026-06-23"/>
</releases>
</component>
+48
View File
@@ -0,0 +1,48 @@
id: com.pena.app
runtime: org.gnome.Platform
runtime-version: '48'
sdk: org.gnome.Sdk
command: pena
finish-args:
# Affichage
- --socket=wayland
- --socket=fallback-x11
- --device=dri
- --share=ipc
# Lecture des fichiers Markdown ouverts par l'utilisateur
- --filesystem=home
modules:
- name: pena
buildsystem: simple
sources:
- type: file
path: pena.deb
- type: file
path: com.pena.app.metainfo.xml
build-commands:
- |
set -eux
# Extraction du .deb produit par `cargo tauri build`
ar -x pena.deb
tar -xf data.tar.gz
# Binaire principal -> /app/bin/pena (correspond à `command:`)
bin="$(find usr/bin -maxdepth 1 -type f | head -n1)"
install -Dm755 "$bin" /app/bin/pena
# Fichier .desktop : renommé en <app-id>.desktop (exigence Flatpak)
desktop="$(find usr/share/applications -name '*.desktop' | head -n1)"
install -Dm644 "$desktop" /app/share/applications/com.pena.app.desktop
sed -i \
-e 's/^Exec=.*/Exec=pena/' \
-e 's/^Icon=.*/Icon=com.pena.app/' \
/app/share/applications/com.pena.app.desktop
# Icône : on prend la plus grande disponible
icon="$(find usr/share/icons -name '*.png' | sort | tail -n1)"
install -Dm644 "$icon" /app/share/icons/hicolor/128x128/apps/com.pena.app.png
# Métadonnées AppStream
install -Dm644 com.pena.app.metainfo.xml /app/share/metainfo/com.pena.app.metainfo.xml
+1 -1
View File
@@ -17,5 +17,5 @@ tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
comrak = { version = "0.28", features = ["syntect"] }
syntect = "5"
syntect = { version = "5", features = ["yaml-load"] }
notify = "7"
File diff suppressed because it is too large Load Diff
-2
View File
@@ -102,13 +102,11 @@ body {
.content pre:not(.syntax-highlighting) {
background: #0f172a;
border: 1px solid #1e293b;
border-radius: 10px;
}
/* Bloc de code avec coloration syntaxique */
.content .syntax-highlighting {
border: 1px solid #1e293b;
border-radius: 10px;
}
/* Texte dans un bloc sans coloration */
@@ -2,15 +2,30 @@ 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().css().build();
let adapter = SyntectAdapterBuilder::new()
.syntax_set(build_syntax_set())
.css()
.build();
let mut options = Options::default();
options.extension.table = true;
options.extension.strikethrough = true;
@@ -24,7 +39,10 @@ impl MarkdownRenderer for ComrakRenderer {
impl MarkdownRenderer for ComrakPreviewRenderer {
fn render(&self, content: &str) -> String {
let adapter = SyntectAdapterBuilder::new().css().build();
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);
@@ -62,6 +80,13 @@ mod tests {
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");
@@ -80,5 +105,4 @@ mod tests {
let css = syntax_css_for_theme("nonexistent-theme-xyz");
assert!(css.is_empty());
}
}

Before

Width:  |  Height:  |  Size: 993 B

After

Width:  |  Height:  |  Size: 993 B

+65 -19
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; }
@@ -91,6 +92,13 @@ body {
background: #1a1b2e;
}
.home-logo {
width: 160px;
height: 160px;
object-fit: contain;
margin: 0 0 16px;
}
.home-title {
color: #fff;
font-size: 4rem;
@@ -206,7 +214,7 @@ body {
position: fixed;
top: 36px;
left: 0;
width: 264px;
width: var(--sidebar-width);
height: calc(100vh - 36px);
background: #1a1b2e;
display: flex;
@@ -215,6 +223,22 @@ body {
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;
@@ -315,10 +339,10 @@ 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;
@@ -358,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;
@@ -402,16 +425,16 @@ body {
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;
margin-left: var(--sidebar-width);
padding-top: 48px;
padding-right: 72px;
padding-bottom: 96px;
@@ -455,6 +478,29 @@ body {
.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;
+32
View File
@@ -35,10 +35,42 @@ function wireLinks(filePath, mode, sidebarEl) {
});
}
function addCopyButtons() {
content.querySelectorAll('pre').forEach(pre => {
if (pre.parentElement?.classList.contains('code-block')) return;
const wrapper = document.createElement('div');
wrapper.className = 'code-block';
pre.parentNode.insertBefore(wrapper, pre);
wrapper.appendChild(pre);
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'copy-code-btn';
btn.textContent = 'Copier';
btn.addEventListener('click', async () => {
const code = (pre.querySelector('code') ?? pre).textContent;
try {
await navigator.clipboard.writeText(code);
btn.textContent = 'Copié !';
btn.classList.add('copied');
} catch {
btn.textContent = 'Erreur';
}
setTimeout(() => {
btn.textContent = 'Copier';
btn.classList.remove('copied');
}, 1500);
});
wrapper.appendChild(btn);
});
}
export async function loadPage(filePath, mode, sidebarEl) {
try {
const html = await convertFile(filePath);
content.innerHTML = html;
addCopyButtons();
const basename = filePath.split('/').pop().split('\\').pop();
const title = basename.replace(/\.md$/i, '');
+16 -5
View File
@@ -1,5 +1,13 @@
import { escapeHtml } from '../utils.js';
// Tri « naturel » : les préfixes numériques sont comparés comme des nombres
// (10 vient après 9, pas après 1).
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
function cleanLabel(name) {
return name.replace(/\.md$/i, '').replace(/-/g, ' ');
}
function buildTree(relPaths, sep) {
const tree = { _files: [], _dirs: {} };
relPaths.forEach((rel) => {
@@ -19,8 +27,11 @@ 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, ' ');
node._files
.slice()
.sort((a, b) => collator.compare(a.split(sep).pop(), b.split(sep).pop()))
.forEach((rel) => {
const label = cleanLabel(rel.split(sep).pop());
const abs = prefix + rel;
const cls = rel === currentRel ? ' active' : '';
if (depth === 0) {
@@ -30,7 +41,7 @@ function renderTree(node, prefix, sep, currentRel, depth) {
}
});
Object.keys(node._dirs).sort().forEach((dirName) => {
Object.keys(node._dirs).sort((a, b) => collator.compare(a, b)).forEach((dirName) => {
const child = node._dirs[dirName];
const isOpen = currentRel && currentRel.split(sep).includes(dirName);
const openAttr = isOpen ? ' open' : '';
@@ -40,9 +51,9 @@ function renderTree(node, prefix, sep, currentRel, depth) {
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>`;
folderLabel = `<a href="#" class="nav-folder-link${cls}" data-path="${escapeHtml(abs)}" onclick="event.stopPropagation()">${escapeHtml(cleanLabel(dirName))}</a>`;
} else {
folderLabel = `<span class="nav-folder-name">${escapeHtml(dirName)}</span>`;
folderLabel = `<span class="nav-folder-name">${escapeHtml(cleanLabel(dirName))}</span>`;
}
const arrow = `<span class="nav-arrow" onclick="event.preventDefault();event.stopPropagation();var d=this.closest('details');d.open=!d.open">▶</span>`;