correction + regexp

This commit is contained in:
2026-05-20 23:26:01 +02:00
parent 8b07e305f0
commit d173db3342
11 changed files with 425 additions and 73 deletions

1
Cargo.lock generated
View File

@ -2464,6 +2464,7 @@ dependencies = [
"image", "image",
"ratatui", "ratatui",
"ratatui-image", "ratatui-image",
"regex",
"rklipd", "rklipd",
"serde", "serde",
"serde_json", "serde_json",

View File

@ -14,6 +14,7 @@ fuzzy-matcher = "0.3.7"
image = "0.25.9" image = "0.25.9"
ratatui = "0.30.0" ratatui = "0.30.0"
ratatui-image = { version = "10.0.6", features = ["crossterm"] } ratatui-image = { version = "10.0.6", features = ["crossterm"] }
regex = "1.12.3"
rklipd = {path = "rklipd"} rklipd = {path = "rklipd"}
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"

70
rklipd/src/config.rs Normal file
View File

@ -0,0 +1,70 @@
/// rklipd --max-entries 500 --max-entry-size-kb 512 --expiry-days 30
pub struct Config {
pub max_entries: usize,
pub max_entry_size_kb: usize,
pub expiry_days: Option<u64>,
}
impl Default for Config {
fn default() -> Self {
Self {
max_entries: 500,
max_entry_size_kb: 512,
expiry_days: None,
}
}
}
impl Config {
pub fn from_args() -> Self {
let mut cfg = Self::default();
let args: Vec<String> = std::env::args().skip(1).collect();
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--max-entries" => {
if let Some(v) = args.get(i + 1).and_then(|s| s.parse().ok()) {
cfg.max_entries = v;
i += 1;
} else {
eprintln!("--max-entries requiert une valeur entière positive");
}
}
"--max-entry-size-kb" => {
if let Some(v) = args.get(i + 1).and_then(|s| s.parse().ok()) {
cfg.max_entry_size_kb = v;
i += 1;
} else {
eprintln!("--max-entry-size-kb requiert une valeur entière positive");
}
}
"--expiry-days" => {
if let Some(v) = args.get(i + 1).and_then(|s| s.parse::<u64>().ok()) {
cfg.expiry_days = Some(v);
i += 1;
} else {
eprintln!("--expiry-days requiert une valeur entière positive");
}
}
"--help" | "-h" => {
println!(
r#"Usage: rklipd [OPTIONS]
Options:
--max-entries <N> Nombre max d'entrées (défaut: 500)
--max-entry-size-kb <N> Taille max d'une entrée en Ko (défaut: 512)
--expiry-days <N> Supprime les entrées > N jours (défaut: désactivé)
--help Affiche cette aide"#
);
std::process::exit(0);
}
unknown => {
eprintln!("Argument inconnu : {unknown}. Utilisez --help.");
}
}
i += 1;
}
cfg
}
}

View File

