From d173db334264b002610ef816e35d695490a2aec4 Mon Sep 17 00:00:00 2001 From: zeefaad Date: Wed, 20 May 2026 23:26:01 +0200 Subject: [PATCH] correction + regexp --- Cargo.lock | 1 + Cargo.toml | 1 + rklipd/src/config.rs | 70 ++++++++++++++++++ rklipd/src/crypto.rs | 21 ++++-- rklipd/src/database.rs | 111 +++++++++++++++++++++++++---- rklipd/src/ipc.rs | 31 ++++++-- rklipd/src/main.rs | 34 ++++++++- src/app.rs | 156 ++++++++++++++++++++++++++++++++--------- src/ipc.rs | 7 +- src/main.rs | 40 ++++++++--- src/ui.rs | 26 +++++++ 11 files changed, 425 insertions(+), 73 deletions(-) create mode 100644 rklipd/src/config.rs diff --git a/Cargo.lock b/Cargo.lock index ef0fee4..761db55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2464,6 +2464,7 @@ dependencies = [ "image", "ratatui", "ratatui-image", + "regex", "rklipd", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index b5baa56..12d5aa3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ fuzzy-matcher = "0.3.7" image = "0.25.9" ratatui = "0.30.0" ratatui-image = { version = "10.0.6", features = ["crossterm"] } +regex = "1.12.3" rklipd = {path = "rklipd"} serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" diff --git a/rklipd/src/config.rs b/rklipd/src/config.rs new file mode 100644 index 0000000..2bc5d02 --- /dev/null +++ b/rklipd/src/config.rs @@ -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, +} + +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 = 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::().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 Nombre max d'entrées (défaut: 500) + --max-entry-size-kb Taille max d'une entrée en Ko (défaut: 512) + --expiry-days 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 + } +} diff --git a/rklipd/src/crypto.rs b/rklipd/src/crypto.rs index 3f68e6e..03c35f3 100644 --- a/rklipd/src/crypto.rs +++ b/rklipd/src/crypto.rs @@ -7,6 +7,9 @@ use std::error::Error; use std::fs; use std::path::Path; +const ENC_PREFIX: &str = "enc:"; +const ENC2_PREFIX: &str = "enc2:"; + pub struct Crypto { key: [u8; 32], } @@ -45,13 +48,13 @@ impl Crypto { .map_err(|e| format!("Erreur de chiffrement : {e}"))?; let mut combined = nonce.to_vec(); 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> { let encoded = encrypted - .strip_prefix("enc:") - .ok_or("Pas une entrée chiffrée")?; + .strip_prefix(ENC_PREFIX) + .ok_or("Pas une entrée chiffrée (enc:)")?; let combined = BASE64.decode(encoded)?; if combined.len() < 12 { return Err("Données chiffrées trop courtes".into()); @@ -66,7 +69,15 @@ impl Crypto { Ok(String::from_utf8(plaintext)?) } - pub fn is_encrypted(content: &str) -> bool { - content.starts_with("enc:") + pub fn is_legacy_encrypted(content: &str) -> bool { + 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) } } diff --git a/rklipd/src/database.rs b/rklipd/src/database.rs index 4f2e6ca..89b9332 100644 --- a/rklipd/src/database.rs +++ b/rklipd/src/database.rs @@ -1,3 +1,4 @@ +use crate::config::Config; use crate::models::{ClipboardData, ClipboardEntry, Image}; use image::codecs::jpeg::JpegEncoder; use image::{ExtendedColorType, ImageEncoder}; @@ -5,46 +6,55 @@ use rusqlite::Connection; use std::error::Error; use std::fs; use std::path::Path; -use std::time::{Duration, UNIX_EPOCH}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use uuid::Uuid; pub struct Database { conn: Connection, dir_path: String, + max_entries: usize, + max_entry_size_bytes: usize, } impl Database { - pub fn init(dir_path: &str) -> Result> { + pub fn init(dir_path: &str, config: &Config) -> Result> { let base_path = Path::new(dir_path); fs::create_dir_all(base_path.join("images"))?; let conn = Connection::open(base_path.join("clipboard.db"))?; 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);", - )?; + "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 - )", + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + content TEXT 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( "DELETE FROM history WHERE id NOT IN ( - SELECT MAX(id) FROM history GROUP BY content - ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_history_content ON history(content);", + SELECT MAX(id) FROM history GROUP BY content + );", )?; Ok(Self { conn, 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() { return Ok(()); } + if t.len() > self.max_entry_size_bytes { + return Ok(()); + } ("text", t.clone()) } ClipboardData::Image(img) => { 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 file = fs::File::create(&path)?; let rgb: Vec = px @@ -81,6 +97,45 @@ impl Database { "INSERT OR REPLACE INTO history (type, content, timestamp) VALUES (?1, ?2, ?3)", (kind, &content, ts), )?; + + self.trim_to_max()?; + + Ok(()) + } + + fn trim_to_max(&self) -> Result<(), Box> { + 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 = 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(()) } @@ -137,14 +192,40 @@ impl Database { Ok(()) } + pub fn delete_entries_older_than(&self, days: u64) -> Result> { + 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 = 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> { let images_dir = Path::new(&self.dir_path).join("images"); - if images_dir.exists() { fs::remove_dir_all(&images_dir)?; } fs::create_dir_all(&images_dir)?; - self.conn.execute("DELETE FROM history", [])?; Ok(()) } diff --git a/rklipd/src/ipc.rs b/rklipd/src/ipc.rs index a879267..e91ec43 100644 --- a/rklipd/src/ipc.rs +++ b/rklipd/src/ipc.rs @@ -119,12 +119,23 @@ pub fn start_server(db: Arc>, crypto: Arc, socket_path: } IpcRequest::SetClipboard { content } => { - let actual = - if content.starts_with("enc:") || content.starts_with("enc2:") { - crypto_clone.decrypt(&content).unwrap_or(content) - } else { - 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) => { @@ -142,6 +153,14 @@ pub fn start_server(db: Arc>, crypto: Arc, socket_path: height: h, bytes: std::borrow::Cow::Owned(rgba.into_raw()), }); + } else { + reply( + &mut stream, + IpcResponse::Error(format!( + "Image introuvable : {actual}" + )), + ); + return; } } } else { diff --git a/rklipd/src/main.rs b/rklipd/src/main.rs index 993e866..24bb575 100644 --- a/rklipd/src/main.rs +++ b/rklipd/src/main.rs @@ -1,3 +1,4 @@ +mod config; mod crypto; mod database; mod ipc; @@ -5,13 +6,27 @@ mod models; mod monitor; mod ws; +use crate::config::Config; use crate::crypto::Crypto; use crate::database::Database; use arboard::Clipboard; use directories::ProjectDirs; use std::sync::{Arc, Mutex}; +use std::time::Duration; fn main() -> Result<(), Box> { + 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 proj_dirs = @@ -19,7 +34,7 @@ fn main() -> Result<(), Box> { let dir_path = proj_dirs.data_dir(); 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 socket_path = dir_path.join("rklip.sock"); @@ -29,8 +44,21 @@ fn main() -> Result<(), Box> { crate::ipc::start_server(db_for_ipc, crypto_for_ipc, &socket_path); }); - println!("rklipd démarrage..."); - monitor::start(db, clipboard)?; + if let Some(days) = config.expiry_days { + 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(()) } diff --git a/src/app.rs b/src/app.rs index eb6283a..880dd70 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,6 +4,7 @@ use chrono::{Local, NaiveDate, TimeZone}; use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2}; use ratatui::widgets::ListState; use ratatui_image::{picker::Picker, protocol}; +use regex::Regex; use std::time::{Duration, Instant}; use syntect::highlighting::ThemeSet; use syntect::parsing::SyntaxSet; @@ -43,6 +44,32 @@ pub struct App { pub status_message: Option<(String, Instant)>, pub syntax_set: SyntaxSet, 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 { @@ -76,6 +103,7 @@ impl App { status_message: None, syntax_set: SyntaxSet::load_defaults_newlines(), theme_set: ThemeSet::load_defaults(), + type_filter: TypeFilter::All, }; app.update_preview(); 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) { + self.last_selected_index = None; + let query = self.input_buffer.trim().to_string(); let (date_before, date_after, text_query) = parse_date_filters(&query); @@ -123,22 +158,45 @@ impl App { .cloned() .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() { 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 { let matcher = SkimMatcherV2::default(); let mut matched: Vec<(i64, HistoryItem)> = base .into_iter() .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 - .fuzzy_match(&search_str, &text_query) + .fuzzy_match(&search_str(&item), &text_query) .map(|s| (s, item)) }) .collect(); @@ -146,11 +204,18 @@ impl App { 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() { None } else { Some(0) }); + self.update_preview(); } @@ -208,21 +273,14 @@ impl App { return; } + self.crypto = None; + if Crypto::is_password_encrypted(&content) { - if self.crypto.is_none() { - self.pending_action = Some(PendingAction::DecryptSelected); - self.enter_password_mode(); - } else { - self.do_decrypt_selected(); - } + self.pending_action = Some(PendingAction::DecryptSelected); } else { - if self.crypto.is_none() { - self.pending_action = Some(PendingAction::EncryptSelected); - self.enter_password_mode(); - } else { - self.do_encrypt_selected(); - } + self.pending_action = Some(PendingAction::EncryptSelected); } + self.enter_password_mode(); } fn enter_password_mode(&mut self) { @@ -260,6 +318,8 @@ impl App { None => return, }; + self.crypto = None; + match encrypt_result { Ok(enc) => { if ipc::update_entry(content.clone(), enc.clone()) { @@ -284,6 +344,8 @@ impl App { None => return, }; + self.crypto = None; + match decrypt_result { Ok(plain) => { if ipc::update_entry(content.clone(), plain.clone()) { @@ -293,7 +355,11 @@ impl App { 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, }; + self.crypto = None; + match decrypt_result { Ok(plain) => { - ipc::set_clipboard(plain); - self.should_quit = true; + if ipc::set_clipboard(plain) { + 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, }; if Crypto::is_password_encrypted(&content) { - if self.crypto.is_none() { - self.pending_action = Some(PendingAction::PasteEncrypted); - self.enter_password_mode(); - } else { - self.do_paste_encrypted(); - } + self.crypto = None; + self.pending_action = Some(PendingAction::PasteEncrypted); + self.enter_password_mode(); } else { - ipc::set_clipboard(content); - self.should_quit = true; + if ipc::set_clipboard(content) { + 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() .zip(&new) .any(|(a, b)| a.content != b.content); + 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 + .iter() + .position(|x| x.content == content) + { + self.list_state.select(Some(pos)); + self.last_selected_index = None; + self.update_preview(); + } + } } } } diff --git a/src/ipc.rs b/src/ipc.rs index 83398a4..3b4a482 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -56,8 +56,11 @@ pub fn fetch_history(limit: usize) -> Option> { } } -pub fn set_clipboard(content: String) { - let _ = send_request(&IpcRequest::SetClipboard { content }); +pub fn set_clipboard(content: String) -> bool { + matches!( + send_request(&IpcRequest::SetClipboard { content }), + Some(IpcResponse::Ok) + ) } pub fn delete_entry(content: String) { diff --git a/src/main.rs b/src/main.rs index 1f6a629..1c8a3ab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -58,14 +58,10 @@ fn run_app(terminal: &mut Terminal>, app: &mut App) } match app.mode { + // Dans la boucle, Mode::Normal : Mode::Normal => { - match key.code { - KeyCode::Char('d') | KeyCode::Char('g') => {} - _ => { - last_d = false; - last_g = false; - } - } + // FIX: les deux blocs match étaient redondants et mal nommés. + // On fusionne la logique en un seul bloc clair. match key.code { KeyCode::Enter => app.paste_selected(), KeyCode::Char('j') | KeyCode::Down => app.next(), @@ -76,8 +72,11 @@ fn run_app(terminal: &mut Terminal>, app: &mut App) 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)); @@ -89,6 +88,7 @@ fn run_app(terminal: &mut Terminal>, app: &mut App) } } KeyCode::Char('d') => { + last_g = false; if last_d { app.mode = Mode::ConfirmDelete; last_d = false; @@ -96,19 +96,39 @@ fn run_app(terminal: &mut Terminal>, app: &mut App) last_d = true; } } - KeyCode::Char('u') => app.undo_delete(), - KeyCode::Char('e') => app.toggle_encrypt(), + 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; + } } } diff --git a/src/ui.rs b/src/ui.rs index 02928cf..dfb522b 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -197,11 +197,37 @@ pub fn render(f: &mut Frame, app: &mut App) { _ => 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 { 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 { + 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) };