Files
rklip/src/app.rs
2026-05-20 23:34:50 +02:00

609 lines
19 KiB
Rust

use crate::crypto::Crypto;
use crate::ipc::{self, HistoryItem};
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;
const PREVIEW_MAX_WIDTH: u32 = 1280;
const PREVIEW_MAX_HEIGHT: u32 = 720;
#[derive(PartialEq, Clone)]
pub enum Mode {
Normal,
Command,
Search,
ConfirmDelete,
PasswordInput,
}
#[derive(Clone)]
pub enum PendingAction {
EncryptSelected,
DecryptSelected,
PasteEncrypted,
}
pub struct App {
pub mode: Mode,
pub all_items: Vec<HistoryItem>,
pub filtered_items: Vec<HistoryItem>,
pub list_state: ListState,
pub input_buffer: String,
pub should_quit: bool,
pub undo_stack: Vec<HistoryItem>,
pub current_image: Option<protocol::StatefulProtocol>,
pub last_selected_index: Option<usize>,
pub picker: Picker,
pub preview_scroll: u16,
pub crypto: Option<Crypto>,
pub salt: Vec<u8>,
pub pending_action: Option<PendingAction>,
pub error_message: Option<(String, Instant)>,
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 {
pub fn new() -> Self {
let items = ipc::fetch_history(200).unwrap_or_default();
let mut list_state = ListState::default();
list_state.select(if items.is_empty() { None } else { Some(0) });
let picker = Picker::from_query_stdio().unwrap_or_else(|_| Picker::halfblocks());
let salt = directories::ProjectDirs::from("com", "zefad", "rklipd")
.and_then(|d| Crypto::load_or_create_salt(d.data_dir()).ok())
.unwrap_or_else(|| vec![0u8; 32]);
let mut app = Self {
mode: Mode::Normal,
filtered_items: items.clone(),
all_items: items,
list_state,
input_buffer: String::new(),
should_quit: false,
undo_stack: Vec::new(),
current_image: None,
last_selected_index: None,
picker,
preview_scroll: 0,
crypto: None,
salt,
pending_action: None,
error_message: None,
status_message: None,
syntax_set: SyntaxSet::load_defaults_newlines(),
theme_set: ThemeSet::load_defaults(),
type_filter: TypeFilter::All,
};
app.update_preview();
app
}
pub fn format_timestamp(ts_ms: i64) -> String {
let secs = ts_ms / 1000;
let nsecs = ((ts_ms % 1000) * 1_000_000) as u32;
match Local.timestamp_opt(secs, nsecs) {
chrono::LocalResult::Single(dt) => {
let diff = Local::now().signed_duration_since(dt);
if diff.num_days() == 0 {
dt.format("%H:%M:%S").to_string()
} else if diff.num_days() < 365 {
dt.format("%d %b %H:%M").to_string()
} else {
dt.format("%d/%m/%Y").to_string()
}
}
_ => "?".to_string(),
}
}
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);
let base: Vec<HistoryItem> = self
.all_items
.iter()
.filter(|item| {
let ts_s = item.timestamp / 1000;
if let Some(before) = date_before {
if ts_s >= before {
return false;
}
}
if let Some(after) = date_after {
if ts_s < after {
return false;
}
}
true
})
.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| {
matcher
.fuzzy_match(&search_str(&item), &text_query)
.map(|s| (s, item))
})
.collect();
matched.sort_by(|a, b| b.0.cmp(&a.0));
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();
}
pub fn delete_selected(&mut self) {
if let Some(i) = self.list_state.selected() {
if i < self.filtered_items.len() {
let item = self.filtered_items.remove(i);
// On stocke juste l'item (plus l'index filtré qui était faux)
self.undo_stack.push(item.clone());
self.all_items.retain(|x| x.content != item.content);
let new_sel = if self.filtered_items.is_empty() {
None
} else if i >= self.filtered_items.len() {
Some(self.filtered_items.len() - 1)
} else {
Some(i)
};
self.list_state.select(new_sel);
}
}
self.update_preview();
}
pub fn undo_delete(&mut self) {
if let Some(item) = self.undo_stack.pop() {
ipc::add_entry(item.content.clone());
if let Some(new_items) = ipc::fetch_history(200) {
self.all_items = new_items;
} else {
self.all_items.insert(0, item.clone());
}
self.update_search();
if let Some(pos) = self
.filtered_items
.iter()
.position(|x| x.content == item.content)
{
self.list_state.select(Some(pos));
self.last_selected_index = None;
}
}
self.update_preview();
}
pub fn toggle_encrypt(&mut self) {
let content = match self.get_selected_item() {
Some(i) => i.content.clone(),
None => return,
};
if Crypto::is_legacy_encrypted(&content) {
self.set_error(
"Entrée chiffrée avec l'ancienne clé machine — non modifiable ici".into(),
);
return;
}
self.crypto = None;
if Crypto::is_password_encrypted(&content) {
self.pending_action = Some(PendingAction::DecryptSelected);
} else {
self.pending_action = Some(PendingAction::EncryptSelected);
}
self.enter_password_mode();
}
fn enter_password_mode(&mut self) {
self.mode = Mode::PasswordInput;
self.input_buffer.clear();
}
pub fn apply_password(&mut self, password: String) {
if password.is_empty() {
self.set_error("Mot de passe vide".into());
return;
}
match Crypto::from_password(&password, &self.salt) {
Ok(crypto) => {
self.crypto = Some(crypto);
match self.pending_action.take() {
Some(PendingAction::EncryptSelected) => self.do_encrypt_selected(),
Some(PendingAction::DecryptSelected) => self.do_decrypt_selected(),
Some(PendingAction::PasteEncrypted) => self.do_paste_encrypted(),
None => self.set_status("Mot de passe défini pour la session".into()),
}
}
Err(e) => self.set_error(format!("Erreur crypto : {e}")),
}
}
fn do_encrypt_selected(&mut self) {
let content = match self.get_selected_item() {
Some(i) => i.content.clone(),
None => return,
};
let encrypt_result = match &self.crypto {
Some(key) => key.encrypt(&content),
None => return,
};
self.crypto = None;
match encrypt_result {
Ok(enc) => {
if ipc::update_entry(content.clone(), enc.clone()) {
self.replace_content(&content, enc);
self.set_status("Entrée chiffrée 🔒".into());
} else {
self.set_error("Erreur mise à jour BDD".into());
}
}
Err(e) => self.set_error(format!("Chiffrement : {e}")),
}
}
fn do_decrypt_selected(&mut self) {
let content = match self.get_selected_item() {
Some(i) => i.content.clone(),
None => return,
};
let decrypt_result = match &self.crypto {
Some(key) => key.decrypt(&content),
None => return,
};
self.crypto = None;
match decrypt_result {
Ok(plain) => {
if ipc::update_entry(content.clone(), plain.clone()) {
self.replace_content(&content, plain);
self.set_status("Entrée déchiffrée".into());
} else {
self.set_error("Erreur mise à jour BDD".into());
}
}
Err(_) => {
self.set_error(
"Déchiffrement échoué — mauvais mot de passe. Réessayez avec [e].".into(),
);
}
}
}
fn do_paste_encrypted(&mut self) {
let content = match self.get_selected_item() {
Some(i) => i.content.clone(),
None => return,
};
let decrypt_result = match &self.crypto {
Some(key) => key.decrypt(&content),
None => return,
};
self.crypto = None;
match decrypt_result {
Ok(plain) => {
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(),
);
}
}
}
fn replace_content(&mut self, old: &str, new: String) {
if let Some(p) = self.all_items.iter().position(|x| x.content == old) {
self.all_items[p].content = new.clone();
}
if let Some(p) = self.filtered_items.iter().position(|x| x.content == old) {
self.filtered_items[p].content = new;
}
self.last_selected_index = None;
self.update_preview();
}
pub fn paste_selected(&mut self) {
let content = match self.get_selected_item().map(|i| i.content.clone()) {
Some(c) => c,
None => return,
};
if Crypto::is_password_encrypted(&content) {
self.crypto = None;
self.pending_action = Some(PendingAction::PasteEncrypted);
self.enter_password_mode();
} else if ipc::set_clipboard(content) {
self.should_quit = true;
} else {
self.set_error("Impossible de définir le presse-papier (daemon injoignable ?)".into());
}
}
pub fn clear_history(&mut self) {
if ipc::clear_history() {
self.all_items.clear();
self.filtered_items.clear();
self.undo_stack.clear();
self.list_state.select(None);
self.current_image = None;
self.set_status("Historique effacé".into());
} else {
self.set_error("Erreur lors de l'effacement".into());
}
}
pub fn update_preview(&mut self) {
let idx = self.list_state.selected();
if self.last_selected_index == idx {
return;
}
self.last_selected_index = idx;
self.current_image = None;
self.preview_scroll = 0;
let content = match self.get_selected_item().map(|i| i.content.clone()) {
Some(c) => c,
None => return,
};
if content.ends_with(".jpg") || content.ends_with(".png") {
if let Some(dirs) = directories::ProjectDirs::from("com", "zefad", "rklipd") {
let path = dirs.data_dir().join("images").join(&content);
if path.exists() {
if let Ok(img) = image::open(&path) {
let img = if img.width() > PREVIEW_MAX_WIDTH
|| img.height() > PREVIEW_MAX_HEIGHT
{
img.thumbnail(PREVIEW_MAX_WIDTH, PREVIEW_MAX_HEIGHT)
} else {
img
};
self.current_image = Some(self.picker.new_resize_protocol(img));
}
}
}
}
}
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);
}
pub fn next(&mut self) {
if self.filtered_items.is_empty() {
return;
}
let i = self.list_state.selected().map_or(0, |i| {
if i >= self.filtered_items.len() - 1 {
0
} else {
i + 1
}
});
self.list_state.select(Some(i));
self.update_preview();
}
pub fn previous(&mut self) {
if self.filtered_items.is_empty() {
return;
}
let i = self.list_state.selected().map_or(0, |i| {
if i == 0 {
self.filtered_items.len() - 1
} else {
i - 1
}
});
self.list_state.select(Some(i));
self.update_preview();
}
pub fn get_selected_item(&self) -> Option<&HistoryItem> {
self.list_state
.selected()
.and_then(|i| self.filtered_items.get(i))
}
pub fn sync_with_daemon(&mut self) {
if let Some(new) = ipc::fetch_history(200) {
let changed = self.all_items.len() != new.len()
|| self
.all_items
.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();
}
}
}
}
}
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()));
}
pub fn tick_messages(&mut self) {
let ttl = Duration::from_secs(3);
if self
.error_message
.as_ref()
.map_or(false, |(_, t)| t.elapsed() > ttl)
{
self.error_message = None;
}
if self
.status_message
.as_ref()
.map_or(false, |(_, t)| t.elapsed() > ttl)
{
self.status_message = None;
}
}
}
fn parse_date_filters(query: &str) -> (Option<i64>, Option<i64>, String) {
let mut before = None;
let mut after = None;
let mut rest = Vec::new();
for token in query.split_whitespace() {
if let Some(d) = token.strip_prefix("before:") {
if let Some(ts) = parse_date(d) {
before = Some(ts);
continue;
}
}
if let Some(d) = token.strip_prefix("after:") {
if let Some(ts) = parse_date(d) {
after = Some(ts);
continue;
}
}
rest.push(token);
}
(before, after, rest.join(" "))
}
fn parse_date(s: &str) -> Option<i64> {
if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
let dt = d.and_hms_opt(0, 0, 0)?;
return Some(Local.from_local_datetime(&dt).single()?.timestamp());
}
if let Ok(d) = NaiveDate::parse_from_str(&format!("{s}-01"), "%Y-%m-%d") {
let dt = d.and_hms_opt(0, 0, 0)?;
return Some(Local.from_local_datetime(&dt).single()?.timestamp());
}
if let Ok(d) = NaiveDate::parse_from_str(&format!("{s}-01-01"), "%Y-%m-%d") {
let dt = d.and_hms_opt(0, 0, 0)?;
return Some(Local.from_local_datetime(&dt).single()?.timestamp());
}
None
}