@ -7,6 +7,9 @@ use std::error::Error;
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
const ENC_PREFIX: &str = "enc:";
const ENC2_PREFIX: &str = "enc2:";
pub struct Crypto { pub struct Crypto {
key: [u8; 32], key: [u8; 32],
} }
@ -45,13 +48,13 @@ impl Crypto {
.map_err(|e| format!("Erreur de chiffrement : {e}"))?; .map_err(|e| format!("Erreur de chiffrement : {e}"))?;
let mut combined = nonce.to_vec(); let mut combined = nonce.to_vec();
combined.extend_from_slice(&ciphertext); combined.extend_from_slice(&ciphertext);
Ok(format!("enc:{}", BASE64.encode(combined))) Ok(format!("{}{}", ENC_PREFIX, BASE64.encode(combined)))
} }
pub fn decrypt(&self, encrypted: &str) -> Result<String, Box<dyn Error>> { pub fn decrypt(&self, encrypted: &str) -> Result<String, Box<dyn Error>> {
let encoded = encrypted let encoded = encrypted
.strip_prefix("enc:") .strip_prefix(ENC_PREFIX)
.ok_or("Pas une entrée chiffrée")?; .ok_or("Pas une entrée chiffrée (enc:)")?;
let combined = BASE64.decode(encoded)?; let combined = BASE64.decode(encoded)?;
if combined.len() < 12 { if combined.len() < 12 {
return Err("Données chiffrées trop courtes".into()); return Err("Données chiffrées trop courtes".into());
@ -66,7 +69,15 @@ impl Crypto {
Ok(String::from_utf8(plaintext)?) Ok(String::from_utf8(plaintext)?)
} }
pub fn is_encrypted(content: &str) -> bool { pub fn is_legacy_encrypted(content: &str) -> bool {
content.starts_with("enc:") content.starts_with(ENC_PREFIX) && !content.starts_with(ENC2_PREFIX)
}
pub fn is_password_encrypted(content: &str) -> bool {
content.starts_with(ENC2_PREFIX)
}
pub fn is_any_encrypted(content: &str) -> bool {
content.starts_with(ENC_PREFIX) || content.starts_with(ENC2_PREFIX)
} }
} }

View File

@ -1,3 +1,4 @@
use crate::config::Config;
use crate::models::{ClipboardData, ClipboardEntry, Image}; use crate::models::{ClipboardData, ClipboardEntry, Image};
use image::codecs::jpeg::JpegEncoder; use image::codecs::jpeg::JpegEncoder;
use image::{ExtendedColorType, ImageEncoder}; use image::{ExtendedColorType, ImageEncoder};
@ -5,46 +6,55 @@ use rusqlite::Connection;
use std::error::Error; use std::error::Error;
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
use std::time::{Duration, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use uuid::Uuid; use uuid::Uuid;
pub struct Database { pub struct Database {
conn: Connection, conn: Connection,
dir_path: String, dir_path: String,
max_entries: usize,
max_entry_size_bytes: usize,
} }
impl Database { impl Database {
pub fn init(dir_path: &str) -> Result<Self, Box<dyn Error>> { pub fn init(dir_path: &str, config: &Config) -> Result<Self, Box<dyn Error>> {
let base_path = Path::new(dir_path); let base_path = Path::new(dir_path);
fs::create_dir_all(base_path.join("images"))?; fs::create_dir_all(base_path.join("images"))?;
let conn = Connection::open(base_path.join("clipboard.db"))?; let conn = Connection::open(base_path.join("clipboard.db"))?;
conn.execute_batch( conn.execute_batch(
"CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL); "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);", 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
)", )",
[], [],
)?; )?;
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);",
)?;
conn.execute_batch( conn.execute_batch(
"DELETE FROM history WHERE id NOT IN ( "DELETE FROM history WHERE id NOT IN (
SELECT MAX(id) FROM history GROUP BY content SELECT MAX(id) FROM history GROUP BY content
); );",
CREATE UNIQUE INDEX IF NOT EXISTS idx_history_content ON history(content);",
)?; )?;
Ok(Self { Ok(Self {
conn, conn,
dir_path: dir_path.to_string(), dir_path: dir_path.to_string(),
max_entries: config.max_entries,
max_entry_size_bytes: config.max_entry_size_kb * 1024,
}) })
} }
@ -56,10 +66,16 @@ impl Database {
if t.trim().is_empty() { if t.trim().is_empty() {
return Ok(()); return Ok(());
} }
if t.len() > self.max_entry_size_bytes {
return Ok(());
}
("text", t.clone()) ("text", t.clone())
} }
ClipboardData::Image(img) => { ClipboardData::Image(img) => {
if let Some(px) = &img.raw_pixels { if let Some(px) = &img.raw_pixels {
if px.len() > self.max_entry_size_bytes * 4 {
return Ok(());
}
let path = img.file_path(&self.dir_path); let path = img.file_path(&self.dir_path);
let file = fs::File::create(&path)?; let file = fs::File::create(&path)?;
let rgb: Vec<u8> = px let rgb: Vec<u8> = px
@ -81,6 +97,45 @@ impl Database {
"INSERT OR REPLACE INTO history (type, content, timestamp) VALUES (?1, ?2, ?3)", "INSERT OR REPLACE INTO history (type, content, timestamp) VALUES (?1, ?2, ?3)",
(kind, &content, ts), (kind, &content, ts),
)?; )?;
self.trim_to_max()?;
Ok(())
}
fn trim_to_max(&self) -> Result<(), Box<dyn Error>> {
if self.max_entries == 0 {
return Ok(());
}
let mut stmt = self.conn.prepare(
"SELECT content FROM history
WHERE type = 'image'
AND id NOT IN (
SELECT id FROM history ORDER BY timestamp DESC LIMIT ?1
)",
)?;
let to_delete: Vec<String> = stmt
.query_map([self.max_entries as i64], |row| row.get(0))?
.filter_map(|r| r.ok())
.collect();
self.conn.execute(
"DELETE FROM history WHERE id NOT IN (
SELECT id FROM history ORDER BY timestamp DESC LIMIT ?1
)",
[self.max_entries as i64],
)?;
for filename in to_delete {
let path = Path::new(&self.dir_path).join("images").join(&filename);
if path.exists() {
if let Err(e) = fs::remove_file(&path) {
eprintln!("Impossible de supprimer l'image {filename} : {e}");
}
}
}
Ok(()) Ok(())
} }
@ -137,14 +192,40 @@ impl Database {
Ok(()) Ok(())
} }
pub fn delete_entries_older_than(&self, days: u64) -> Result<usize, Box<dyn Error>> {
let cutoff_ms = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as i64
- (days as i64 * 86_400_000);
let mut stmt = self
.conn
.prepare("SELECT content FROM history WHERE type = 'image' AND timestamp < ?1")?;
let image_files: Vec<String> = stmt
.query_map([cutoff_ms], |row| row.get(0))?
.filter_map(|r| r.ok())
.collect();
let count = self
.conn
.execute("DELETE FROM history WHERE timestamp < ?1", [cutoff_ms])?;
for filename in image_files {
let path = Path::new(&self.dir_path).join("images").join(&filename);
if path.exists() {
if let Err(e) = fs::remove_file(&path) {
eprintln!("Impossible de supprimer l'image expirée {filename} : {e}");
}
}
}
Ok(count)
}
pub fn clear_history(&self) -> Result<(), Box<dyn Error>> { pub fn clear_history(&self) -> Result<(), Box<dyn Error>> {
let images_dir = Path::new(&self.dir_path).join("images"); let images_dir = Path::new(&self.dir_path).join("images");
if images_dir.exists() { if images_dir.exists() {
fs::remove_dir_all(&images_dir)?; fs::remove_dir_all(&images_dir)?;
} }
fs::create_dir_all(&images_dir)?; fs::create_dir_all(&images_dir)?;
self.conn.execute("DELETE FROM history", [])?; self.conn.execute("DELETE FROM history", [])?;
Ok(()) Ok(())
} }

