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 std::error::Error;
use std::fs;
use std::io::Cursor;
use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use uuid::Uuid;
@ -29,25 +30,44 @@ impl Database {
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(
"CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type 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(
"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(
@ -85,25 +105,25 @@ impl Database {
.flat_map(|rgba| [rgba[0], rgba[1], rgba[2]])
.collect();
let mut jpeg_buf = Vec::new();
JpegEncoder::new_with_quality(&mut jpeg_buf, 70).write_image(
let mut buf = Vec::new();
JpegEncoder::new_with_quality(Cursor::new(&mut buf), 70).write_image(
&rgb,
img.width,
img.height,
ExtendedColorType::Rgb8,
)?;
if jpeg_buf.len() > self.max_entry_size_bytes {
if buf.len() > self.max_entry_size_bytes {
eprintln!(
"Image rejetée dans DB : JPEG {} Ko > limite {} Ko",
jpeg_buf.len() / 1024,
"Image rejetée : JPEG {} Ko > limite {} Ko",
buf.len() / 1024,
self.max_entry_size_bytes / 1024
);
return Ok(());
}
let path = img.file_path(&self.dir_path);
fs::write(&path, &jpeg_buf)?;
fs::write(&path, &buf)?;
}
None => return Ok(()),
}
@ -112,7 +132,13 @@ impl Database {
};
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),
)?;
@ -130,9 +156,10 @@ impl Database {
let image_files: Vec<String> = {
let mut stmt = tx.prepare(
"SELECT content FROM history
WHERE type = 'image'
WHERE type = 'image' AND pinned = 0
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))?
@ -141,8 +168,11 @@ impl Database {
};
tx.execute(
"DELETE FROM history WHERE id NOT IN (
SELECT id FROM history ORDER BY timestamp DESC LIMIT ?1
"DELETE FROM history
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],
)?;
@ -163,7 +193,10 @@ impl Database {
pub fn read_history(&self, limit: usize) -> Result<Vec<ClipboardEntry>, Box<dyn Error>> {
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| {
@ -171,12 +204,13 @@ impl Database {
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)?,
row.get::<_, bool>(3)?,
))
})?;
let mut entries = Vec::new();
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 data = if ty == "text" {
ClipboardData::Text(content)
@ -192,11 +226,23 @@ impl Database {
entries.push(ClipboardEntry {
content: data,
timestamp,
pinned,
});
}
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>> {
self.conn
.execute("DELETE FROM history WHERE content = ?1", [content])?;
@ -221,14 +267,19 @@ impl Database {
let tx = self.conn.unchecked_transaction()?;
let image_files: Vec<String> = {
let mut stmt =
tx.prepare("SELECT content FROM history WHERE type = 'image' AND timestamp < ?1")?;
let mut stmt = tx.prepare(
"SELECT content FROM history
WHERE type = 'image' AND pinned = 0 AND timestamp < ?1",
)?;
stmt.query_map([cutoff_ms], |row| row.get(0))?
.filter_map(|r| r.ok())
.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()?;

View File

@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{Read, Write};
use std::os::unix::net::UnixListener;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
@ -16,6 +16,8 @@ const IPC_MAX_REQUEST_BYTES: usize = 4 * 1024 * 1024;
pub struct HistoryItem {
pub content: String,
pub timestamp: i64,
#[serde(default)]
pub pinned: bool,
}
#[derive(Serialize, Deserialize, Debug)]
@ -36,6 +38,10 @@ pub enum IpcRequest {
AddEntry {
content: String,
},
PinEntry {
content: String,
pinned: bool,
},
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() {
let _ = fs::remove_file(socket_path);
}
@ -84,8 +95,23 @@ pub fn start_server(db: Arc<Mutex<Database>>, crypto: Arc<Crypto>, socket_path:
let db_clone = Arc::clone(&db);
let crypto_clone = Arc::clone(&crypto);
let data_dir_clone = Arc::clone(&data_dir);
std::thread::spawn(move || {
handle_connection(&mut stream, db_clone, crypto_clone, data_dir_clone);
});
}
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];
@ -131,9 +157,8 @@ pub fn start_server(db: Arc<Mutex<Database>>, crypto: Arc<Crypto>, socket_path:
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 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()
@ -146,28 +171,28 @@ pub fn start_server(db: Arc<Mutex<Database>>, crypto: Arc<Crypto>, socket_path:
.timestamp
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
as i64;
.as_millis() as i64;
HistoryItem {
content,
timestamp: ts,
pinned: e.pinned,
}
})
.collect();
reply(&mut stream, IpcResponse::History(items));
reply(stream, IpcResponse::History(items));
}
IpcRequest::SetClipboard { content } => {
let actual = if Crypto::is_legacy_encrypted(&content) {
crypto_clone.decrypt(&content).unwrap_or_else(|e| {
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(
&mut stream,
stream,
IpcResponse::Error(
"Entrée chiffrée par mot de passe : déchiffrez-la côté client avant de coller"
"Entrée chiffrée par mot de passe : déchiffrez côté client avant de coller"
.to_string(),
),
);
@ -179,67 +204,55 @@ pub fn start_server(db: Arc<Mutex<Database>>, crypto: Arc<Crypto>, socket_path:
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);
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 (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);
reply(stream, IpcResponse::Ok);
} else {
reply(
&mut stream,
IpcResponse::Error(format!(
"Image introuvable : {actual}"
)),
stream,
IpcResponse::Error(format!("Image introuvable : {actual}")),
);
}
}
} else {
let _ = cb.set_text(actual);
reply(&mut stream, IpcResponse::Ok);
reply(stream, IpcResponse::Ok);
}
}
Err(e) => reply(&mut stream, IpcResponse::Error(e.to_string())),
Err(e) => reply(stream, IpcResponse::Error(e.to_string())),
}
}
IpcRequest::DeleteEntry { content } => {
{
let lock = db_clone.lock().unwrap();
let lock = db.lock().unwrap_or_else(|p| p.into_inner());
let _ = lock.delete_entry_by_content(&content);
}
if !content.starts_with("enc:")
&& !content.starts_with("enc2:")
if !Crypto::is_any_encrypted(&content)
&& (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);
let p = data_dir.join("images").join(&content);
if p.exists() {
let _ = fs::remove_file(p);
}
}
}
reply(&mut stream, IpcResponse::Ok);
reply(stream, IpcResponse::Ok);
}
IpcRequest::UpdateEntry {
old_content,
new_content,
} => {
let lock = db_clone.lock().unwrap();
let lock = db.lock().unwrap_or_else(|p| p.into_inner());
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())),
Ok(_) => reply(stream, IpcResponse::Ok),
Err(e) => reply(stream, IpcResponse::Error(e.to_string())),
}
}
@ -247,25 +260,29 @@ pub fn start_server(db: Arc<Mutex<Database>>, crypto: Arc<Crypto>, socket_path:
let entry = ClipboardEntry {
content: ClipboardData::Text(content),
timestamp: SystemTime::now(),
pinned: false,
};
let lock = db_clone.lock().unwrap();
let lock = db.lock().unwrap_or_else(|p| p.into_inner());
match lock.append(entry) {
Ok(_) => reply(&mut stream, IpcResponse::Ok),
Err(e) => reply(&mut stream, IpcResponse::Error(e.to_string())),
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_clone.lock().unwrap();
let lock = db.lock().unwrap_or_else(|p| p.into_inner());
match lock.clear_history() {
Ok(_) => reply(&mut stream, IpcResponse::Ok),
Err(e) => reply(&mut stream, IpcResponse::Error(e.to_string())),
Ok(_) => reply(stream, IpcResponse::Ok),
Err(e) => reply(stream, IpcResponse::Error(e.to_string())),
}
}
}
});
}
Err(e) => eprintln!("Erreur connexion IPC : {e}"),
}
}
}

