refonte
This commit is contained in:
473
src/app.rs
473
src/app.rs
@ -1,45 +1,62 @@
|
||||
use crate::ipc;
|
||||
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 std::time::{Duration, Instant};
|
||||
use syntect::highlighting::ThemeSet;
|
||||
use syntect::parsing::SyntaxSet;
|
||||
|
||||
#[derive(PartialEq)]
|
||||
#[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<String>,
|
||||
pub filtered_items: Vec<String>,
|
||||
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<(usize, String)>,
|
||||
pub undo_stack: Vec<(usize, 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,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new() -> Self {
|
||||
let items = ipc::fetch_history(200).unwrap_or_default();
|
||||
let mut list_state = ListState::default();
|
||||
list_state.select(Some(0));
|
||||
|
||||
let items = ipc::fetch_history(100).unwrap_or_default();
|
||||
|
||||
let mut list_state = ListState::default();
|
||||
if items.is_empty() {
|
||||
list_state.select(None);
|
||||
} else {
|
||||
list_state.select(Some(0));
|
||||
}
|
||||
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(),
|
||||
@ -51,57 +68,107 @@ impl App {
|
||||
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(),
|
||||
};
|
||||
|
||||
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 update_search(&mut self) {
|
||||
if self.input_buffer.is_empty() {
|
||||
self.filtered_items = self.all_items.clone();
|
||||
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();
|
||||
|
||||
self.filtered_items = if text_query.is_empty() {
|
||||
base
|
||||
} else {
|
||||
let matcher = SkimMatcherV2::default();
|
||||
|
||||
let mut matched: Vec<(i64, String)> = self
|
||||
.all_items
|
||||
.iter()
|
||||
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(item, &self.input_buffer)
|
||||
.map(|score| (score, item.clone()))
|
||||
.fuzzy_match(&search_str, &text_query)
|
||||
.map(|s| (s, item))
|
||||
})
|
||||
.collect();
|
||||
|
||||
matched.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
|
||||
self.filtered_items = matched.into_iter().map(|(_, item)| item).collect();
|
||||
self.update_preview();
|
||||
}
|
||||
matched.into_iter().map(|(_, i)| i).collect()
|
||||
};
|
||||
|
||||
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_to_remove = self.filtered_items.remove(i);
|
||||
let item = self.filtered_items.remove(i);
|
||||
self.undo_stack.push((i, item.clone()));
|
||||
self.all_items.retain(|x| x.content != item.content);
|
||||
|
||||
self.undo_stack.push((i, item_to_remove.clone()));
|
||||
|
||||
if let Some(pos) = self.all_items.iter().position(|x| *x == item_to_remove) {
|
||||
self.all_items.remove(pos);
|
||||
}
|
||||
|
||||
if self.filtered_items.is_empty() {
|
||||
self.list_state.select(None);
|
||||
let new_sel = if self.filtered_items.is_empty() {
|
||||
None
|
||||
} else if i >= self.filtered_items.len() {
|
||||
self.list_state.select(Some(self.filtered_items.len() - 1));
|
||||
}
|
||||
Some(self.filtered_items.len() - 1)
|
||||
} else {
|
||||
Some(i)
|
||||
};
|
||||
self.list_state.select(new_sel);
|
||||
}
|
||||
}
|
||||
self.update_preview();
|
||||
@ -109,56 +176,233 @@ impl App {
|
||||
|
||||
pub fn undo_delete(&mut self) {
|
||||
if let Some((i, item)) = self.undo_stack.pop() {
|
||||
self.all_items.insert(i, item.clone());
|
||||
ipc::add_entry(item.content.clone());
|
||||
let pos = i.min(self.all_items.len());
|
||||
self.all_items.insert(pos, item.clone());
|
||||
self.update_search();
|
||||
self.list_state.select(Some(i));
|
||||
let sel = self
|
||||
.filtered_items
|
||||
.iter()
|
||||
.position(|x| x.content == item.content)
|
||||
.unwrap_or(0)
|
||||
.min(self.filtered_items.len().saturating_sub(1));
|
||||
self.list_state.select(if self.filtered_items.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(sel)
|
||||
});
|
||||
}
|
||||
self.update_preview();
|
||||
}
|
||||
|
||||
pub fn update_preview(&mut self) {
|
||||
let current_index = self.list_state.selected();
|
||||
pub fn toggle_encrypt(&mut self) {
|
||||
let content = match self.get_selected_item() {
|
||||
Some(i) => i.content.clone(),
|
||||
None => return,
|
||||
};
|
||||
|
||||
if self.last_selected_index == current_index {
|
||||
if Crypto::is_legacy_encrypted(&content) {
|
||||
self.set_error(
|
||||
"Entrée chiffrée avec l'ancienne clé machine — non modifiable ici".into(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.last_selected_index = current_index;
|
||||
|
||||
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();
|
||||
}
|
||||
} else {
|
||||
if self.crypto.is_none() {
|
||||
self.pending_action = Some(PendingAction::EncryptSelected);
|
||||
self.enter_password_mode();
|
||||
} else {
|
||||
self.do_encrypt_selected();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
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(e) => self.set_error(format!("{e}")),
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
match decrypt_result {
|
||||
Ok(plain) => {
|
||||
ipc::set_clipboard(plain);
|
||||
self.should_quit = true;
|
||||
}
|
||||
Err(e) => self.set_error(format!("{e}")),
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
if self.crypto.is_none() {
|
||||
self.pending_action = Some(PendingAction::PasteEncrypted);
|
||||
self.enter_password_mode();
|
||||
} else {
|
||||
self.do_paste_encrypted();
|
||||
}
|
||||
} else {
|
||||
ipc::set_clipboard(content);
|
||||
self.should_quit = true;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if let Some(selected_text) = self.get_selected_item() {
|
||||
// To change later with entry type
|
||||
if selected_text.ends_with(".jpg") || selected_text.ends_with(".png") {
|
||||
let base_dir = directories::ProjectDirs::from("com", "zefad", "rklipd")
|
||||
.expect("No home dir")
|
||||
.data_dir()
|
||||
.to_path_buf();
|
||||
let content = match self.get_selected_item().map(|i| i.content.clone()) {
|
||||
Some(c) => c,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let img_path = base_dir.join("images").join(selected_text);
|
||||
|
||||
if img_path.exists() {
|
||||
if let Ok(img) = image::open(&img_path) {
|
||||
let protocol = self.picker.new_resize_protocol(img);
|
||||
self.current_image = Some(protocol);
|
||||
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) {
|
||||
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 = match self.list_state.selected() {
|
||||
Some(i) => {
|
||||
if i >= self.filtered_items.len() - 1 {
|
||||
0
|
||||
} else {
|
||||
i + 1
|
||||
}
|
||||
let i = self.list_state.selected().map_or(0, |i| {
|
||||
if i >= self.filtered_items.len() - 1 {
|
||||
0
|
||||
} else {
|
||||
i + 1
|
||||
}
|
||||
None => 0,
|
||||
};
|
||||
});
|
||||
self.list_state.select(Some(i));
|
||||
self.update_preview();
|
||||
}
|
||||
@ -167,32 +411,99 @@ impl App {
|
||||
if self.filtered_items.is_empty() {
|
||||
return;
|
||||
}
|
||||
let i = match self.list_state.selected() {
|
||||
Some(i) => {
|
||||
if i == 0 {
|
||||
self.filtered_items.len() - 1
|
||||
} else {
|
||||
i - 1
|
||||
}
|
||||
let i = self.list_state.selected().map_or(0, |i| {
|
||||
if i == 0 {
|
||||
self.filtered_items.len() - 1
|
||||
} else {
|
||||
i - 1
|
||||
}
|
||||
None => 0,
|
||||
};
|
||||
});
|
||||
self.list_state.select(Some(i));
|
||||
self.update_preview();
|
||||
}
|
||||
|
||||
pub fn get_selected_item(&self) -> Option<&String> {
|
||||
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_history) = crate::ipc::fetch_history(100) {
|
||||
if self.all_items != new_history {
|
||||
self.all_items = new_history;
|
||||
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 {
|
||||
self.all_items = new;
|
||||
self.update_search();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user