View File

@ -119,12 +119,23 @@ pub fn start_server(db: Arc<Mutex<Database>>, crypto: Arc<Crypto>, socket_path:
} }
IpcRequest::SetClipboard { content } => { IpcRequest::SetClipboard { content } => {
let actual = let actual = if Crypto::is_legacy_encrypted(&content) {
if content.starts_with("enc:") || content.starts_with("enc2:") { crypto_clone.decrypt(&content).unwrap_or_else(|e| {
crypto_clone.decrypt(&content).unwrap_or(content) eprintln!("Impossible de déchiffrer l'entrée enc: : {e}");
} else { content.clone()
content })
}; } 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() { match arboard::Clipboard::new() {
Ok(mut cb) => { Ok(mut cb) => {
@ -142,6 +153,14 @@ pub fn start_server(db: Arc<Mutex<Database>>, crypto: Arc<Crypto>, socket_path:
height: h, height: h,
bytes: std::borrow::Cow::Owned(rgba.into_raw()), bytes: std::borrow::Cow::Owned(rgba.into_raw()),
}); });
} else {
reply(
&mut stream,
IpcResponse::Error(format!(
"Image introuvable : {actual}"
)),
);
return;
} }
} }
} else { } else {

View File

@ -1,3 +1,4 @@
mod config;
mod crypto; mod crypto;
mod database; mod database;
mod ipc; mod ipc;
@ -5,13 +6,27 @@ mod models;
mod monitor; mod monitor;
mod ws; mod ws;
use crate::config::Config;
use crate::crypto::Crypto; use crate::crypto::Crypto;
use crate::database::Database; use crate::database::Database;
use arboard::Clipboard; use arboard::Clipboard;
use directories::ProjectDirs; use directories::ProjectDirs;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration;
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = Config::from_args();
println!(
"rklipd démarrage — max_entries={}, max_entry_size_kb={}, expiry_days={}",
config.max_entries,
config.max_entry_size_kb,
config
.expiry_days
.map(|d| d.to_string())
.unwrap_or_else(|| "désactivé".to_string())
);
let clipboard = Clipboard::new()?; let clipboard = Clipboard::new()?;
let proj_dirs = let proj_dirs =
@ -19,7 +34,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let dir_path = proj_dirs.data_dir(); let dir_path = proj_dirs.data_dir();
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)?)); 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");
@ -29,8 +44,21 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
crate::ipc::start_server(db_for_ipc, crypto_for_ipc, &socket_path); crate::ipc::start_server(db_for_ipc, crypto_for_ipc, &socket_path);
}); });
println!("rklipd démarrage..."); if let Some(days) = config.expiry_days {
monitor::start(db, clipboard)?; let db_for_expiry = Arc::clone(&db);
std::thread::spawn(move || {
loop {
std::thread::sleep(Duration::from_secs(3600));
let lock = db_for_expiry.lock().unwrap();
match lock.delete_entries_older_than(days) {
Ok(0) => {}
Ok(n) => println!("Expiration : {n} entrée(s) supprimée(s) (> {days} jours)"),
Err(e) => eprintln!("Erreur expiration : {e}"),
}
}
});
}
monitor::start(db, clipboard)?;
Ok(()) Ok(())
} }