View File

@ -31,17 +31,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let proj_dirs =
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 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 db_for_ipc = Arc::clone(&db);
let crypto_for_ipc = Arc::clone(&crypto);
let data_dir = Arc::new(dir_path.clone());
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 {
@ -49,12 +50,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
std::thread::spawn(move || {
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) {
Ok(0) => {}
Ok(n) => {
println!("Expiration : {n} entrée(s) supprimée(s) (> {days} jours)")
}
Ok(n) => println!("Expiration : {n} entrée(s) > {days} jours supprimée(s)"),
Err(e) => eprintln!("Erreur expiration : {e}"),
}
}

View File

@ -7,6 +7,7 @@ use uuid::Uuid;
pub struct ClipboardEntry {
pub content: ClipboardData,
pub timestamp: SystemTime,
pub pinned: bool,
}
#[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 || {
for entry in rx {
let lock = match db.lock() {
Ok(l) => l,
Err(poisoned) => {
let lock = db.lock().unwrap_or_else(|poisoned| {
eprintln!("Mutex DB empoisonné, récupération forcée");
poisoned.into_inner()
}
};
});
if let Err(e) = lock.append(entry) {
eprintln!("SQLite write error: {e}");
} else {

View File

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

View File

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

View File

@ -9,7 +9,7 @@ use ratatui::widgets::ListState;
use ratatui_image::{picker::Picker, protocol};
use regex::Regex;
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
use syntect::easy::HighlightLines;
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")
}
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)]
pub enum Mode {
Normal,
@ -35,6 +55,7 @@ pub enum Mode {
Search,
ConfirmDelete,
PasswordInput,
Help,
}
#[derive(Clone)]
@ -59,7 +80,6 @@ impl TypeFilter {
Self::Image => Self::All,
}
}
pub fn label(self) -> &'static str {
match self {
Self::All => "Tous",
@ -102,6 +122,40 @@ fn syn_color(c: syntect::highlighting::Color) -> Color {
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(
content: &str,
syntax_set: &SyntaxSet,
@ -112,6 +166,8 @@ pub fn highlight_code(
let mut h = HighlightLines::new(syntax, theme);
let mut lines = Vec::new();
let total_lines = content.lines().count();
for (no, line) in LinesWithEndings::from(content)
.enumerate()
.take(MAX_HIGHLIGHT_LINES)
@ -134,7 +190,6 @@ pub fn highlight_code(
lines.push(Line::from(spans));
}
let total_lines = content.lines().count();
if total_lines > MAX_HIGHLIGHT_LINES {
lines.push(Line::from(Span::styled(
format!(
@ -148,43 +203,6 @@ pub fn highlight_code(
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 {
pub fn new() -> Self {
let items = ipc::fetch_history(PAGE_SIZE).unwrap_or_default();
@ -244,24 +262,19 @@ impl App {
if !self.has_more {
return false;
}
let new_limit = self.loaded_count + PAGE_SIZE;
let Some(items) = ipc::fetch_history(new_limit) else {
return false;
};
if items.len() <= self.all_items.len() {
self.has_more = false;
return false;
}
self.has_more = items.len() == new_limit;
self.loaded_count = new_limit;
let selected_content = self.get_selected_item().map(|i| i.content.clone());
self.all_items = items;
self.update_search();
if let Some(content) = selected_content {
if let Some(pos) = self
.filtered_items
@ -273,7 +286,6 @@ impl App {
self.update_preview();
}
}
self.set_status(format!("{} entrées chargées", self.all_items.len()));
true
}
@ -288,7 +300,6 @@ impl App {
self.image_cache_order.push_back(filename.to_string());
return self.image_cache.get(filename).cloned();
}
let path = base_dir.join("images").join(filename);
if !path.exists() {
return None;
@ -299,7 +310,6 @@ impl App {
} 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);
@ -318,9 +328,7 @@ impl App {
match Local.timestamp_opt(secs, nsecs) {
chrono::LocalResult::Single(dt) => {
let today = Local::now().date_naive();
let entry_date = dt.date_naive();
let diff_days = (today - entry_date).num_days();
let diff_days = (today - dt.date_naive()).num_days();
if diff_days == 0 {
dt.format("%H:%M:%S").to_string()
} else if diff_days < 365 {
@ -405,9 +413,9 @@ impl App {
let mut matched: Vec<(i64, HistoryItem)> = base
.into_iter()
.filter_map(|item| {
matcher
.fuzzy_match(&search_str(&item), &text_query)
.map(|s| (s, item))
let score = matcher.fuzzy_match(&search_str(&item), &text_query)?;
let adjusted = score + if item.pinned { 1000 } else { 0 };
Some((adjusted, item))
})
.collect();
matched.sort_by(|a, b| b.0.cmp(&a.0));
@ -419,7 +427,6 @@ impl App {
} else {
Some(0)
});
self.update_preview();
}
@ -429,7 +436,6 @@ impl App {
}
let current = self.list_state.selected().unwrap_or(0);
let last = self.filtered_items.len() - 1;
if current >= last {
if self.try_load_more() {
let current = self.list_state.selected().unwrap_or(0);
@ -508,6 +514,69 @@ impl App {
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) {
let content = match self.get_selected_item() {
Some(i) => i.content.clone(),
@ -703,7 +772,6 @@ impl App {
pub fn scroll_preview_down(&mut self) {
self.preview_scroll = self.preview_scroll.saturating_add(3);
}
pub fn scroll_preview_up(&mut self) {
self.preview_scroll = self.preview_scroll.saturating_sub(3);
}
@ -731,13 +799,12 @@ impl App {
.all_items
.iter()
.zip(&new)
.any(|(a, b)| a.content != b.content);
.any(|(a, b)| a.content != b.content || a.pinned != b.pinned);
if changed {
let selected_content = self.get_selected_item().map(|i| i.content.clone());
self.all_items = new;
self.update_search();
if let Some(content) = selected_content {
if let Some(pos) = self
.filtered_items
@ -755,7 +822,6 @@ impl App {
pub fn set_error(&mut self, msg: String) {
self.error_message = Some((msg, Instant::now()));
}
pub fn set_status(&mut self, msg: String) {
self.status_message = Some((msg, Instant::now()));
}

View File

@ -6,6 +6,8 @@ use std::os::unix::net::UnixStream;
pub struct HistoryItem {
pub content: String,
pub timestamp: i64,
#[serde(default)]
pub pinned: bool,
}
#[derive(Serialize, Deserialize, Debug)]
@ -26,6 +28,10 @@ pub enum IpcRequest {
AddEntry {
content: String,
},
PinEntry {
content: String,
pinned: bool,
},
ClearHistory,
}
@ -84,6 +90,13 @@ pub fn add_entry(content: String) {
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 {
matches!(
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 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 {
KeyCode::Char('j') => {
app.scroll_preview_down();
@ -58,75 +59,18 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
}
match app.mode {
Mode::Normal => match key.code {
KeyCode::Enter => app.paste_selected(),
KeyCode::Char('j') | KeyCode::Down => app.next(),
KeyCode::Char('k') | KeyCode::Up => app.previous(),
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();
// ----------------------------------------------------------
Mode::Help => {
// N'importe quelle touche ferme l'aide
app.mode = Mode::Normal;
}
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 {
KeyCode::Esc => {
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::Down => app.next(),
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) => {
app.input_buffer.push(c);
app.update_search();
@ -147,11 +95,11 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
_ => {}
},
// ----------------------------------------------------------
Mode::Command => match key.code {
KeyCode::Esc => {
app.mode = Mode::Normal;
app.input_buffer.clear();
app.update_search();
}
KeyCode::Char(c) => app.input_buffer.push(c),
KeyCode::Backspace => {
@ -168,12 +116,13 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
app.pending_action = None;
app.mode = Mode::PasswordInput;
}
_ => {}
_ => app.set_error(format!("Commande inconnue : {cmd}")),
}
}
_ => {}
},
// ----------------------------------------------------------
Mode::ConfirmDelete => match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
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 {
KeyCode::Esc => {
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 content: ClipboardData,
pub timestamp: SystemTime,
pub pinned: bool,
}
#[derive(Debug, Clone)]
@ -29,7 +30,6 @@ impl Image {
.join("images")
.join(format!("{}.jpg", self.id))
}
pub fn load_bytes(&self, dir_path: &str) -> io::Result<Vec<u8>> {
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 ratatui::{
Frame,
layout::{Alignment, Constraint, Direction, Layout},
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, List, ListItem, Padding, Paragraph},
widgets::{Block, BorderType, Borders, Clear, List, ListItem, Padding, Paragraph},
};
use ratatui_image::StatefulImage;
// ---------------------------------------------------------------------------
// Point d'entrée
// ---------------------------------------------------------------------------
pub fn render(f: &mut Frame, app: &mut App) {
let outer = Layout::default()
.direction(Direction::Vertical)
@ -20,6 +24,26 @@ pub fn render(f: &mut Frame, app: &mut App) {
.constraints([Constraint::Length(46), Constraint::Min(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
.filtered_items
.iter()
@ -30,8 +54,21 @@ pub fn render(f: &mut Frame, app: &mut App) {
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) {
ListItem::new(Line::from(vec![
pin_span,
ts_span,
Span::styled(
"🔒 [Chiffré]",
@ -40,8 +77,9 @@ pub fn render(f: &mut Frame, app: &mut App) {
.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![
pin_span,
ts_span,
Span::styled(
format!("🖼 {}", &item.content),
@ -50,6 +88,19 @@ pub fn render(f: &mut Frame, app: &mut App) {
.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 {
let preview: String = item
.content
@ -57,9 +108,9 @@ pub fn render(f: &mut Frame, app: &mut App) {
.find(|l| !l.trim().is_empty())
.unwrap_or("")
.chars()
.take(28)
.take(26)
.collect();
ListItem::new(Line::from(vec![ts_span, Span::raw(preview)]))
ListItem::new(Line::from(vec![pin_span, ts_span, Span::raw(preview)]))
}
})
.collect();
@ -86,8 +137,14 @@ pub fn render(f: &mut Frame, app: &mut App) {
)
.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 preview_title = match &app.preview_lang {
@ -108,8 +165,8 @@ pub fn render(f: &mut Frame, app: &mut App) {
.title_alignment(Alignment::Center)
.padding(Padding::uniform(1));
let inner = preview_block.inner(panels[1]);
f.render_widget(preview_block, panels[1]);
let inner = preview_block.inner(area);
f.render_widget(preview_block, area);
let scroll = (app.preview_scroll, 0);
@ -122,17 +179,40 @@ pub fn render(f: &mut Frame, app: &mut App) {
.scroll(scroll),
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 {
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 {
Mode::Normal => (" NORMAL ", Color::Green),
Mode::Search => (" RECHERCHE ", Color::Cyan),
Mode::Command => (" COMMANDE ", Color::Yellow),
Mode::ConfirmDelete => (" SUPPRIMER ? y/n ", Color::Red),
Mode::PasswordInput => (" MOT DE PASSE ", Color::Magenta),
Mode::Help => (" AIDE ", Color::Blue),
};
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::PasswordInput => format!(" {}", "".repeat(app.input_buffer.len())),
Mode::Help => " ? ou Esc pour fermer".to_string(),
_ => filter_hint,
};
Span::raw(extra)
@ -177,7 +258,7 @@ pub fn render(f: &mut Frame, app: &mut App) {
let status_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(clen)])
.split(outer[1]);
.split(area);
f.render_widget(
Paragraph::new(Line::from(vec![
@ -192,6 +273,7 @@ pub fn render(f: &mut Frame, app: &mut App) {
])),
status_cols[0],
);
f.render_widget(
Paragraph::new(Line::from(Span::styled(
counter,
@ -203,3 +285,147 @@ pub fn render(f: &mut Frame, app: &mut App) {
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,
);
}