add fav + opti

This commit is contained in:
2026-05-21 10:26:49 +02:00
parent 4f18a72785
commit 72ad88e888
12 changed files with 788 additions and 362 deletions

View File

@ -5,6 +5,7 @@ use image::{ExtendedColorType, ImageEncoder};
use rusqlite::Connection; use rusqlite::Connection;
use std::error::Error; use std::error::Error;
use std::fs; use std::fs;
use std::io::Cursor;
use std::path::Path; use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use uuid::Uuid; use uuid::Uuid;
@ -29,25 +30,44 @@ impl Database {
PRAGMA foreign_keys=ON;", PRAGMA foreign_keys=ON;",
)?; )?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL);
INSERT OR IGNORE INTO schema_version (version)
SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM schema_version);",
)?;
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS history ( "CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL, type TEXT NOT NULL,
content TEXT NOT NULL, content TEXT NOT NULL,
timestamp INTEGER NOT NULL timestamp INTEGER NOT NULL,
pinned INTEGER NOT NULL DEFAULT 0
)", )",
[], [],
)?; )?;
let version: i64 = conn
.query_row("SELECT version FROM schema_version", [], |r| r.get(0))
.unwrap_or(1);
if version < 2 {
let col_exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('history') WHERE name='pinned'",
[],
|r| r.get::<_, i64>(0),
)
.unwrap_or(0)
> 0;
if !col_exists {
conn.execute_batch(
"ALTER TABLE history ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0;",
)?;
}
conn.execute("UPDATE schema_version SET version = 2", [])?;
println!("DB migrée → schema v2 (colonne `pinned`)");
}
conn.execute_batch( conn.execute_batch(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_history_content ON history(content); "CREATE UNIQUE INDEX IF NOT EXISTS idx_history_content ON history(content);
CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp DESC);", CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_history_pinned ON history(pinned);",
)?; )?;
conn.execute_batch( conn.execute_batch(
@ -85,25 +105,25 @@ impl Database {
.flat_map(|rgba| [rgba[0], rgba[1], rgba[2]]) .flat_map(|rgba| [rgba[0], rgba[1], rgba[2]])
.collect(); .collect();
let mut jpeg_buf = Vec::new(); let mut buf = Vec::new();
JpegEncoder::new_with_quality(&mut jpeg_buf, 70).write_image( JpegEncoder::new_with_quality(Cursor::new(&mut buf), 70).write_image(
&rgb, &rgb,
img.width, img.width,
img.height, img.height,
ExtendedColorType::Rgb8, ExtendedColorType::Rgb8,
)?; )?;
if jpeg_buf.len() > self.max_entry_size_bytes { if buf.len() > self.max_entry_size_bytes {
eprintln!( eprintln!(
"Image rejetée dans DB : JPEG {} Ko > limite {} Ko", "Image rejetée : JPEG {} Ko > limite {} Ko",
jpeg_buf.len() / 1024, buf.len() / 1024,
self.max_entry_size_bytes / 1024 self.max_entry_size_bytes / 1024
); );
return Ok(()); return Ok(());
} }
let path = img.file_path(&self.dir_path); let path = img.file_path(&self.dir_path);
fs::write(&path, &jpeg_buf)?; fs::write(&path, &buf)?;
} }
None => return Ok(()), None => return Ok(()),
} }
@ -112,7 +132,13 @@ impl Database {
}; };
self.conn.execute( self.conn.execute(
"INSERT OR REPLACE INTO history (type, content, timestamp) VALUES (?1, ?2, ?3)", "INSERT OR REPLACE INTO history (type, content, timestamp, pinned)
VALUES (?1, ?2, ?3,
COALESCE(
(SELECT pinned FROM history WHERE content = ?2),
0
)
)",
(kind, &content, ts), (kind, &content, ts),
)?; )?;
@ -130,10 +156,11 @@ impl Database {
let image_files: Vec<String> = { let image_files: Vec<String> = {
let mut stmt = tx.prepare( let mut stmt = tx.prepare(
"SELECT content FROM history "SELECT content FROM history
WHERE type = 'image' WHERE type = 'image' AND pinned = 0
AND id NOT IN ( AND id NOT IN (
SELECT id FROM history ORDER BY timestamp DESC LIMIT ?1 SELECT id FROM history WHERE pinned = 0
)", ORDER BY timestamp DESC LIMIT ?1
)",
)?; )?;
stmt.query_map([self.max_entries as i64], |row| row.get(0))? stmt.query_map([self.max_entries as i64], |row| row.get(0))?
.filter_map(|r| r.ok()) .filter_map(|r| r.ok())
@ -141,8 +168,11 @@ impl Database {
}; };
tx.execute( tx.execute(
"DELETE FROM history WHERE id NOT IN ( "DELETE FROM history
SELECT id FROM history ORDER BY timestamp DESC LIMIT ?1 WHERE pinned = 0
AND id NOT IN (
SELECT id FROM history WHERE pinned = 0
ORDER BY timestamp DESC LIMIT ?1
)", )",
[self.max_entries as i64], [self.max_entries as i64],
)?; )?;
@ -163,7 +193,10 @@ impl Database {
pub fn read_history(&self, limit: usize) -> Result<Vec<ClipboardEntry>, Box<dyn Error>> { pub fn read_history(&self, limit: usize) -> Result<Vec<ClipboardEntry>, Box<dyn Error>> {
let mut stmt = self.conn.prepare( let mut stmt = self.conn.prepare(
"SELECT type, content, timestamp FROM history ORDER BY timestamp DESC LIMIT ?1", "SELECT type, content, timestamp, pinned
FROM history
ORDER BY pinned DESC, timestamp DESC
LIMIT ?1",
)?; )?;
let rows = stmt.query_map([limit as i64], |row| { let rows = stmt.query_map([limit as i64], |row| {
@ -171,12 +204,13 @@ impl Database {
row.get::<_, String>(0)?, row.get::<_, String>(0)?,
row.get::<_, String>(1)?, row.get::<_, String>(1)?,
row.get::<_, i64>(2)?, row.get::<_, i64>(2)?,
row.get::<_, bool>(3)?,
)) ))
})?; })?;
let mut entries = Vec::new(); let mut entries = Vec::new();
for row in rows { for row in rows {
let (ty, content, ts_ms) = row?; let (ty, content, ts_ms, pinned) = row?;
let timestamp = UNIX_EPOCH + Duration::from_millis(ts_ms as u64); let timestamp = UNIX_EPOCH + Duration::from_millis(ts_ms as u64);
let data = if ty == "text" { let data = if ty == "text" {
ClipboardData::Text(content) ClipboardData::Text(content)
@ -192,11 +226,23 @@ impl Database {
entries.push(ClipboardEntry { entries.push(ClipboardEntry {
content: data, content: data,
timestamp, timestamp,
pinned,
}); });
} }
Ok(entries) Ok(entries)
} }
pub fn set_pin(&self, content: &str, pinned: bool) -> Result<(), Box<dyn Error>> {
let rows = self.conn.execute(
"UPDATE history SET pinned = ?1 WHERE content = ?2",
(pinned as i32, content),
)?;
if rows == 0 {
return Err(format!("Entrée introuvable pour pin : {content}").into());
}
Ok(())
}
pub fn delete_entry_by_content(&self, content: &str) -> Result<(), Box<dyn Error>> { pub fn delete_entry_by_content(&self, content: &str) -> Result<(), Box<dyn Error>> {
self.conn self.conn
.execute("DELETE FROM history WHERE content = ?1", [content])?; .execute("DELETE FROM history WHERE content = ?1", [content])?;
@ -221,14 +267,19 @@ impl Database {
let tx = self.conn.unchecked_transaction()?; let tx = self.conn.unchecked_transaction()?;
let image_files: Vec<String> = { let image_files: Vec<String> = {
let mut stmt = let mut stmt = tx.prepare(
tx.prepare("SELECT content FROM history WHERE type = 'image' AND timestamp < ?1")?; "SELECT content FROM history
WHERE type = 'image' AND pinned = 0 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 = tx.execute("DELETE FROM history WHERE timestamp < ?1", [cutoff_ms])?; let count = tx.execute(
"DELETE FROM history WHERE timestamp < ?1 AND pinned = 0",
[cutoff_ms],
)?;
tx.commit()?; tx.commit()?;

View File

@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::os::unix::net::UnixListener; use std::os::unix::net::UnixListener;
use std::path::Path; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
@ -16,6 +16,8 @@ const IPC_MAX_REQUEST_BYTES: usize = 4 * 1024 * 1024;
pub struct HistoryItem { pub struct HistoryItem {
pub content: String, pub content: String,
pub timestamp: i64, pub timestamp: i64,
#[serde(default)]
pub pinned: bool,
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
@ -36,6 +38,10 @@ pub enum IpcRequest {
AddEntry { AddEntry {
content: String, content: String,
}, },
PinEntry {
content: String,
pinned: bool,
},
ClearHistory, ClearHistory,
} }
@ -52,7 +58,12 @@ fn reply(stream: &mut std::os::unix::net::UnixStream, resp: IpcResponse) {
} }
} }
pub fn start_server(db: Arc<Mutex<Database>>, crypto: Arc<Crypto>, socket_path: &Path) { pub fn start_server(
db: Arc<Mutex<Database>>,
crypto: Arc<Crypto>,
socket_path: &Path,
data_dir: Arc<PathBuf>,
) {
if socket_path.exists() { if socket_path.exists() {
let _ = fs::remove_file(socket_path); let _ = fs::remove_file(socket_path);
} }
@ -84,188 +95,194 @@ pub fn start_server(db: Arc<Mutex<Database>>, crypto: Arc<Crypto>, socket_path:
let db_clone = Arc::clone(&db); let db_clone = Arc::clone(&db);
let crypto_clone = Arc::clone(&crypto); let crypto_clone = Arc::clone(&crypto);
let data_dir_clone = Arc::clone(&data_dir);
std::thread::spawn(move || { std::thread::spawn(move || {
let mut buf = Vec::new(); handle_connection(&mut stream, db_clone, crypto_clone, data_dir_clone);
let mut tmp = [0u8; 4096];
loop {
match stream.read(&mut tmp) {
Ok(0) => break,
Ok(n) => {
buf.extend_from_slice(&tmp[..n]);
if buf.len() > IPC_MAX_REQUEST_BYTES {
eprintln!("IPC : requête trop grande, abandon");
return;
}
}
Err(e)
if e.kind() == std::io::ErrorKind::WouldBlock
|| e.kind() == std::io::ErrorKind::TimedOut =>
{
eprintln!("IPC : timeout de lecture");
return;
}
Err(e) => {
eprintln!("IPC read error : {e}");
return;
}
}
}
let buf_str = match String::from_utf8(buf) {
Ok(s) => s,
Err(e) => {
eprintln!("IPC : requête non-UTF8 : {e}");
return;
}
};
let req = match serde_json::from_str::<IpcRequest>(&buf_str) {
Ok(r) => r,
Err(e) => {
eprintln!("IPC parse error : {e}");
return;
}
};
match req {
IpcRequest::GetHistory { limit } => {
// Limite à 1000 pour éviter les requêtes abusives
let limit = limit.min(1000);
let lock = db_clone.lock().unwrap();
let history = lock.read_history(limit).unwrap_or_default();
let items: Vec<HistoryItem> = history
.into_iter()
.map(|e| {
let content = match e.content {
ClipboardData::Text(t) => t,
ClipboardData::Image(img) => format!("{}.jpg", img.id),
};
let ts = e
.timestamp
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
as i64;
HistoryItem {
content,
timestamp: ts,
}
})
.collect();
reply(&mut stream, IpcResponse::History(items));
}
IpcRequest::SetClipboard { content } => {
let actual = if Crypto::is_legacy_encrypted(&content) {
crypto_clone.decrypt(&content).unwrap_or_else(|e| {
eprintln!("Impossible de déchiffrer l'entrée enc: : {e}");
content.clone()
})
} else if Crypto::is_password_encrypted(&content) {
reply(
&mut stream,
IpcResponse::Error(
"Entrée chiffrée par mot de passe : déchiffrez-la côté client avant de coller"
.to_string(),
),
);
return;
} else {
content
};
match arboard::Clipboard::new() {
Ok(mut cb) => {
if actual.ends_with(".jpg") || actual.ends_with(".png") {
if let Some(dirs) =
directories::ProjectDirs::from("com", "zefad", "rklipd")
{
let path = dirs.data_dir().join("images").join(&actual);
if let Ok(img) = image::open(&path) {
let rgba = img.into_rgba8();
let (w, h) =
(rgba.width() as usize, rgba.height() as usize);
let _ = cb.set_image(arboard::ImageData {
width: w,
height: h,
bytes: std::borrow::Cow::Owned(rgba.into_raw()),
});
reply(&mut stream, IpcResponse::Ok);
} else {
reply(
&mut stream,
IpcResponse::Error(format!(
"Image introuvable : {actual}"
)),
);
}
}
} else {
let _ = cb.set_text(actual);
reply(&mut stream, IpcResponse::Ok);
}
}
Err(e) => reply(&mut stream, IpcResponse::Error(e.to_string())),
}
}
IpcRequest::DeleteEntry { content } => {
{
let lock = db_clone.lock().unwrap();
let _ = lock.delete_entry_by_content(&content);
}
if !content.starts_with("enc:")
&& !content.starts_with("enc2:")
&& (content.ends_with(".jpg") || content.ends_with(".png"))
{
if let Some(dirs) =
directories::ProjectDirs::from("com", "zefad", "rklipd")
{
let p = dirs.data_dir().join("images").join(&content);
if p.exists() {
let _ = fs::remove_file(p);
}
}
}
reply(&mut stream, IpcResponse::Ok);
}
IpcRequest::UpdateEntry {
old_content,
new_content,
} => {
let lock = db_clone.lock().unwrap();
match lock.update_entry_content(&old_content, &new_content) {
Ok(_) => reply(&mut stream, IpcResponse::Ok),
Err(e) => reply(&mut stream, IpcResponse::Error(e.to_string())),
}
}
IpcRequest::AddEntry { content } => {
let entry = ClipboardEntry {
content: ClipboardData::Text(content),
timestamp: SystemTime::now(),
};
let lock = db_clone.lock().unwrap();
match lock.append(entry) {
Ok(_) => reply(&mut stream, IpcResponse::Ok),
Err(e) => reply(&mut stream, IpcResponse::Error(e.to_string())),
}
}
IpcRequest::ClearHistory => {
let lock = db_clone.lock().unwrap();
match lock.clear_history() {
Ok(_) => reply(&mut stream, IpcResponse::Ok),
Err(e) => reply(&mut stream, IpcResponse::Error(e.to_string())),
}
}
}
}); });
} }
Err(e) => eprintln!("Erreur connexion IPC : {e}"), Err(e) => eprintln!("Erreur connexion IPC : {e}"),
} }
} }
} }
fn handle_connection(
stream: &mut std::os::unix::net::UnixStream,
db: Arc<Mutex<Database>>,
crypto: Arc<Crypto>,
data_dir: Arc<PathBuf>,
) {
let mut buf = Vec::new();
let mut tmp = [0u8; 4096];
loop {
match stream.read(&mut tmp) {
Ok(0) => break,
Ok(n) => {
buf.extend_from_slice(&tmp[..n]);
if buf.len() > IPC_MAX_REQUEST_BYTES {
eprintln!("IPC : requête trop grande, abandon");
return;
}
}
Err(e)
if e.kind() == std::io::ErrorKind::WouldBlock
|| e.kind() == std::io::ErrorKind::TimedOut =>
{
eprintln!("IPC : timeout de lecture");
return;
}
Err(e) => {
eprintln!("IPC read error : {e}");
return;
}
}
}
let buf_str = match String::from_utf8(buf) {
Ok(s) => s,
Err(e) => {
eprintln!("IPC : requête non-UTF8 : {e}");
return;
}
};
let req = match serde_json::from_str::<IpcRequest>(&buf_str) {
Ok(r) => r,
Err(e) => {
eprintln!("IPC parse error : {e}");
return;
}
};
match req {
IpcRequest::GetHistory { limit } => {
let limit = limit.min(1000);
let lock = db.lock().unwrap_or_else(|p| p.into_inner());
let history = lock.read_history(limit).unwrap_or_default();
let items: Vec<HistoryItem> = history
.into_iter()
.map(|e| {
let content = match e.content {
ClipboardData::Text(t) => t,
ClipboardData::Image(img) => format!("{}.jpg", img.id),
};
let ts = e
.timestamp
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64;
HistoryItem {
content,
timestamp: ts,
pinned: e.pinned,
}
})
.collect();
reply(stream, IpcResponse::History(items));
}
IpcRequest::SetClipboard { content } => {
let actual = if Crypto::is_legacy_encrypted(&content) {
crypto.decrypt(&content).unwrap_or_else(|e| {
eprintln!("Impossible de déchiffrer l'entrée enc: : {e}");
content.clone()
})
} else if Crypto::is_password_encrypted(&content) {
reply(
stream,
IpcResponse::Error(
"Entrée chiffrée par mot de passe : déchiffrez côté client avant de coller"
.to_string(),
),
);
return;
} else {
content
};
match arboard::Clipboard::new() {
Ok(mut cb) => {
if actual.ends_with(".jpg") || actual.ends_with(".png") {
let path = data_dir.join("images").join(&actual);
if let Ok(img) = image::open(&path) {
let rgba = img.into_rgba8();
let (w, h) = (rgba.width() as usize, rgba.height() as usize);
let _ = cb.set_image(arboard::ImageData {
width: w,
height: h,
bytes: std::borrow::Cow::Owned(rgba.into_raw()),
});
reply(stream, IpcResponse::Ok);
} else {
reply(
stream,
IpcResponse::Error(format!("Image introuvable : {actual}")),
);
}
} else {
let _ = cb.set_text(actual);
reply(stream, IpcResponse::Ok);
}
}
Err(e) => reply(stream, IpcResponse::Error(e.to_string())),
}
}
IpcRequest::DeleteEntry { content } => {
{
let lock = db.lock().unwrap_or_else(|p| p.into_inner());
let _ = lock.delete_entry_by_content(&content);
}
if !Crypto::is_any_encrypted(&content)
&& (content.ends_with(".jpg") || content.ends_with(".png"))
{
let p = data_dir.join("images").join(&content);
if p.exists() {
let _ = fs::remove_file(p);
}
}
reply(stream, IpcResponse::Ok);
}
IpcRequest::UpdateEntry {
old_content,
new_content,
} => {
let lock = db.lock().unwrap_or_else(|p| p.into_inner());
match lock.update_entry_content(&old_content, &new_content) {
Ok(_) => reply(stream, IpcResponse::Ok),
Err(e) => reply(stream, IpcResponse::Error(e.to_string())),
}
}
IpcRequest::AddEntry { content } => {
let entry = ClipboardEntry {
content: ClipboardData::Text(content),
timestamp: SystemTime::now(),
pinned: false,
};
let lock = db.lock().unwrap_or_else(|p| p.into_inner());
match lock.append(entry) {
Ok(_) => reply(stream, IpcResponse::Ok),
Err(e) => reply(stream, IpcResponse::Error(e.to_string())),
}
}
IpcRequest::PinEntry { content, pinned } => {
let lock = db.lock().unwrap_or_else(|p| p.into_inner());
match lock.set_pin(&content, pinned) {
Ok(_) => reply(stream, IpcResponse::Ok),
Err(e) => reply(stream, IpcResponse::Error(e.to_string())),
}
}
IpcRequest::ClearHistory => {
let lock = db.lock().unwrap_or_else(|p| p.into_inner());
match lock.clear_history() {
Ok(_) => reply(stream, IpcResponse::Ok),
Err(e) => reply(stream, IpcResponse::Error(e.to_string())),
}
}
}
}

View File

@ -31,17 +31,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let proj_dirs = let proj_dirs =
ProjectDirs::from("com", "zefad", "rklipd").expect("Impossible d'ouvrir le répertoire"); ProjectDirs::from("com", "zefad", "rklipd").expect("Impossible d'ouvrir le répertoire");
let dir_path = proj_dirs.data_dir(); let dir_path = proj_dirs.data_dir().to_path_buf();
let dir_path_str = dir_path.to_str().expect("Chemin invalide").to_string(); let dir_path_str = dir_path.to_str().expect("Chemin invalide").to_string();
let db = Arc::new(Mutex::new(Database::init(&dir_path_str, &config)?)); let db = Arc::new(Mutex::new(Database::init(&dir_path_str, &config)?));
let crypto = Arc::new(Crypto::load_or_create(dir_path)?); let crypto = Arc::new(Crypto::load_or_create(&dir_path)?);
let socket_path = dir_path.join("rklip.sock"); let socket_path = dir_path.join("rklip.sock");
let db_for_ipc = Arc::clone(&db); let db_for_ipc = Arc::clone(&db);
let crypto_for_ipc = Arc::clone(&crypto); let crypto_for_ipc = Arc::clone(&crypto);
let data_dir = Arc::new(dir_path.clone());
std::thread::spawn(move || { std::thread::spawn(move || {
crate::ipc::start_server(db_for_ipc, crypto_for_ipc, &socket_path); crate::ipc::start_server(db_for_ipc, crypto_for_ipc, &socket_path, data_dir);
}); });
if let Some(days) = config.expiry_days { if let Some(days) = config.expiry_days {
@ -49,12 +50,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
std::thread::spawn(move || { std::thread::spawn(move || {
loop { loop {
{ {
let lock = db_for_expiry.lock().unwrap(); let lock = db_for_expiry.lock().unwrap_or_else(|p| p.into_inner());
match lock.delete_entries_older_than(days) { match lock.delete_entries_older_than(days) {
Ok(0) => {} Ok(0) => {}
Ok(n) => { Ok(n) => println!("Expiration : {n} entrée(s) > {days} jours supprimée(s)"),
println!("Expiration : {n} entrée(s) supprimée(s) (> {days} jours)")
}
Err(e) => eprintln!("Erreur expiration : {e}"), Err(e) => eprintln!("Erreur expiration : {e}"),
} }
} }

View File

@ -7,6 +7,7 @@ use uuid::Uuid;
pub struct ClipboardEntry { pub struct ClipboardEntry {
pub content: ClipboardData, pub content: ClipboardData,
pub timestamp: SystemTime, pub timestamp: SystemTime,
pub pinned: bool,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]

View File

@ -9,13 +9,10 @@ pub fn start(db: Arc<Mutex<Database>>, clipboard: Clipboard) -> Result<(), Box<d
std::thread::spawn(move || { std::thread::spawn(move || {
for entry in rx { for entry in rx {
let lock = match db.lock() { let lock = db.lock().unwrap_or_else(|poisoned| {
Ok(l) => l, eprintln!("Mutex DB empoisonné, récupération forcée");
Err(poisoned) => { poisoned.into_inner()
eprintln!("Mutex DB empoisonné, récupération forcée"); });
poisoned.into_inner()
}
};
if let Err(e) = lock.append(entry) { if let Err(e) = lock.append(entry) {
eprintln!("SQLite write error: {e}"); eprintln!("SQLite write error: {e}");
} else { } else {

View File

@ -45,6 +45,7 @@ pub fn start(
ClipboardEntry { ClipboardEntry {
content: ClipboardData::Text(text), content: ClipboardData::Text(text),
timestamp: SystemTime::now(), timestamp: SystemTime::now(),
pinned: false,
} }
} else { } else {
let hash = hash_bytes(data); let hash = hash_bytes(data);
@ -83,6 +84,7 @@ pub fn start(
id: Uuid::new_v4(), id: Uuid::new_v4(),
}), }),
timestamp: SystemTime::now(), timestamp: SystemTime::now(),
pinned: false,
} }
} }
Err(e) => { Err(e) => {

View File

@ -94,6 +94,7 @@ fn handle_clipboard_event(
.send(ClipboardEntry { .send(ClipboardEntry {
content: ClipboardData::Text(text), content: ClipboardData::Text(text),
timestamp: SystemTime::now(), timestamp: SystemTime::now(),
pinned: false,
}) })
.is_err() .is_err()
{ {
@ -137,6 +138,7 @@ fn handle_clipboard_event(
id: Uuid::new_v4(), id: Uuid::new_v4(),
}), }),
timestamp: SystemTime::now(), timestamp: SystemTime::now(),
pinned: false,
}) })
.is_err() .is_err()
{ {

View File

@ -9,7 +9,7 @@ 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::sync::{Arc, OnceLock};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use syntect::easy::HighlightLines; use syntect::easy::HighlightLines;
use syntect::highlighting::{FontStyle as SynFontStyle, ThemeSet}; use syntect::highlighting::{FontStyle as SynFontStyle, ThemeSet};
@ -28,6 +28,26 @@ pub fn is_image(s: &str) -> bool {
s.ends_with(".jpg") || s.ends_with(".png") s.ends_with(".jpg") || s.ends_with(".png")
} }
pub fn is_url_only(content: &str) -> bool {
let t = content.trim();
!t.contains('\n')
&& !t.contains(' ')
&& (t.starts_with("http://") || t.starts_with("https://"))
&& t.len() > 10
}
pub fn extract_url(content: &str) -> Option<String> {
static RE: OnceLock<Regex> = OnceLock::new();
let re = RE
.get_or_init(|| Regex::new(r#"https?://[^\s<>"'()\[\]{}]+"#).expect("URL regex invalide"));
re.find(content).map(|m| {
let url = m
.as_str()
.trim_end_matches(|c: char| matches!(c, '.' | ',' | ';' | ':' | '!' | '?'));
url.to_string()
})
}
#[derive(PartialEq, Clone)] #[derive(PartialEq, Clone)]
pub enum Mode { pub enum Mode {
Normal, Normal,
@ -35,6 +55,7 @@ pub enum Mode {
Search, Search,
ConfirmDelete, ConfirmDelete,
PasswordInput, PasswordInput,
Help,
} }
#[derive(Clone)] #[derive(Clone)]
@ -59,7 +80,6 @@ impl TypeFilter {
Self::Image => Self::All, Self::Image => Self::All,
} }
} }
pub fn label(self) -> &'static str { pub fn label(self) -> &'static str {
match self { match self {
Self::All => "Tous", Self::All => "Tous",
@ -102,6 +122,40 @@ fn syn_color(c: syntect::highlighting::Color) -> Color {
Color::Rgb(c.r, c.g, c.b) Color::Rgb(c.r, c.g, c.b)
} }
fn detect_syntax<'a>(
content: &str,
syntax_set: &'a SyntaxSet,
) -> &'a syntect::parsing::SyntaxReference {
if let Some(s) = syntax_set.find_syntax_by_first_line(content) {
if s.name != "Plain Text" {
return s;
}
}
for line in content.lines().take(3) {
if let Some(word) = line.split_whitespace().last() {
if let Some(ext) = word.rsplit('.').next() {
if (1..=6).contains(&ext.len()) && ext.chars().all(|c| c.is_ascii_alphanumeric()) {
if let Some(s) = syntax_set.find_syntax_by_extension(ext) {
if s.name != "Plain Text" {
return s;
}
}
}
}
}
}
syntax_set.find_syntax_plain_text()
}
pub fn detect_lang(content: &str, syntax_set: &SyntaxSet) -> Option<String> {
let s = detect_syntax(content, syntax_set);
if s.name == "Plain Text" {
None
} else {
Some(s.name.clone())
}
}
pub fn highlight_code( pub fn highlight_code(
content: &str, content: &str,
syntax_set: &SyntaxSet, syntax_set: &SyntaxSet,
@ -112,6 +166,8 @@ pub fn highlight_code(
let mut h = HighlightLines::new(syntax, theme); let mut h = HighlightLines::new(syntax, theme);
let mut lines = Vec::new(); let mut lines = Vec::new();
let total_lines = content.lines().count();
for (no, line) in LinesWithEndings::from(content) for (no, line) in LinesWithEndings::from(content)
.enumerate() .enumerate()
.take(MAX_HIGHLIGHT_LINES) .take(MAX_HIGHLIGHT_LINES)
@ -134,7 +190,6 @@ pub fn highlight_code(
lines.push(Line::from(spans)); lines.push(Line::from(spans));
} }
let total_lines = content.lines().count();
if total_lines > MAX_HIGHLIGHT_LINES { if total_lines > MAX_HIGHLIGHT_LINES {
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled(
format!( format!(
@ -148,43 +203,6 @@ pub fn highlight_code(
lines lines
} }
fn detect_syntax<'a>(
content: &str,
syntax_set: &'a SyntaxSet,
) -> &'a syntect::parsing::SyntaxReference {
if let Some(s) = syntax_set.find_syntax_by_first_line(content) {
if s.name != "Plain Text" {
return s;
}
}
for line in content.lines().take(3) {
let trimmed = line.trim_start_matches(|c: char| !c.is_alphanumeric() && c != '.');
if let Some(word) = trimmed.split_whitespace().last() {
if let Some(ext) = word.rsplit('.').next() {
if ext.len() <= 6 && ext.chars().all(|c| c.is_ascii_alphanumeric()) {
if let Some(s) = syntax_set.find_syntax_by_extension(ext) {
if s.name != "Plain Text" {
return s;
}
}
}
}
}
}
syntax_set.find_syntax_plain_text()
}
pub fn detect_lang(content: &str, syntax_set: &SyntaxSet) -> Option<String> {
let s = detect_syntax(content, syntax_set);
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();
@ -244,24 +262,19 @@ impl App {
if !self.has_more { if !self.has_more {
return false; return false;
} }
let new_limit = self.loaded_count + PAGE_SIZE; let new_limit = self.loaded_count + PAGE_SIZE;
let Some(items) = ipc::fetch_history(new_limit) else { let Some(items) = ipc::fetch_history(new_limit) else {
return false; return false;
}; };
if items.len() <= self.all_items.len() { if items.len() <= self.all_items.len() {
self.has_more = false; self.has_more = false;
return false; return false;
} }
self.has_more = items.len() == new_limit; self.has_more = items.len() == new_limit;
self.loaded_count = new_limit; self.loaded_count = new_limit;
let selected_content = self.get_selected_item().map(|i| i.content.clone()); let selected_content = self.get_selected_item().map(|i| i.content.clone());
self.all_items = items; self.all_items = items;
self.update_search(); self.update_search();
if let Some(content) = selected_content { if let Some(content) = selected_content {
if let Some(pos) = self if let Some(pos) = self
.filtered_items .filtered_items
@ -273,7 +286,6 @@ impl App {
self.update_preview(); self.update_preview();
} }
} }
self.set_status(format!("{} entrées chargées", self.all_items.len())); self.set_status(format!("{} entrées chargées", self.all_items.len()));
true true
} }
@ -288,7 +300,6 @@ impl App {
self.image_cache_order.push_back(filename.to_string()); self.image_cache_order.push_back(filename.to_string());
return self.image_cache.get(filename).cloned(); return self.image_cache.get(filename).cloned();
} }
let path = base_dir.join("images").join(filename); let path = base_dir.join("images").join(filename);
if !path.exists() { if !path.exists() {
return None; return None;
@ -299,7 +310,6 @@ impl App {
} else { } else {
img img
}; };
if self.image_cache.len() >= IMAGE_CACHE_MAX { if self.image_cache.len() >= IMAGE_CACHE_MAX {
if let Some(oldest) = self.image_cache_order.pop_front() { if let Some(oldest) = self.image_cache_order.pop_front() {
self.image_cache.remove(&oldest); self.image_cache.remove(&oldest);
@ -318,9 +328,7 @@ impl App {
match Local.timestamp_opt(secs, nsecs) { match Local.timestamp_opt(secs, nsecs) {
chrono::LocalResult::Single(dt) => { chrono::LocalResult::Single(dt) => {
let today = Local::now().date_naive(); let today = Local::now().date_naive();
let entry_date = dt.date_naive(); let diff_days = (today - dt.date_naive()).num_days();
let diff_days = (today - entry_date).num_days();
if diff_days == 0 { if diff_days == 0 {
dt.format("%H:%M:%S").to_string() dt.format("%H:%M:%S").to_string()
} else if diff_days < 365 { } else if diff_days < 365 {
@ -405,9 +413,9 @@ impl App {
let mut matched: Vec<(i64, HistoryItem)> = base let mut matched: Vec<(i64, HistoryItem)> = base
.into_iter() .into_iter()
.filter_map(|item| { .filter_map(|item| {
matcher let score = matcher.fuzzy_match(&search_str(&item), &text_query)?;
.fuzzy_match(&search_str(&item), &text_query) let adjusted = score + if item.pinned { 1000 } else { 0 };
.map(|s| (s, item)) Some((adjusted, item))
}) })
.collect(); .collect();
matched.sort_by(|a, b| b.0.cmp(&a.0)); matched.sort_by(|a, b| b.0.cmp(&a.0));
@ -419,7 +427,6 @@ impl App {
} else { } else {
Some(0) Some(0)
}); });
self.update_preview(); self.update_preview();
} }
@ -429,7 +436,6 @@ impl App {
} }
let current = self.list_state.selected().unwrap_or(0); let current = self.list_state.selected().unwrap_or(0);
let last = self.filtered_items.len() - 1; let last = self.filtered_items.len() - 1;
if current >= last { if current >= last {
if self.try_load_more() { if self.try_load_more() {
let current = self.list_state.selected().unwrap_or(0); let current = self.list_state.selected().unwrap_or(0);
@ -508,6 +514,69 @@ impl App {
self.update_preview(); self.update_preview();
} }
pub fn toggle_pin(&mut self) {
let item = match self.get_selected_item() {
Some(i) => i.clone(),
None => return,
};
let new_pinned = !item.pinned;
if !ipc::pin_entry(item.content.clone(), new_pinned) {
self.set_error("Erreur pin : daemon injoignable".into());
return;
}
for e in self.all_items.iter_mut() {
if e.content == item.content {
e.pinned = new_pinned;
break;
}
}
self.all_items
.sort_by(|a, b| b.pinned.cmp(&a.pinned).then(b.timestamp.cmp(&a.timestamp)));
let sel_content = item.content.clone();
self.update_search();
if let Some(pos) = self
.filtered_items
.iter()
.position(|x| x.content == sel_content)
{
self.list_state.select(Some(pos));
self.last_selected_index = None;
self.update_preview();
}
self.set_status(if new_pinned {
"★ Épinglé".into()
} else {
"Désépinglé".into()
});
}
pub fn open_url_selected(&mut self) {
let content = match self.get_selected_item().map(|i| i.content.clone()) {
Some(c) => c,
None => return,
};
match extract_url(&content) {
Some(url) => match std::process::Command::new("xdg-open").arg(&url).spawn() {
Ok(_) => {
let preview = if url.len() > 48 {
format!("{}", &url[..48])
} else {
url.clone()
};
self.set_status(format!("Ouverture : {preview}"));
}
Err(e) => self.set_error(format!("xdg-open : {e}")),
},
None => self.set_error("Aucune URL trouvée dans cette entrée".into()),
}
}
pub fn toggle_encrypt(&mut self) { pub fn toggle_encrypt(&mut self) {
let content = match self.get_selected_item() { let content = match self.get_selected_item() {
Some(i) => i.content.clone(), Some(i) => i.content.clone(),
@ -703,7 +772,6 @@ impl App {
pub fn scroll_preview_down(&mut self) { pub fn scroll_preview_down(&mut self) {
self.preview_scroll = self.preview_scroll.saturating_add(3); self.preview_scroll = self.preview_scroll.saturating_add(3);
} }
pub fn scroll_preview_up(&mut self) { pub fn scroll_preview_up(&mut self) {
self.preview_scroll = self.preview_scroll.saturating_sub(3); self.preview_scroll = self.preview_scroll.saturating_sub(3);
} }
@ -731,13 +799,12 @@ impl App {
.all_items .all_items
.iter() .iter()
.zip(&new) .zip(&new)
.any(|(a, b)| a.content != b.content); .any(|(a, b)| a.content != b.content || a.pinned != b.pinned);
if changed { if changed {
let selected_content = self.get_selected_item().map(|i| i.content.clone()); let selected_content = self.get_selected_item().map(|i| i.content.clone());
self.all_items = new; self.all_items = new;
self.update_search(); self.update_search();
if let Some(content) = selected_content { if let Some(content) = selected_content {
if let Some(pos) = self if let Some(pos) = self
.filtered_items .filtered_items
@ -755,7 +822,6 @@ impl App {
pub fn set_error(&mut self, msg: String) { pub fn set_error(&mut self, msg: String) {
self.error_message = Some((msg, Instant::now())); self.error_message = Some((msg, Instant::now()));
} }
pub fn set_status(&mut self, msg: String) { pub fn set_status(&mut self, msg: String) {
self.status_message = Some((msg, Instant::now())); self.status_message = Some((msg, Instant::now()));
} }

View File

@ -6,6 +6,8 @@ use std::os::unix::net::UnixStream;
pub struct HistoryItem { pub struct HistoryItem {
pub content: String, pub content: String,
pub timestamp: i64, pub timestamp: i64,
#[serde(default)]
pub pinned: bool,
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
@ -26,6 +28,10 @@ pub enum IpcRequest {
AddEntry { AddEntry {
content: String, content: String,
}, },
PinEntry {
content: String,
pinned: bool,
},
ClearHistory, ClearHistory,
} }
@ -84,6 +90,13 @@ pub fn add_entry(content: String) {
let _ = send_request(&IpcRequest::AddEntry { content }); let _ = send_request(&IpcRequest::AddEntry { content });
} }
pub fn pin_entry(content: String, pinned: bool) -> bool {
matches!(
send_request(&IpcRequest::PinEntry { content, pinned }),
Some(IpcResponse::Ok)
)
}
pub fn clear_history() -> bool { pub fn clear_history() -> bool {
matches!( matches!(
send_request(&IpcRequest::ClearHistory), send_request(&IpcRequest::ClearHistory),

View File

@ -43,7 +43,8 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
if event::poll(Duration::from_millis(250))? { if event::poll(Duration::from_millis(250))? {
if let Event::Key(key) = event::read()? { if let Event::Key(key) = event::read()? {
if key.modifiers.contains(KeyModifiers::CONTROL) { // Ctrl+j / Ctrl+k : scroll prévisualisation (tous modes sauf aide)
if key.modifiers.contains(KeyModifiers::CONTROL) && app.mode != Mode::Help {
match key.code { match key.code {
KeyCode::Char('j') => { KeyCode::Char('j') => {
app.scroll_preview_down(); app.scroll_preview_down();
@ -58,75 +59,18 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
} }
match app.mode { match app.mode {
Mode::Normal => match key.code { // ----------------------------------------------------------
KeyCode::Enter => app.paste_selected(), Mode::Help => {
KeyCode::Char('j') | KeyCode::Down => app.next(), // N'importe quelle touche ferme l'aide
KeyCode::Char('k') | KeyCode::Up => app.previous(), app.mode = Mode::Normal;
KeyCode::Char('G') => { }
if !app.filtered_items.is_empty() {
let l = app.filtered_items.len() - 1;
app.list_state.select(Some(l));
app.update_preview();
}
last_d = false;
last_g = false;
}
KeyCode::Char('g') => {
last_d = false;
if last_g {
if !app.filtered_items.is_empty() {
app.list_state.select(Some(0));
app.update_preview();
}
last_g = false;
} else {
last_g = true;
}
}
KeyCode::Char('d') => {
last_g = false;
if last_d {
app.mode = Mode::ConfirmDelete;
last_d = false;
} else {
last_d = true;
}
}
KeyCode::Char('u') => {
app.undo_delete();
last_d = false;
last_g = false;
}
KeyCode::Char('e') => {
app.toggle_encrypt();
last_d = false;
last_g = false;
}
KeyCode::Char('t') => {
app.cycle_type_filter();
last_d = false;
last_g = false;
}
KeyCode::Char('/') => {
app.mode = Mode::Search;
app.input_buffer.clear();
app.update_search();
last_d = false;
last_g = false;
}
KeyCode::Char(':') => {
app.mode = Mode::Command;
app.input_buffer.clear();
last_d = false;
last_g = false;
}
KeyCode::Char('q') => app.should_quit = true,
_ => {
last_d = false;
last_g = false;
}
},
// ----------------------------------------------------------
Mode::Normal => {
last_d = handle_normal(app, key.code, last_d, &mut last_g);
}
// ----------------------------------------------------------
Mode::Search => match key.code { Mode::Search => match key.code {
KeyCode::Esc => { KeyCode::Esc => {
app.mode = Mode::Normal; app.mode = Mode::Normal;
@ -136,6 +80,10 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
KeyCode::Enter => app.paste_selected(), KeyCode::Enter => app.paste_selected(),
KeyCode::Down => app.next(), KeyCode::Down => app.next(),
KeyCode::Up => app.previous(), KeyCode::Up => app.previous(),
KeyCode::Char('o') if app.input_buffer.is_empty() => {
// `o` sans texte saisi → ouvre URL
app.open_url_selected();
}
KeyCode::Char(c) => { KeyCode::Char(c) => {
app.input_buffer.push(c); app.input_buffer.push(c);
app.update_search(); app.update_search();
@ -147,11 +95,11 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
_ => {} _ => {}
}, },
// ----------------------------------------------------------
Mode::Command => match key.code { Mode::Command => match key.code {
KeyCode::Esc => { KeyCode::Esc => {
app.mode = Mode::Normal; app.mode = Mode::Normal;
app.input_buffer.clear(); app.input_buffer.clear();
app.update_search();
} }
KeyCode::Char(c) => app.input_buffer.push(c), KeyCode::Char(c) => app.input_buffer.push(c),
KeyCode::Backspace => { KeyCode::Backspace => {
@ -168,12 +116,13 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
app.pending_action = None; app.pending_action = None;
app.mode = Mode::PasswordInput; app.mode = Mode::PasswordInput;
} }
_ => {} _ => app.set_error(format!("Commande inconnue : {cmd}")),
} }
} }
_ => {} _ => {}
}, },
// ----------------------------------------------------------
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() {
@ -194,6 +143,7 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
_ => {} _ => {}
}, },
// ----------------------------------------------------------
Mode::PasswordInput => match key.code { Mode::PasswordInput => match key.code {
KeyCode::Esc => { KeyCode::Esc => {
app.mode = Mode::Normal; app.mode = Mode::Normal;
@ -223,3 +173,105 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
} }
} }
} }
/// Gère les touches en mode Normal. Retourne le nouvel état de `last_d`.
fn handle_normal(app: &mut App, code: KeyCode, last_d: bool, last_g: &mut bool) -> bool {
match code {
KeyCode::Char('?') => {
app.mode = Mode::Help;
*last_g = false;
false
}
KeyCode::Enter => {
app.paste_selected();
*last_g = false;
false
}
KeyCode::Char('j') | KeyCode::Down => {
app.next();
*last_g = false;
false
}
KeyCode::Char('k') | KeyCode::Up => {
app.previous();
*last_g = false;
false
}
KeyCode::Char('G') => {
if !app.filtered_items.is_empty() {
let l = app.filtered_items.len() - 1;
app.list_state.select(Some(l));
app.update_preview();
}
*last_g = false;
false
}
KeyCode::Char('g') => {
if *last_g {
if !app.filtered_items.is_empty() {
app.list_state.select(Some(0));
app.update_preview();
}
*last_g = false;
} else {
*last_g = true;
}
false
}
KeyCode::Char('d') => {
*last_g = false;
if last_d {
app.mode = Mode::ConfirmDelete;
false
} else {
true // dernier appui était 'd'
}
}
KeyCode::Char('u') => {
app.undo_delete();
*last_g = false;
false
}
KeyCode::Char('p') => {
app.toggle_pin();
*last_g = false;
false
}
KeyCode::Char('o') => {
app.open_url_selected();
*last_g = false;
false
}
KeyCode::Char('e') => {
app.toggle_encrypt();
*last_g = false;
false
}
KeyCode::Char('t') => {
app.cycle_type_filter();
*last_g = false;
false
}
KeyCode::Char('/') => {
app.mode = Mode::Search;
app.input_buffer.clear();
app.update_search();
*last_g = false;
false
}
KeyCode::Char(':') => {
app.mode = Mode::Command;
app.input_buffer.clear();
*last_g = false;
false
}
KeyCode::Char('q') => {
app.should_quit = true;
false
}
_ => {
*last_g = false;
false
}
}
}

View File

@ -7,6 +7,7 @@ use uuid::Uuid;
pub struct ClipboardEntry { pub struct ClipboardEntry {
pub content: ClipboardData, pub content: ClipboardData,
pub timestamp: SystemTime, pub timestamp: SystemTime,
pub pinned: bool,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -29,7 +30,6 @@ impl Image {
.join("images") .join("images")
.join(format!("{}.jpg", self.id)) .join(format!("{}.jpg", self.id))
} }
pub fn load_bytes(&self, dir_path: &str) -> io::Result<Vec<u8>> { pub fn load_bytes(&self, dir_path: &str) -> io::Result<Vec<u8>> {
fs::read(self.file_path(dir_path)) fs::read(self.file_path(dir_path))
} }

246
src/ui.rs
View File

@ -1,14 +1,18 @@
use crate::app::{App, Mode, detect_lang, highlight_code}; use crate::app::{App, Mode, detect_lang, highlight_code, is_image, is_url_only};
use crate::crypto::Crypto; use crate::crypto::Crypto;
use ratatui::{ use ratatui::{
Frame, Frame,
layout::{Alignment, Constraint, Direction, Layout}, layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
text::{Line, Span}, text::{Line, Span},
widgets::{Block, BorderType, Borders, List, ListItem, Padding, Paragraph}, widgets::{Block, BorderType, Borders, Clear, List, ListItem, Padding, Paragraph},
}; };
use ratatui_image::StatefulImage; use ratatui_image::StatefulImage;
// ---------------------------------------------------------------------------
// Point d'entrée
// ---------------------------------------------------------------------------
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()
.direction(Direction::Vertical) .direction(Direction::Vertical)
@ -20,6 +24,26 @@ pub fn render(f: &mut Frame, app: &mut App) {
.constraints([Constraint::Length(46), Constraint::Min(0)]) .constraints([Constraint::Length(46), Constraint::Min(0)])
.split(outer[0]); .split(outer[0]);
// ---- Liste ----
render_list(f, app, panels[0]);
// ---- Prévisualisation ----
render_preview(f, app, panels[1]);
// ---- Barre de statut ----
render_statusbar(f, app, outer[1]);
// ---- Overlay aide (par-dessus tout le reste) ----
if app.mode == Mode::Help {
render_help_overlay(f, f.area());
}
}
// ---------------------------------------------------------------------------
// Liste
// ---------------------------------------------------------------------------
fn render_list(f: &mut Frame, app: &mut App, area: Rect) {
let items: Vec<ListItem> = app let items: Vec<ListItem> = app
.filtered_items .filtered_items
.iter() .iter()
@ -30,8 +54,21 @@ pub fn render(f: &mut Frame, app: &mut App) {
Style::default().fg(Color::Rgb(90, 90, 110)), Style::default().fg(Color::Rgb(90, 90, 110)),
); );
// Indicateur d'épingle (largeur fixe pour garder l'alignement)
let pin_span = if item.pinned {
Span::styled(
"",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
} else {
Span::raw(" ")
};
if Crypto::is_any_encrypted(&item.content) { if Crypto::is_any_encrypted(&item.content) {
ListItem::new(Line::from(vec![ ListItem::new(Line::from(vec![
pin_span,
ts_span, ts_span,
Span::styled( Span::styled(
"🔒 [Chiffré]", "🔒 [Chiffré]",
@ -40,8 +77,9 @@ pub fn render(f: &mut Frame, app: &mut App) {
.add_modifier(Modifier::ITALIC), .add_modifier(Modifier::ITALIC),
), ),
])) ]))
} else if item.content.ends_with(".jpg") || item.content.ends_with(".png") { } else if is_image(&item.content) {
ListItem::new(Line::from(vec![ ListItem::new(Line::from(vec![
pin_span,
ts_span, ts_span,
Span::styled( Span::styled(
format!("🖼 {}", &item.content), format!("🖼 {}", &item.content),
@ -50,6 +88,19 @@ pub fn render(f: &mut Frame, app: &mut App) {
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
])) ]))
} else if is_url_only(&item.content) {
let preview: String = item.content.chars().take(26).collect();
ListItem::new(Line::from(vec![
pin_span,
ts_span,
Span::styled(
"[URL] ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(preview, Style::default().fg(Color::Rgb(100, 180, 255))),
]))
} else { } else {
let preview: String = item let preview: String = item
.content .content
@ -57,9 +108,9 @@ pub fn render(f: &mut Frame, app: &mut App) {
.find(|l| !l.trim().is_empty()) .find(|l| !l.trim().is_empty())
.unwrap_or("") .unwrap_or("")
.chars() .chars()
.take(28) .take(26)
.collect(); .collect();
ListItem::new(Line::from(vec![ts_span, Span::raw(preview)])) ListItem::new(Line::from(vec![pin_span, ts_span, Span::raw(preview)]))
} }
}) })
.collect(); .collect();
@ -86,8 +137,14 @@ pub fn render(f: &mut Frame, app: &mut App) {
) )
.highlight_symbol(""); .highlight_symbol("");
f.render_stateful_widget(list, panels[0], &mut app.list_state); f.render_stateful_widget(list, area, &mut app.list_state);
}
// ---------------------------------------------------------------------------
// Prévisualisation
// ---------------------------------------------------------------------------
fn render_preview(f: &mut Frame, app: &mut App, area: Rect) {
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 preview_title = match &app.preview_lang { let preview_title = match &app.preview_lang {
@ -108,8 +165,8 @@ pub fn render(f: &mut Frame, app: &mut App) {
.title_alignment(Alignment::Center) .title_alignment(Alignment::Center)
.padding(Padding::uniform(1)); .padding(Padding::uniform(1));
let inner = preview_block.inner(panels[1]); let inner = preview_block.inner(area);
f.render_widget(preview_block, panels[1]); f.render_widget(preview_block, area);
let scroll = (app.preview_scroll, 0); let scroll = (app.preview_scroll, 0);
@ -122,17 +179,40 @@ pub fn render(f: &mut Frame, app: &mut App) {
.scroll(scroll), .scroll(scroll),
inner, inner,
); );
} else if is_url_only(content) {
// Affiche l'URL complète + hint
let lines = vec![
Line::from(Span::styled(
content.trim(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::UNDERLINED),
)),
Line::from(""),
Line::from(Span::styled(
" [o] Ouvrir dans le navigateur",
Style::default().fg(Color::DarkGray),
)),
];
f.render_widget(Paragraph::new(lines).scroll(scroll), inner);
} else if let Some(lines) = &app.preview_highlighted { } else if let Some(lines) = &app.preview_highlighted {
f.render_widget(Paragraph::new(lines.clone()).scroll(scroll), inner); f.render_widget(Paragraph::new(lines.clone()).scroll(scroll), inner);
} }
} }
}
// ---------------------------------------------------------------------------
// Barre de statut
// ---------------------------------------------------------------------------
fn render_statusbar(f: &mut Frame, app: &mut App, area: Rect) {
let (mode_label, mode_color) = match &app.mode { let (mode_label, mode_color) = match &app.mode {
Mode::Normal => (" NORMAL ", Color::Green), Mode::Normal => (" NORMAL ", Color::Green),
Mode::Search => (" RECHERCHE ", Color::Cyan), Mode::Search => (" RECHERCHE ", Color::Cyan),
Mode::Command => (" COMMANDE ", Color::Yellow), Mode::Command => (" COMMANDE ", Color::Yellow),
Mode::ConfirmDelete => (" SUPPRIMER ? y/n ", Color::Red), Mode::ConfirmDelete => (" SUPPRIMER ? y/n ", Color::Red),
Mode::PasswordInput => (" MOT DE PASSE ", Color::Magenta), Mode::PasswordInput => (" MOT DE PASSE ", Color::Magenta),
Mode::Help => (" AIDE ", Color::Blue),
}; };
let filter_hint = match app.type_filter { let filter_hint = match app.type_filter {
@ -156,6 +236,7 @@ pub fn render(f: &mut Frame, app: &mut App) {
} }
Mode::Command => format!(" :{}", app.input_buffer), Mode::Command => format!(" :{}", app.input_buffer),
Mode::PasswordInput => format!(" {}", "".repeat(app.input_buffer.len())), Mode::PasswordInput => format!(" {}", "".repeat(app.input_buffer.len())),
Mode::Help => " ? ou Esc pour fermer".to_string(),
_ => filter_hint, _ => filter_hint,
}; };
Span::raw(extra) Span::raw(extra)
@ -177,7 +258,7 @@ pub fn render(f: &mut Frame, app: &mut App) {
let status_cols = Layout::default() let status_cols = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(clen)]) .constraints([Constraint::Min(0), Constraint::Length(clen)])
.split(outer[1]); .split(area);
f.render_widget( f.render_widget(
Paragraph::new(Line::from(vec![ Paragraph::new(Line::from(vec![
@ -192,6 +273,7 @@ pub fn render(f: &mut Frame, app: &mut App) {
])), ])),
status_cols[0], status_cols[0],
); );
f.render_widget( f.render_widget(
Paragraph::new(Line::from(Span::styled( Paragraph::new(Line::from(Span::styled(
counter, counter,
@ -203,3 +285,147 @@ pub fn render(f: &mut Frame, app: &mut App) {
status_cols[1], status_cols[1],
); );
} }
// ---------------------------------------------------------------------------
// Overlay d'aide
// ---------------------------------------------------------------------------
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
Rect::new(x, y, width.min(area.width), height.min(area.height))
}
fn help_lines() -> Vec<Line<'static>> {
let k = |s: &'static str| {
Span::styled(
s,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
};
let d = |s: &'static str| Span::styled(s, Style::default().fg(Color::White));
let sep = || Span::raw(" ");
let h = |s: &'static str| {
Span::styled(
s,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
)
};
let dim = |s: &'static str| Span::styled(s, Style::default().fg(Color::DarkGray));
vec![
Line::from(vec![h("Navigation")]),
Line::from(vec![
k(" j / ↓"),
sep(),
d("Bas"),
sep(),
sep(),
k("k / ↑"),
sep(),
d("Haut"),
]),
Line::from(vec![
k(" g g"),
sep(),
d("Premier"),
sep(),
sep(),
k("G"),
sep(),
d("Dernier"),
]),
Line::from(vec![
k(" Ctrl+j"),
sep(),
d("Scroll prévisualisation ↓"),
sep(),
k("Ctrl+k"),
sep(),
d(""),
]),
Line::from(""),
Line::from(vec![h("Actions")]),
Line::from(vec![k(" Entrée"), sep(), d("Coller & quitter")]),
Line::from(vec![
k(" d d"),
sep(),
d("Supprimer (demande confirmation)"),
]),
Line::from(vec![k(" u"), sep(), d("Annuler la suppression")]),
Line::from(vec![k(" p"), sep(), d("★ Épingler / désépingler")]),
Line::from(vec![k(" e"), sep(), d("🔒 Chiffrer / déchiffrer")]),
Line::from(vec![k(" o"), sep(), d("Ouvrir l'URL dans le navigateur")]),
Line::from(vec![
k(" t"),
sep(),
d("Filtrer par type (Tous → Texte → Image)"),
]),
Line::from(""),
Line::from(vec![h("Recherche (mode /)")]),
Line::from(vec![
k(" /texte"),
sep(),
d("Fuzzy search"),
sep(),
k("//regex"),
sep(),
d("Regex (préfixe /)"),
]),
Line::from(vec![
k(" after:YYYY-MM-DD"),
sep(),
d("Après date"),
sep(),
k("before:..."),
sep(),
d("Avant date"),
]),
Line::from(""),
Line::from(vec![h("Commandes (:)")]),
Line::from(vec![
k(" :clear"),
sep(),
d("Effacer tout l'historique"),
sep(),
k(":password"),
sep(),
d("Mot de passe session"),
sep(),
k(":q"),
sep(),
d("Quitter"),
]),
Line::from(""),
Line::from(vec![dim(" ? / Esc pour fermer cette aide")]),
]
}
fn render_help_overlay(f: &mut Frame, area: Rect) {
let popup = centered_rect(68, 25, area);
f.render_widget(Clear, popup);
let block = Block::default()
.title(Span::styled(
" Aide — Raccourcis clavier ",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
))
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Cyan))
.style(Style::default().bg(Color::Rgb(18, 18, 28)));
let inner = block.inner(popup);
f.render_widget(block, popup);
f.render_widget(
Paragraph::new(help_lines()).style(Style::default().bg(Color::Rgb(18, 18, 28))),
inner,
);
}