View File

@ -4,6 +4,7 @@ use chrono::{Local, NaiveDate, TimeZone};
use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2}; use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
use ratatui::widgets::ListState; use ratatui::widgets::ListState;
use ratatui_image::{picker::Picker, protocol}; use ratatui_image::{picker::Picker, protocol};
use regex::Regex;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use syntect::highlighting::ThemeSet; use syntect::highlighting::ThemeSet;
use syntect::parsing::SyntaxSet; use syntect::parsing::SyntaxSet;
@ -43,6 +44,32 @@ pub struct App {
pub status_message: Option<(String, Instant)>, pub status_message: Option<(String, Instant)>,
pub syntax_set: SyntaxSet, pub syntax_set: SyntaxSet,
pub theme_set: ThemeSet, pub theme_set: ThemeSet,
pub type_filter: TypeFilter,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TypeFilter {
All,
Text,
Image,
}
impl TypeFilter {
pub fn next(self) -> Self {
match self {
Self::All => Self::Text,
Self::Text => Self::Image,
Self::Image => Self::All,
}
}
pub fn label(self) -> &'static str {
match self {
Self::All => "Tous",
Self::Text => "Texte",
Self::Image => "Image",
}
}
} }
impl App { impl App {
@ -76,6 +103,7 @@ impl App {
status_message: None, status_message: None,
syntax_set: SyntaxSet::load_defaults_newlines(), syntax_set: SyntaxSet::load_defaults_newlines(),
theme_set: ThemeSet::load_defaults(), theme_set: ThemeSet::load_defaults(),
type_filter: TypeFilter::All,
}; };
app.update_preview(); app.update_preview();
app app
@ -99,7 +127,14 @@ impl App {
} }
} }
pub fn cycle_type_filter(&mut self) {
self.type_filter = self.type_filter.next();
self.update_search();
}
pub fn update_search(&mut self) { pub fn update_search(&mut self) {
self.last_selected_index = None;
let query = self.input_buffer.trim().to_string(); let query = self.input_buffer.trim().to_string();
let (date_before, date_after, text_query) = parse_date_filters(&query); let (date_before, date_after, text_query) = parse_date_filters(&query);
@ -123,22 +158,45 @@ impl App {
.cloned() .cloned()
.collect(); .collect();
let search_str = |item: &HistoryItem| -> String {
if Crypto::is_any_encrypted(&item.content) {
"[chiffré]".to_string()
} else if item.content.ends_with(".jpg") || item.content.ends_with(".png") {
format!("image {}", item.content)
} else {
item.content.clone()
}
};
let is_regex = text_query.starts_with('/') && text_query.len() > 1;
self.filtered_items = if text_query.is_empty() { self.filtered_items = if text_query.is_empty() {
base base
} else if is_regex {
let pattern = &text_query[1..];
match Regex::new(pattern) {
Ok(re) => base
.into_iter()
.filter(|item| re.is_match(&search_str(item)))
.collect(),
Err(e) => {
self.error_message = Some((
format!(
"Regex invalide : {}",
e.to_string().lines().next().unwrap_or("")
),
Instant::now(),
));
base
}
}
} else { } else {
let matcher = SkimMatcherV2::default(); let matcher = SkimMatcherV2::default();
let mut matched: Vec<(i64, HistoryItem)> = base let mut matched: Vec<(i64, HistoryItem)> = base
.into_iter() .into_iter()
.filter_map(|item| { .filter_map(|item| {
let search_str = if Crypto::is_any_encrypted(&item.content) {
"[chiffré]".to_string()
} else if item.content.ends_with(".jpg") || item.content.ends_with(".png") {
format!("image {}", item.content)
} else {
item.content.clone()
};
matcher matcher
.fuzzy_match(&search_str, &text_query) .fuzzy_match(&search_str(&item), &text_query)
.map(|s| (s, item)) .map(|s| (s, item))
}) })
.collect(); .collect();
@ -146,11 +204,18 @@ 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 {
Some(0) Some(0)
}); });
self.update_preview(); self.update_preview();
} }
@ -208,21 +273,14 @@ impl App {
return; return;
} }
self.crypto = None;
if Crypto::is_password_encrypted(&content) { if Crypto::is_password_encrypted(&content) {
if self.crypto.is_none() { self.pending_action = Some(PendingAction::DecryptSelected);
self.pending_action = Some(PendingAction::DecryptSelected);
self.enter_password_mode();
} else {
self.do_decrypt_selected();
}
} else { } else {
if self.crypto.is_none() { self.pending_action = Some(PendingAction::EncryptSelected);
self.pending_action = Some(PendingAction::EncryptSelected);
self.enter_password_mode();
} else {
self.do_encrypt_selected();
}
} }
self.enter_password_mode();
} }
fn enter_password_mode(&mut self) { fn enter_password_mode(&mut self) {
@ -260,6 +318,8 @@ impl App {
None => return, None => return,
}; };
self.crypto = None;
match encrypt_result { match encrypt_result {
Ok(enc) => { Ok(enc) => {
if ipc::update_entry(content.clone(), enc.clone()) { if ipc::update_entry(content.clone(), enc.clone()) {
@ -284,6 +344,8 @@ impl App {
None => return, None => return,
}; };
self.crypto = None;
match decrypt_result { match decrypt_result {
Ok(plain) => { Ok(plain) => {
if ipc::update_entry(content.clone(), plain.clone()) { if ipc::update_entry(content.clone(), plain.clone()) {
@ -293,7 +355,11 @@ impl App {
self.set_error("Erreur mise à jour BDD".into()); self.set_error("Erreur mise à jour BDD".into());
} }
} }
Err(e) => self.set_error(format!("{e}")), Err(_) => {
self.set_error(
"Déchiffrement échoué — mauvais mot de passe. Réessayez avec [e].".into(),
);
}
} }
} }
@ -308,12 +374,21 @@ impl App {
None => return, None => return,
}; };
self.crypto = None;
match decrypt_result { match decrypt_result {
Ok(plain) => { Ok(plain) => {
ipc::set_clipboard(plain); if ipc::set_clipboard(plain) {
self.should_quit = true; self.should_quit = true;
} else {
self.set_error("Erreur : impossible de définir le presse-papier".into());
}
}
Err(_) => {
self.set_error(
"Déchiffrement échoué — mauvais mot de passe. Réessayez avec Entrée.".into(),
);
} }
Err(e) => self.set_error(format!("{e}")),
} }
} }
@ -334,15 +409,17 @@ impl App {
None => return, None => return,
}; };
if Crypto::is_password_encrypted(&content) { if Crypto::is_password_encrypted(&content) {
if self.crypto.is_none() { self.crypto = None;
self.pending_action = Some(PendingAction::PasteEncrypted); self.pending_action = Some(PendingAction::PasteEncrypted);
self.enter_password_mode(); self.enter_password_mode();
} else {
self.do_paste_encrypted();
}
} else { } else {
ipc::set_clipboard(content); if ipc::set_clipboard(content) {
self.should_quit = true; self.should_quit = true;
} else {
self.set_error(
"Impossible de définir le presse-papier (daemon injoignable ?)".into(),
);
}
} }
} }
@ -436,9 +513,24 @@ impl App {
.iter() .iter()
.zip(&new) .zip(&new)
.any(|(a, b)| a.content != b.content); .any(|(a, b)| a.content != b.content);
if changed { if changed {
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(pos) = self
.filtered_items
.iter()
.position(|x| x.content == content)
{
self.list_state.select(Some(pos));
self.last_selected_index = None;
self.update_preview();
}
}
} }
} }
} }

