This commit is contained in:
2026-05-21 09:39:12 +02:00
parent fc085a8a83
commit 041e90a8f2
15 changed files with 269 additions and 207 deletions

View File

@ -20,3 +20,6 @@ serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
syntect = "5.3.0" syntect = "5.3.0"
uuid = "1.22.0" uuid = "1.22.0"
[profile.release]
debug = true

BIN
profile.json.gz Normal file

Binary file not shown.

View File

@ -20,3 +20,6 @@ x11rb = "0.13.2"
[features] [features]
x11 = [] x11 = []
wayland = [] wayland = []
[profile.release]
debug = true

BIN
rklipd/profile.json.gz Normal file

Binary file not shown.

View File

@ -24,27 +24,31 @@ impl Config {
while i < args.len() { while i < args.len() {
match args[i].as_str() { match args[i].as_str() {
"--max-entries" => { "--max-entries" => {
if let Some(v) = args.get(i + 1).and_then(|s| s.parse().ok()) { i += 1;
cfg.max_entries = v; match args.get(i).and_then(|s| s.parse::<usize>().ok()) {
i += 1; Some(0) => eprintln!("--max-entries doit être > 0"),
} else { Some(v) => cfg.max_entries = v,
eprintln!("--max-entries requiert une valeur entière positive"); None => eprintln!("--max-entries requiert une valeur entière positive"),
} }
} }
"--max-entry-size-kb" => { "--max-entry-size-kb" => {
if let Some(v) = args.get(i + 1).and_then(|s| s.parse().ok()) { i += 1;
cfg.max_entry_size_kb = v; match args.get(i).and_then(|s| s.parse::<usize>().ok()) {
i += 1; Some(0) => eprintln!("--max-entry-size-kb doit être > 0"),
} else { Some(v) => cfg.max_entry_size_kb = v,
eprintln!("--max-entry-size-kb requiert une valeur entière positive"); None => {
eprintln!("--max-entry-size-kb requiert une valeur entière positive")
}
} }
} }
"--expiry-days" => { "--expiry-days" => {
if let Some(v) = args.get(i + 1).and_then(|s| s.parse::<u64>().ok()) { i += 1;
cfg.expiry_days = Some(v); match args.get(i).and_then(|s| s.parse::<u64>().ok()) {
i += 1; Some(0) => eprintln!(
} else { "--expiry-days doit être > 0 (0 supprimerait tout immédiatement)"
eprintln!("--expiry-days requiert une valeur entière positive"); ),
Some(v) => cfg.expiry_days = Some(v),
None => eprintln!("--expiry-days requiert une valeur entière positive"),
} }
} }
"--help" | "-h" => { "--help" | "-h" => {

View File

@ -101,9 +101,7 @@ impl Database {
ExtendedColorType::Rgb8, ExtendedColorType::Rgb8,
)?; )?;
} }
None => { None => return Ok(()),
return Ok(());
}
} }
("image", format!("{}.jpg", img.id)) ("image", format!("{}.jpg", img.id))
} }
@ -115,7 +113,6 @@ impl Database {
)?; )?;
self.trim_to_max()?; self.trim_to_max()?;
Ok(()) Ok(())
} }
@ -124,8 +121,10 @@ impl Database {
return Ok(()); return Ok(());
} }
let tx = self.conn.unchecked_transaction()?;
let image_files: Vec<String> = { let image_files: Vec<String> = {
let mut stmt = self.conn.prepare( let mut stmt = tx.prepare(
"SELECT content FROM history "SELECT content FROM history
WHERE type = 'image' WHERE type = 'image'
AND id NOT IN ( AND id NOT IN (
@ -137,13 +136,15 @@ impl Database {
.collect() .collect()
}; };
self.conn.execute( tx.execute(
"DELETE FROM history WHERE id NOT IN ( "DELETE FROM history WHERE id NOT IN (
SELECT id FROM history ORDER BY timestamp DESC LIMIT ?1 SELECT id FROM history ORDER BY timestamp DESC LIMIT ?1
)", )",
[self.max_entries as i64], [self.max_entries as i64],
)?; )?;
tx.commit()?;
for filename in image_files { for filename in image_files {
let path = Path::new(&self.dir_path).join("images").join(&filename); let path = Path::new(&self.dir_path).join("images").join(&filename);
if path.exists() { if path.exists() {
@ -213,18 +214,19 @@ impl Database {
let cutoff_ms = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as i64 let cutoff_ms = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as i64
- (days as i64 * 86_400_000); - (days as i64 * 86_400_000);
let tx = self.conn.unchecked_transaction()?;
let image_files: Vec<String> = { let image_files: Vec<String> = {
let mut stmt = self let mut stmt =
.conn tx.prepare("SELECT content FROM history WHERE type = 'image' AND timestamp < ?1")?;
.prepare("SELECT content FROM history WHERE type = 'image' AND timestamp < ?1")?;
stmt.query_map([cutoff_ms], |row| row.get(0))? stmt.query_map([cutoff_ms], |row| row.get(0))?
.filter_map(|r| r.ok()) .filter_map(|r| r.ok())
.collect() .collect()
}; };
let count = self let count = tx.execute("DELETE FROM history WHERE timestamp < ?1", [cutoff_ms])?;
.conn
.execute("DELETE FROM history WHERE timestamp < ?1", [cutoff_ms])?; tx.commit()?;
for filename in image_files { for filename in image_files {
let path = Path::new(&self.dir_path).join("images").join(&filename); let path = Path::new(&self.dir_path).join("images").join(&filename);

View File

@ -48,13 +48,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let db_for_expiry = Arc::clone(&db); let db_for_expiry = Arc::clone(&db);
std::thread::spawn(move || { std::thread::spawn(move || {
loop { loop {
std::thread::sleep(Duration::from_secs(3600)); {
let lock = db_for_expiry.lock().unwrap(); let lock = db_for_expiry.lock().unwrap();
match lock.delete_entries_older_than(days) { match lock.delete_entries_older_than(days) {
Ok(0) => {} Ok(0) => {}
Ok(n) => println!("Expiration : {n} entrée(s) supprimée(s) (> {days} jours)"), Ok(n) => {
Err(e) => eprintln!("Erreur expiration : {e}"), println!("Expiration : {n} entrée(s) supprimée(s) (> {days} jours)")
}
Err(e) => eprintln!("Erreur expiration : {e}"),
}
} }
std::thread::sleep(Duration::from_secs(3600));
} }
}); });
} }

View File

@ -1,28 +1,42 @@
use crate::database::Database; use crate::database::Database;
use crate::models::ClipboardEntry;
use arboard::Clipboard; use arboard::Clipboard;
use std::error::Error; use std::error::Error;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex, mpsc};
pub fn start(db: Arc<Mutex<Database>>, clipboard: Clipboard) -> Result<(), Box<dyn Error>> { pub fn start(db: Arc<Mutex<Database>>, clipboard: Clipboard) -> Result<(), Box<dyn Error>> {
let (tx, rx) = mpsc::channel::<ClipboardEntry>();
std::thread::spawn(move || {
for entry in rx {
let lock = db.lock().unwrap();
if let Err(e) = lock.append(entry) {
eprintln!("SQLite write error: {e}");
} else {
println!("SQLite updated!");
}
}
});
#[cfg(all(feature = "wayland", not(feature = "x11")))] #[cfg(all(feature = "wayland", not(feature = "x11")))]
{ {
crate::ws::wayland::start(db, clipboard) crate::ws::wayland::start(tx, clipboard)
} }
#[cfg(all(feature = "x11", not(feature = "wayland")))] #[cfg(all(feature = "x11", not(feature = "wayland")))]
{ {
crate::ws::x11::start(db, clipboard) crate::ws::x11::start(tx, clipboard)
} }
#[cfg(all(feature = "x11", feature = "wayland"))] #[cfg(all(feature = "x11", feature = "wayland"))]
{ {
let _ = (db, clipboard); let _ = (tx, clipboard);
Err("Les features 'x11' et 'wayland' sont mutuellement exclusives".into()) Err("Les features 'x11' et 'wayland' sont mutuellement exclusives".into())
} }
#[cfg(not(any(feature = "x11", feature = "wayland")))] #[cfg(not(any(feature = "x11", feature = "wayland")))]
{ {
let _ = (db, clipboard); let _ = (tx, clipboard);
Err("Aucune feature de système de fenêtrage activée (--features x11 ou wayland)".into()) Err("Aucune feature de système de fenêtrage activée (--features x11 ou wayland)".into())
} }
} }

View File

@ -1,15 +1,22 @@
use crate::database::Database;
use crate::models::{ClipboardData, ClipboardEntry, Image}; use crate::models::{ClipboardData, ClipboardEntry, Image};
use std::collections::hash_map::DefaultHasher;
use std::error::Error; use std::error::Error;
use std::sync::{Arc, Mutex}; use std::hash::{Hash, Hasher};
use std::sync::mpsc;
use std::time::SystemTime; use std::time::SystemTime;
use uuid::Uuid; use uuid::Uuid;
use wayland_clipboard_listener::{WlClipboardPasteStream, WlListenType}; use wayland_clipboard_listener::{WlClipboardPasteStream, WlListenType};
const MAX_IMAGE_PIXELS: usize = 3840 * 2160; const MAX_IMAGE_PIXELS: usize = 3840 * 2160;
fn hash_bytes(data: &[u8]) -> u64 {
let mut hasher = DefaultHasher::new();
data.hash(&mut hasher);
hasher.finish()
}
pub fn start( pub fn start(
db: Arc<Mutex<Database>>, tx: mpsc::Sender<ClipboardEntry>,
_clipboard: arboard::Clipboard, _clipboard: arboard::Clipboard,
) -> Result<(), Box<dyn Error>> { ) -> Result<(), Box<dyn Error>> {
let mut stream = WlClipboardPasteStream::init(WlListenType::ListenOnCopy) let mut stream = WlClipboardPasteStream::init(WlListenType::ListenOnCopy)
@ -17,9 +24,11 @@ pub fn start(
println!("Écoute du presse-papier Wayland..."); println!("Écoute du presse-papier Wayland...");
let mut last_text: Option<String> = None;
let mut last_image_hash: Option<u64> = None;
for msg in stream.paste_stream().flatten() { for msg in stream.paste_stream().flatten() {
let context = &msg.context; let data: &[u8] = msg.context.context.as_slice();
let data: &[u8] = context.context.as_slice();
if data.is_empty() { if data.is_empty() {
continue; continue;
@ -27,14 +36,22 @@ pub fn start(
let entry = if let Ok(text) = String::from_utf8(data.to_vec()) { let entry = if let Ok(text) = String::from_utf8(data.to_vec()) {
let text = text.trim_end_matches('\n').to_string(); let text = text.trim_end_matches('\n').to_string();
if text.is_empty() { if text.is_empty() || Some(&text) == last_text.as_ref() {
continue; continue;
} }
last_text = Some(text.clone());
last_image_hash = None;
println!("Clipboard update (texte)");
ClipboardEntry { ClipboardEntry {
content: ClipboardData::Text(text), content: ClipboardData::Text(text),
timestamp: SystemTime::now(), timestamp: SystemTime::now(),
} }
} else { } else {
let hash = hash_bytes(data);
if Some(hash) == last_image_hash {
continue;
}
match image::load_from_memory(data) { match image::load_from_memory(data) {
Ok(img) => { Ok(img) => {
let (width, height) = (img.width(), img.height()); let (width, height) = (img.width(), img.height());
@ -48,9 +65,15 @@ pub fn start(
3840, 3840,
2160 2160
); );
last_image_hash = Some(hash);
last_text = None;
continue; continue;
} }
last_image_hash = Some(hash);
last_text = None;
println!("Clipboard update (image)");
let rgba = img.into_rgba8(); let rgba = img.into_rgba8();
ClipboardEntry { ClipboardEntry {
content: ClipboardData::Image(Image { content: ClipboardData::Image(Image {
@ -69,15 +92,10 @@ pub fn start(
} }
}; };
println!("Clipboard update détecté"); if tx.send(entry).is_err() {
eprintln!("Wayland : writer thread disparu, arrêt");
let db_clone = Arc::clone(&db); break;
std::thread::spawn(move || { }
let db_lock = db_clone.lock().unwrap();
if let Err(e) = db_lock.append(entry) {
eprintln!("SQLite error : {e}");
}
});
} }
Ok(()) Ok(())

View File

@ -1,10 +1,9 @@
use crate::database::Database;
use crate::models::{ClipboardData, ClipboardEntry, Image}; use crate::models::{ClipboardData, ClipboardEntry, Image};
use arboard::Clipboard; use arboard::Clipboard;
use std::collections::hash_map::DefaultHasher; use std::collections::hash_map::DefaultHasher;
use std::error::Error; use std::error::Error;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex}; use std::sync::mpsc;
use std::thread; use std::thread;
use std::time::{Duration, SystemTime}; use std::time::{Duration, SystemTime};
use uuid::Uuid; use uuid::Uuid;
@ -22,7 +21,10 @@ fn hash_bytes(data: &[u8]) -> u64 {
hasher.finish() hasher.finish()
} }
pub fn start(db: Arc<Mutex<Database>>, mut clipboard: Clipboard) -> Result<(), Box<dyn Error>> { pub fn start(
tx: mpsc::Sender<ClipboardEntry>,
mut clipboard: Clipboard,
) -> Result<(), Box<dyn Error>> {
let (conn, screen_num) = let (conn, screen_num) =
RustConnection::connect(None).map_err(|e| format!("Connexion X11 impossible : {e}"))?; RustConnection::connect(None).map_err(|e| format!("Connexion X11 impossible : {e}"))?;
@ -49,15 +51,14 @@ pub fn start(db: Arc<Mutex<Database>>, mut clipboard: Clipboard) -> Result<(), B
.reply()?; .reply()?;
let clipboard_atom = conn.intern_atom(false, b"CLIPBOARD")?.reply()?.atom; let clipboard_atom = conn.intern_atom(false, b"CLIPBOARD")?.reply()?.atom;
conn.xfixes_select_selection_input( conn.xfixes_select_selection_input(
win, win,
clipboard_atom, clipboard_atom,
SelectionEventMask::SET_SELECTION_OWNER, SelectionEventMask::SET_SELECTION_OWNER,
)? )?
.check()?; .check()?;
conn.flush()?; conn.flush()?;
println!("Clipboard monitor démarré (X11 XFIXES — zéro polling)"); println!("Clipboard monitor démarré (X11 XFIXES — zéro polling)");
let mut last_text: Option<String> = None; let mut last_text: Option<String> = None;
@ -68,14 +69,14 @@ pub fn start(db: Arc<Mutex<Database>>, mut clipboard: Clipboard) -> Result<(), B
if let Event::XfixesSelectionNotify(_) = event { if let Event::XfixesSelectionNotify(_) = event {
thread::sleep(Duration::from_millis(50)); thread::sleep(Duration::from_millis(50));
handle_clipboard_event(&mut clipboard, &db, &mut last_text, &mut last_image_hash); handle_clipboard_event(&mut clipboard, &tx, &mut last_text, &mut last_image_hash);
} }
} }
} }
fn handle_clipboard_event( fn handle_clipboard_event(
clipboard: &mut Clipboard, clipboard: &mut Clipboard,
db: &Arc<Mutex<Database>>, tx: &mpsc::Sender<ClipboardEntry>,
last_text: &mut Option<String>, last_text: &mut Option<String>,
last_image_hash: &mut Option<u64>, last_image_hash: &mut Option<u64>,
) { ) {
@ -89,13 +90,15 @@ fn handle_clipboard_event(
*last_image_hash = None; *last_image_hash = None;
println!("Clipboard update (texte)"); println!("Clipboard update (texte)");
spawn_db_write( if tx
Arc::clone(db), .send(ClipboardEntry {
ClipboardEntry {
content: ClipboardData::Text(text), content: ClipboardData::Text(text),
timestamp: SystemTime::now(), timestamp: SystemTime::now(),
}, })
); .is_err()
{
eprintln!("X11 : writer thread disparu");
}
} }
Err(_) => { Err(_) => {
@ -124,29 +127,20 @@ fn handle_clipboard_event(
*last_text = None; *last_text = None;
println!("Clipboard update (image)"); println!("Clipboard update (image)");
spawn_db_write( if tx
Arc::clone(db), .send(ClipboardEntry {
ClipboardEntry { content: ClipboardData::Image(Image {
content: ClipboardData::Image(crate::models::Image {
raw_pixels: Some(img_data.bytes.into_owned()), raw_pixels: Some(img_data.bytes.into_owned()),
width: img_data.width as u32, width: img_data.width as u32,
height: img_data.height as u32, height: img_data.height as u32,
id: Uuid::new_v4(), id: Uuid::new_v4(),
}), }),
timestamp: SystemTime::now(), timestamp: SystemTime::now(),
}, })
); .is_err()
{
eprintln!("X11 : writer thread disparu");
}
} }
} }
} }
fn spawn_db_write(db: Arc<Mutex<Database>>, entry: ClipboardEntry) {
thread::spawn(move || {
let lock = db.lock().unwrap();
if let Err(e) = lock.append(entry) {
eprintln!("SQLite write error: {e}");
} else {
println!("SQLite updated!");
}
});
}

View File

@ -3,19 +3,22 @@ use crate::ipc::{self, HistoryItem};
use chrono::{Local, NaiveDate, TimeZone}; use chrono::{Local, NaiveDate, TimeZone};
use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2}; use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
use image::DynamicImage; use image::DynamicImage;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::ListState; use ratatui::widgets::ListState;
use ratatui_image::{picker::Picker, protocol}; use ratatui_image::{picker::Picker, protocol};
use regex::Regex; use regex::Regex;
use std::collections::{HashMap, VecDeque}; use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use syntect::highlighting::ThemeSet; use syntect::easy::HighlightLines;
use syntect::highlighting::{FontStyle as SynFontStyle, ThemeSet};
use syntect::parsing::SyntaxSet; use syntect::parsing::SyntaxSet;
use syntect::util::LinesWithEndings;
const PREVIEW_MAX_WIDTH: u32 = 1280; const PREVIEW_MAX_WIDTH: u32 = 1280;
const PREVIEW_MAX_HEIGHT: u32 = 720; const PREVIEW_MAX_HEIGHT: u32 = 720;
const IMAGE_CACHE_MAX: usize = 8; const IMAGE_CACHE_MAX: usize = 8;
const PAGE_SIZE: usize = 50; const PAGE_SIZE: usize = 50;
#[derive(PartialEq, Clone)] #[derive(PartialEq, Clone)]
@ -56,7 +59,9 @@ pub struct App {
pub type_filter: TypeFilter, pub type_filter: TypeFilter,
pub loaded_count: usize, pub loaded_count: usize,
pub has_more: bool, pub has_more: bool,
image_cache: HashMap<String, DynamicImage>, pub preview_highlighted: Option<Vec<Line<'static>>>,
pub preview_lang: Option<String>,
image_cache: HashMap<String, Arc<DynamicImage>>,
image_cache_order: VecDeque<String>, image_cache_order: VecDeque<String>,
} }
@ -85,6 +90,53 @@ impl TypeFilter {
} }
} }
fn syn_color(c: syntect::highlighting::Color) -> Color {
Color::Rgb(c.r, c.g, c.b)
}
pub fn highlight_code(
content: &str,
syntax_set: &SyntaxSet,
theme_set: &ThemeSet,
) -> Vec<Line<'static>> {
let theme = &theme_set.themes["base16-ocean.dark"];
let syntax = syntax_set
.find_syntax_by_first_line(content)
.unwrap_or_else(|| syntax_set.find_syntax_plain_text());
let mut h = HighlightLines::new(syntax, theme);
let mut lines = Vec::new();
for (no, line) in LinesWithEndings::from(content).enumerate() {
let ranges = h.highlight_line(line, syntax_set).unwrap_or_default();
let mut spans = vec![Span::styled(
format!("{:>4}", no + 1),
Style::default().fg(Color::Rgb(80, 80, 100)),
)];
for (style, text) in &ranges {
let mut s = Style::default().fg(syn_color(style.foreground));
if style.font_style.contains(SynFontStyle::BOLD) {
s = s.add_modifier(Modifier::BOLD);
}
if style.font_style.contains(SynFontStyle::ITALIC) {
s = s.add_modifier(Modifier::ITALIC);
}
spans.push(Span::styled(text.trim_end_matches('\n').to_string(), s));
}
lines.push(Line::from(spans));
}
lines
}
pub fn detect_lang(content: &str, syntax_set: &SyntaxSet) -> Option<String> {
let s = syntax_set.find_syntax_by_first_line(content)?;
if s.name == "Plain Text" {
None
} else {
Some(s.name.clone())
}
}
impl App { impl App {
pub fn new() -> Self { pub fn new() -> Self {
let items = ipc::fetch_history(PAGE_SIZE).unwrap_or_default(); let items = ipc::fetch_history(PAGE_SIZE).unwrap_or_default();
@ -94,9 +146,19 @@ impl App {
let picker = Picker::from_query_stdio().unwrap_or_else(|_| Picker::halfblocks()); let picker = Picker::from_query_stdio().unwrap_or_else(|_| Picker::halfblocks());
let salt = directories::ProjectDirs::from("com", "zefad", "rklipd") let salt = match directories::ProjectDirs::from("com", "zefad", "rklipd") {
.and_then(|d| Crypto::load_or_create_salt(d.data_dir()).ok()) Some(dirs) => match Crypto::load_or_create_salt(dirs.data_dir()) {
.unwrap_or_else(|| vec![0u8; 32]); Ok(s) => s,
Err(e) => {
eprintln!("Erreur sel cryptographique : {e}");
vec![0u8; 32]
}
},
None => {
eprintln!("Impossible de déterminer le répertoire de données");
vec![0u8; 32]
}
};
let mut app = Self { let mut app = Self {
mode: Mode::Normal, mode: Mode::Normal,
@ -120,6 +182,8 @@ impl App {
type_filter: TypeFilter::All, type_filter: TypeFilter::All,
loaded_count: PAGE_SIZE, loaded_count: PAGE_SIZE,
has_more, has_more,
preview_highlighted: None,
preview_lang: None,
image_cache: HashMap::new(), image_cache: HashMap::new(),
image_cache_order: VecDeque::new(), image_cache_order: VecDeque::new(),
}; };
@ -169,29 +233,34 @@ impl App {
&mut self, &mut self,
filename: &str, filename: &str,
base_dir: &std::path::Path, base_dir: &std::path::Path,
) -> Option<DynamicImage> { ) -> Option<Arc<DynamicImage>> {
if !self.image_cache.contains_key(filename) { if self.image_cache.contains_key(filename) {
let path = base_dir.join("images").join(filename); self.image_cache_order.retain(|k| k != filename);
if !path.exists() {
return None;
}
let img = image::open(&path).ok()?;
let img = if img.width() > PREVIEW_MAX_WIDTH || img.height() > PREVIEW_MAX_HEIGHT {
img.thumbnail(PREVIEW_MAX_WIDTH, PREVIEW_MAX_HEIGHT)
} else {
img
};
if self.image_cache.len() >= IMAGE_CACHE_MAX {
if let Some(oldest) = self.image_cache_order.pop_front() {
self.image_cache.remove(&oldest);
}
}
self.image_cache_order.push_back(filename.to_string()); self.image_cache_order.push_back(filename.to_string());
self.image_cache.insert(filename.to_string(), img); return self.image_cache.get(filename).cloned();
} }
self.image_cache.get(filename).cloned() let path = base_dir.join("images").join(filename);
if !path.exists() {
return None;
}
let img = image::open(&path).ok()?;
let img = if img.width() > PREVIEW_MAX_WIDTH || img.height() > PREVIEW_MAX_HEIGHT {
img.thumbnail(PREVIEW_MAX_WIDTH, PREVIEW_MAX_HEIGHT)
} else {
img
};
if self.image_cache.len() >= IMAGE_CACHE_MAX {
if let Some(oldest) = self.image_cache_order.pop_front() {
self.image_cache.remove(&oldest);
}
}
let arc = Arc::new(img);
self.image_cache_order.push_back(filename.to_string());
self.image_cache
.insert(filename.to_string(), Arc::clone(&arc));
Some(arc)
} }
pub fn format_timestamp(ts_ms: i64) -> String { pub fn format_timestamp(ts_ms: i64) -> String {
@ -238,7 +307,15 @@ impl App {
return false; return false;
} }
} }
true match self.type_filter {
TypeFilter::All => true,
TypeFilter::Text => {
!item.content.ends_with(".jpg") && !item.content.ends_with(".png")
}
TypeFilter::Image => {
item.content.ends_with(".jpg") || item.content.ends_with(".png")
}
}
}) })
.cloned() .cloned()
.collect(); .collect();
@ -289,12 +366,6 @@ impl App {
matched.into_iter().map(|(_, i)| i).collect() matched.into_iter().map(|(_, i)| i).collect()
}; };
self.filtered_items.retain(|item| match self.type_filter {
TypeFilter::All => true,
TypeFilter::Text => !item.content.ends_with(".jpg") && !item.content.ends_with(".png"),
TypeFilter::Image => item.content.ends_with(".jpg") || item.content.ends_with(".png"),
});
self.list_state.select(if self.filtered_items.is_empty() { self.list_state.select(if self.filtered_items.is_empty() {
None None
} else { } else {
@ -313,16 +384,12 @@ impl App {
if current >= last { if current >= last {
if self.try_load_more() { if self.try_load_more() {
// try_load_more restaure la sélection sur le même item ;
// on peut maintenant avancer d'un cran
let current = self.list_state.selected().unwrap_or(0); let current = self.list_state.selected().unwrap_or(0);
if current + 1 < self.filtered_items.len() { if current + 1 < self.filtered_items.len() {
self.list_state.select(Some(current + 1)); self.list_state.select(Some(current + 1));
self.update_preview(); self.update_preview();
} }
// Sinon (le filtre actif masque les nouveaux items) : on reste
} else { } else {
// Fin réelle — wrap vers le haut
self.list_state.select(Some(0)); self.list_state.select(Some(0));
self.update_preview(); self.update_preview();
} }
@ -367,13 +434,13 @@ impl App {
self.list_state.select(new_sel); self.list_state.select(new_sel);
} }
} }
self.last_selected_index = None;
self.update_preview(); self.update_preview();
} }
pub fn undo_delete(&mut self) { pub fn undo_delete(&mut self) {
if let Some(item) = self.undo_stack.pop() { if let Some(item) = self.undo_stack.pop() {
ipc::add_entry(item.content.clone()); ipc::add_entry(item.content.clone());
// Re-sync depuis le daemon pour avoir l'ordre chronologique correct
if let Some(new_items) = ipc::fetch_history(self.loaded_count) { if let Some(new_items) = ipc::fetch_history(self.loaded_count) {
self.has_more = new_items.len() == self.loaded_count; self.has_more = new_items.len() == self.loaded_count;
self.all_items = new_items; self.all_items = new_items;
@ -545,6 +612,8 @@ impl App {
self.current_image = None; self.current_image = None;
self.image_cache.clear(); self.image_cache.clear();
self.image_cache_order.clear(); self.image_cache_order.clear();
self.preview_highlighted = None;
self.preview_lang = None;
self.loaded_count = PAGE_SIZE; self.loaded_count = PAGE_SIZE;
self.has_more = false; self.has_more = false;
self.set_status("Historique effacé".into()); self.set_status("Historique effacé".into());
@ -561,6 +630,8 @@ impl App {
self.last_selected_index = idx; self.last_selected_index = idx;
self.current_image = None; self.current_image = None;
self.preview_scroll = 0; self.preview_scroll = 0;
self.preview_highlighted = None;
self.preview_lang = None;
let content = match self.get_selected_item().map(|i| i.content.clone()) { let content = match self.get_selected_item().map(|i| i.content.clone()) {
Some(c) => c, Some(c) => c,
@ -569,12 +640,15 @@ impl App {
if content.ends_with(".jpg") || content.ends_with(".png") { if content.ends_with(".jpg") || content.ends_with(".png") {
if let Some(dirs) = directories::ProjectDirs::from("com", "zefad", "rklipd") { if let Some(dirs) = directories::ProjectDirs::from("com", "zefad", "rklipd") {
if let Some(img) = self.get_cached_image(&content, dirs.data_dir()) { if let Some(arc_img) = self.get_cached_image(&content, dirs.data_dir()) {
// new_resize_protocol attend un DynamicImage — on clone depuis le cache let img = (*arc_img).clone();
// (l'image est déjà redimensionnée ≤ 1280×720, clone = ~3,5 Mo max)
self.current_image = Some(self.picker.new_resize_protocol(img)); self.current_image = Some(self.picker.new_resize_protocol(img));
} }
} }
} else if !Crypto::is_any_encrypted(&content) {
self.preview_lang = detect_lang(&content, &self.syntax_set);
self.preview_highlighted =
Some(highlight_code(&content, &self.syntax_set, &self.theme_set));
} }
} }
@ -596,7 +670,6 @@ impl App {
return; return;
}; };
// Mise à jour du flag has_more lors de chaque sync
self.has_more = new.len() == self.loaded_count; self.has_more = new.len() == self.loaded_count;
let changed = self.all_items.len() != new.len() let changed = self.all_items.len() != new.len()

View File

@ -34,6 +34,14 @@ impl Crypto {
if bytes.len() == SALT_LEN { if bytes.len() == SALT_LEN {
return Ok(bytes); return Ok(bytes);
} }
return Err(format!(
"Fichier sel corrompu ({} octets au lieu de {}). \
Supprimez {:?} manuellement si vous souhaitez réinitialiser le chiffrement.",
bytes.len(),
SALT_LEN,
path
)
.into());
} }
let mut salt = vec![0u8; SALT_LEN]; let mut salt = vec![0u8; SALT_LEN];
OsRng.fill_bytes(&mut salt); OsRng.fill_bytes(&mut salt);

View File

@ -63,8 +63,11 @@ pub fn set_clipboard(content: String) -> bool {
) )
} }
pub fn delete_entry(content: String) { pub fn delete_entry(content: String) -> bool {
let _ = send_request(&IpcRequest::DeleteEntry { content }); matches!(
send_request(&IpcRequest::DeleteEntry { content }),
Some(IpcResponse::Ok)
)
} }
pub fn update_entry(old_content: String, new_content: String) -> bool { pub fn update_entry(old_content: String, new_content: String) -> bool {

View File

@ -177,8 +177,14 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
Mode::ConfirmDelete => match key.code { Mode::ConfirmDelete => match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
if let Some(item) = app.get_selected_item() { if let Some(item) = app.get_selected_item() {
ipc::delete_entry(item.content.clone()); let content = item.content.clone();
app.delete_selected(); if ipc::delete_entry(content) {
app.delete_selected();
} else {
app.set_error(
"Erreur : daemon injoignable, entrée non supprimée".into(),
);
}
} }
app.mode = Mode::Normal; app.mode = Mode::Normal;
} }

View File

@ -1,4 +1,4 @@
use crate::app::{App, Mode}; use crate::app::{App, Mode, detect_lang, highlight_code};
use crate::crypto::Crypto; use crate::crypto::Crypto;
use ratatui::{ use ratatui::{
Frame, Frame,
@ -8,55 +8,6 @@ use ratatui::{
widgets::{Block, BorderType, Borders, List, ListItem, Padding, Paragraph}, widgets::{Block, BorderType, Borders, List, ListItem, Padding, Paragraph},
}; };
use ratatui_image::StatefulImage; use ratatui_image::StatefulImage;
use syntect::easy::HighlightLines;
use syntect::highlighting::FontStyle as SynFontStyle;
use syntect::util::LinesWithEndings;
fn syn_color(c: syntect::highlighting::Color) -> Color {
Color::Rgb(c.r, c.g, c.b)
}
fn highlight_code(content: &str, app: &App) -> Vec<Line<'static>> {
let ps = &app.syntax_set;
let ts = &app.theme_set;
let theme = &ts.themes["base16-ocean.dark"];
let syntax = ps
.find_syntax_by_first_line(content)
.unwrap_or_else(|| ps.find_syntax_plain_text());
let mut h = HighlightLines::new(syntax, theme);
let mut lines = Vec::new();
for (no, line) in LinesWithEndings::from(content).enumerate() {
let ranges = h.highlight_line(line, ps).unwrap_or_default();
let mut spans = vec![Span::styled(
format!("{:>4}", no + 1),
Style::default().fg(Color::Rgb(80, 80, 100)),
)];
for (style, text) in &ranges {
let mut s = Style::default().fg(syn_color(style.foreground));
if style.font_style.contains(SynFontStyle::BOLD) {
s = s.add_modifier(Modifier::BOLD);
}
if style.font_style.contains(SynFontStyle::ITALIC) {
s = s.add_modifier(Modifier::ITALIC);
}
spans.push(Span::styled(text.trim_end_matches('\n').to_string(), s));
}
lines.push(Line::from(spans));
}
lines
}
fn detect_lang(content: &str, app: &App) -> Option<String> {
let s = app.syntax_set.find_syntax_by_first_line(content)?;
if s.name == "Plain Text" {
None
} else {
Some(s.name.clone())
}
}
pub fn render(f: &mut Frame, app: &mut App) { pub fn render(f: &mut Frame, app: &mut App) {
let outer = Layout::default() let outer = Layout::default()
@ -103,7 +54,7 @@ pub fn render(f: &mut Frame, app: &mut App) {
let preview: String = item let preview: String = item
.content .content
.lines() .lines()
.next() .find(|l| !l.trim().is_empty())
.unwrap_or("") .unwrap_or("")
.chars() .chars()
.take(28) .take(28)
@ -139,12 +90,7 @@ pub fn render(f: &mut Frame, app: &mut App) {
let selected_content = app.get_selected_item().map(|i| i.content.clone()); let selected_content = app.get_selected_item().map(|i| i.content.clone());
let lang = selected_content let preview_title = match &app.preview_lang {
.as_deref()
.filter(|c| !Crypto::is_any_encrypted(c) && !c.ends_with(".jpg") && !c.ends_with(".png"))
.and_then(|c| detect_lang(c, app));
let preview_title = match &lang {
Some(l) => format!(" Prévisualisation — {} ", l), Some(l) => format!(" Prévisualisation — {} ", l),
None => " Prévisualisation ".to_string(), None => " Prévisualisation ".to_string(),
}; };
@ -165,20 +111,19 @@ pub fn render(f: &mut Frame, app: &mut App) {
let inner = preview_block.inner(panels[1]); let inner = preview_block.inner(panels[1]);
f.render_widget(preview_block, panels[1]); f.render_widget(preview_block, panels[1]);
if app.current_image.is_some() { let scroll = (app.preview_scroll, 0);
let state = app.current_image.as_mut().unwrap();
if let Some(state) = app.current_image.as_mut() {
f.render_stateful_widget(StatefulImage::default(), inner, state); f.render_stateful_widget(StatefulImage::default(), inner, state);
} else if let Some(content) = &selected_content { } else if let Some(content) = &selected_content {
let scroll = (app.preview_scroll, 0);
if Crypto::is_any_encrypted(content) { if Crypto::is_any_encrypted(content) {
f.render_widget( f.render_widget(
Paragraph::new("🔒 Contenu chiffré\n\nAppuyez sur [e] pour déchiffrer.") Paragraph::new("🔒 Contenu chiffré\n\nAppuyez sur [e] pour déchiffrer.")
.scroll(scroll), .scroll(scroll),
inner, inner,
); );
} else { } else if let Some(lines) = &app.preview_highlighted {
let lines = highlight_code(content, app); f.render_widget(Paragraph::new(lines.clone()).scroll(scroll), inner);
f.render_widget(Paragraph::new(lines).scroll(scroll), inner);
} }
} }
@ -190,20 +135,6 @@ pub fn render(f: &mut Frame, app: &mut App) {
Mode::PasswordInput => (" MOT DE PASSE ", Color::Magenta), Mode::PasswordInput => (" MOT DE PASSE ", Color::Magenta),
}; };
let extra = match &app.mode {
Mode::Search => format!(" /{}", app.input_buffer),
Mode::Command => format!(" :{}", app.input_buffer),
Mode::PasswordInput => format!(" {}", "".repeat(app.input_buffer.len())),
_ => String::new(),
};
// let msg_span = if let Some((msg, _)) = &app.error_message {
// Span::styled(format!(" ⚠ {msg}"), Style::default().fg(Color::Red))
// } else if let Some((msg, _)) = &app.status_message {
// Span::styled(format!(" ✓ {msg}"), Style::default().fg(Color::Green))
// } else {
// Span::raw(extra)
// };
let filter_hint = match app.type_filter { let filter_hint = match app.type_filter {
crate::app::TypeFilter::All => String::new(), crate::app::TypeFilter::All => String::new(),
f => format!(" [{}]", f.label()), f => format!(" [{}]", f.label()),
@ -216,7 +147,6 @@ pub fn render(f: &mut Frame, app: &mut App) {
} else { } else {
let extra = match &app.mode { let extra = match &app.mode {
Mode::Search => { Mode::Search => {
// Indicateur visuel du mode de recherche actif (fuzzy vs regexp)
let mode_hint = if app.input_buffer.trim_start().starts_with('/') { let mode_hint = if app.input_buffer.trim_start().starts_with('/') {
"re" "re"
} else { } else {