View File

@ -56,8 +56,11 @@ pub fn fetch_history(limit: usize) -> Option<Vec<HistoryItem>> {
} }
} }
pub fn set_clipboard(content: String) { pub fn set_clipboard(content: String) -> bool {
let _ = send_request(&IpcRequest::SetClipboard { content }); matches!(
send_request(&IpcRequest::SetClipboard { content }),
Some(IpcResponse::Ok)
)
} }
pub fn delete_entry(content: String) { pub fn delete_entry(content: String) {

View File

@ -58,14 +58,10 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
} }
match app.mode { match app.mode {
// Dans la boucle, Mode::Normal :
Mode::Normal => { Mode::Normal => {
match key.code { // FIX: les deux blocs match étaient redondants et mal nommés.
KeyCode::Char('d') | KeyCode::Char('g') => {} // On fusionne la logique en un seul bloc clair.
_ => {
last_d = false;
last_g = false;
}
}
match key.code { match key.code {
KeyCode::Enter => app.paste_selected(), KeyCode::Enter => app.paste_selected(),
KeyCode::Char('j') | KeyCode::Down => app.next(), KeyCode::Char('j') | KeyCode::Down => app.next(),
@ -76,8 +72,11 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
app.list_state.select(Some(l)); app.list_state.select(Some(l));
app.update_preview(); app.update_preview();
} }
last_d = false;
last_g = false;
} }
KeyCode::Char('g') => { KeyCode::Char('g') => {
last_d = false;
if last_g { if last_g {
if !app.filtered_items.is_empty() { if !app.filtered_items.is_empty() {
app.list_state.select(Some(0)); app.list_state.select(Some(0));
@ -89,6 +88,7 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
} }
} }
KeyCode::Char('d') => { KeyCode::Char('d') => {
last_g = false;
if last_d { if last_d {
app.mode = Mode::ConfirmDelete; app.mode = Mode::ConfirmDelete;
last_d = false; last_d = false;
@ -96,19 +96,39 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
last_d = true; last_d = true;
} }
} }
KeyCode::Char('u') => app.undo_delete(), KeyCode::Char('u') => {
KeyCode::Char('e') => app.toggle_encrypt(), 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('/') => { KeyCode::Char('/') => {
app.mode = Mode::Search; app.mode = Mode::Search;
app.input_buffer.clear(); app.input_buffer.clear();
app.update_search(); app.update_search();
last_d = false;
last_g = false;
} }
KeyCode::Char(':') => { KeyCode::Char(':') => {
app.mode = Mode::Command; app.mode = Mode::Command;
app.input_buffer.clear(); app.input_buffer.clear();
last_d = false;
last_g = false;
} }
KeyCode::Char('q') => app.should_quit = true, KeyCode::Char('q') => app.should_quit = true,
_ => {} _ => {
last_d = false;
last_g = false;
}
} }
} }

View File

@ -197,11 +197,37 @@ pub fn render(f: &mut Frame, app: &mut App) {
_ => String::new(), _ => 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 {
crate::app::TypeFilter::All => String::new(),
f => format!(" [{}]", f.label()),
};
let msg_span = if let Some((msg, _)) = &app.error_message { let msg_span = if let Some((msg, _)) = &app.error_message {
Span::styled(format!("{msg}"), Style::default().fg(Color::Red)) Span::styled(format!("{msg}"), Style::default().fg(Color::Red))
} else if let Some((msg, _)) = &app.status_message { } else if let Some((msg, _)) = &app.status_message {
Span::styled(format!("{msg}"), Style::default().fg(Color::Green)) Span::styled(format!("{msg}"), Style::default().fg(Color::Green))
} else { } else {
let extra = match &app.mode {
Mode::Search => {
// Indicateur visuel du mode de recherche actif (fuzzy vs regexp)
let mode_hint = if app.input_buffer.trim_start().starts_with('/') {
"re"
} else {
"~"
};
format!(" [{}] /{}{}", mode_hint, app.input_buffer, filter_hint)
}
Mode::Command => format!(" :{}", app.input_buffer),
Mode::PasswordInput => format!(" {}", "".repeat(app.input_buffer.len())),
_ => filter_hint,
};
Span::raw(extra) Span::raw(extra)
}; };