opti
This commit is contained in:
@ -20,3 +20,6 @@ serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
syntect = "5.3.0"
|
||||
uuid = "1.22.0"
|
||||
|
||||
[profile.release]
|
||||
debug = true
|
||||
|
||||
BIN
profile.json.gz
Normal file
BIN
profile.json.gz
Normal file
Binary file not shown.
@ -20,3 +20,6 @@ x11rb = "0.13.2"
|
||||
[features]
|
||||
x11 = []
|
||||
wayland = []
|
||||
|
||||
[profile.release]
|
||||
debug = true
|
||||
|
||||
BIN
rklipd/profile.json.gz
Normal file
BIN
rklipd/profile.json.gz
Normal file
Binary file not shown.
@ -24,27 +24,31 @@ impl Config {
|
||||
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");
|
||||
i += 1;
|
||||
match args.get(i).and_then(|s| s.parse::<usize>().ok()) {
|
||||
Some(0) => eprintln!("--max-entries doit être > 0"),
|
||||
Some(v) => cfg.max_entries = v,
|
||||
None => 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");
|
||||
i += 1;
|
||||
match args.get(i).and_then(|s| s.parse::<usize>().ok()) {
|
||||
Some(0) => eprintln!("--max-entry-size-kb doit être > 0"),
|
||||
Some(v) => cfg.max_entry_size_kb = v,
|
||||
None => {
|
||||
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");
|
||||
i += 1;
|
||||
match args.get(i).and_then(|s| s.parse::<u64>().ok()) {
|
||||
Some(0) => eprintln!(
|
||||
"--expiry-days doit être > 0 (0 supprimerait tout immédiatement)"
|
||||
),
|
||||
Some(v) => cfg.expiry_days = Some(v),
|
||||
None => eprintln!("--expiry-days requiert une valeur entière positive"),
|
||||
}
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
|
||||
@ -101,9 +101,7 @@ impl Database {
|
||||
ExtendedColorType::Rgb8,
|
||||
)?;
|
||||
}
|
||||
None => {
|
||||
return Ok(());
|
||||
}
|
||||
None => return Ok(()),
|
||||
}
|
||||
("image", format!("{}.jpg", img.id))
|
||||
}
|
||||
@ -115,7 +113,6 @@ impl Database {
|
||||
)?;
|
||||
|
||||
self.trim_to_max()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -124,8 +121,10 @@ impl Database {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let tx = self.conn.unchecked_transaction()?;
|
||||
|
||||
let image_files: Vec<String> = {
|
||||
let mut stmt = self.conn.prepare(
|
||||
let mut stmt = tx.prepare(
|
||||
"SELECT content FROM history
|
||||
WHERE type = 'image'
|
||||
AND id NOT IN (
|
||||
@ -137,13 +136,15 @@ impl Database {
|
||||
.collect()
|
||||
};
|
||||
|
||||
self.conn.execute(
|
||||
tx.execute(
|
||||
"DELETE FROM history WHERE id NOT IN (
|
||||
SELECT id FROM history ORDER BY timestamp DESC LIMIT ?1
|
||||
)",
|
||||
[self.max_entries as i64],
|
||||
)?;
|
||||
|
||||
tx.commit()?;
|
||||
|
||||
for filename in image_files {
|
||||
let path = Path::new(&self.dir_path).join("images").join(&filename);
|
||||
if path.exists() {
|
||||
@ -213,18 +214,19 @@ impl Database {
|
||||
let cutoff_ms = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as i64
|
||||
- (days as i64 * 86_400_000);
|
||||
|
||||
let tx = self.conn.unchecked_transaction()?;
|
||||
|
||||
let image_files: Vec<String> = {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT content FROM history WHERE type = 'image' AND timestamp < ?1")?;
|
||||
let mut stmt =
|
||||
tx.prepare("SELECT content FROM history WHERE type = 'image' AND timestamp < ?1")?;
|
||||
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])?;
|
||||
let count = tx.execute("DELETE FROM history WHERE timestamp < ?1", [cutoff_ms])?;
|
||||
|
||||
tx.commit()?;
|
||||
|
||||
for filename in image_files {
|
||||
let path = Path::new(&self.dir_path).join("images").join(&filename);
|
||||
|
||||
@ -48,13 +48,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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}"),
|
||||
{
|
||||
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}"),
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(3600));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,28 +1,42 @@
|
||||
use crate::database::Database;
|
||||
use crate::models::ClipboardEntry;
|
||||
use arboard::Clipboard;
|
||||
use std::error::Error;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
|
||||
pub fn start(db: Arc<Mutex<Database>>, clipboard: Clipboard) -> Result<(), Box<dyn Error>> {
|
||||
let (tx, rx) = mpsc::channel::<ClipboardEntry>();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
for entry in rx {
|
||||
let lock = db.lock().unwrap();
|
||||
if let Err(e) = lock.append(entry) {
|
||||
eprintln!("SQLite write error: {e}");
|
||||
} else {
|
||||
println!("SQLite updated!");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
#[cfg(all(feature = "wayland", not(feature = "x11")))]
|
||||
{
|
||||
crate::ws::wayland::start(db, clipboard)
|
||||
crate::ws::wayland::start(tx, clipboard)
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "x11", not(feature = "wayland")))]
|
||||
{
|
||||
crate::ws::x11::start(db, clipboard)
|
||||
crate::ws::x11::start(tx, clipboard)
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "x11", feature = "wayland"))]
|
||||
{
|
||||
let _ = (db, clipboard);
|
||||
let _ = (tx, clipboard);
|
||||
Err("Les features 'x11' et 'wayland' sont mutuellement exclusives".into())
|
||||
}
|
||||
|
||||
#[cfg(not(any(feature = "x11", feature = "wayland")))]
|
||||
{
|
||||
let _ = (db, clipboard);
|
||||
let _ = (tx, clipboard);
|
||||
Err("Aucune feature de système de fenêtrage activée (--features x11 ou wayland)".into())
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,15 +1,22 @@
|
||||
use crate::database::Database;
|
||||
use crate::models::{ClipboardData, ClipboardEntry, Image};
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::error::Error;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::mpsc;
|
||||
use std::time::SystemTime;
|
||||
use uuid::Uuid;
|
||||
use wayland_clipboard_listener::{WlClipboardPasteStream, WlListenType};
|
||||
|
||||
const MAX_IMAGE_PIXELS: usize = 3840 * 2160;
|
||||
|
||||
fn hash_bytes(data: &[u8]) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
data.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
pub fn start(
|
||||
db: Arc<Mutex<Database>>,
|
||||
tx: mpsc::Sender<ClipboardEntry>,
|
||||
_clipboard: arboard::Clipboard,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let mut stream = WlClipboardPasteStream::init(WlListenType::ListenOnCopy)
|
||||
@ -17,9 +24,11 @@ pub fn start(
|
||||
|
||||
println!("Écoute du presse-papier Wayland...");
|
||||
|
||||
let mut last_text: Option<String> = None;
|
||||
let mut last_image_hash: Option<u64> = None;
|
||||
|
||||
for msg in stream.paste_stream().flatten() {
|
||||
let context = &msg.context;
|
||||
let data: &[u8] = context.context.as_slice();
|
||||
let data: &[u8] = msg.context.context.as_slice();
|
||||
|
||||
if data.is_empty() {
|
||||
continue;
|
||||
@ -27,14 +36,22 @@ pub fn start(
|
||||
|
||||
let entry = if let Ok(text) = String::from_utf8(data.to_vec()) {
|
||||
let text = text.trim_end_matches('\n').to_string();
|
||||
if text.is_empty() {
|
||||
if text.is_empty() || Some(&text) == last_text.as_ref() {
|
||||
continue;
|
||||
}
|
||||
last_text = Some(text.clone());
|
||||
last_image_hash = None;
|
||||
println!("Clipboard update (texte)");
|
||||
ClipboardEntry {
|
||||
content: ClipboardData::Text(text),
|
||||
timestamp: SystemTime::now(),
|
||||
}
|
||||
} else {
|
||||
let hash = hash_bytes(data);
|
||||
if Some(hash) == last_image_hash {
|
||||
continue;
|
||||
}
|
||||
|
||||
match image::load_from_memory(data) {
|
||||
Ok(img) => {
|
||||
let (width, height) = (img.width(), img.height());
|
||||
@ -48,9 +65,15 @@ pub fn start(
|
||||
3840,
|
||||
2160
|
||||
);
|
||||
last_image_hash = Some(hash);
|
||||
last_text = None;
|
||||
continue;
|
||||
}
|
||||
|
||||
last_image_hash = Some(hash);
|
||||
last_text = None;
|
||||
println!("Clipboard update (image)");
|
||||
|
||||
let rgba = img.into_rgba8();
|
||||
ClipboardEntry {
|
||||
content: ClipboardData::Image(Image {
|
||||
@ -69,15 +92,10 @@ pub fn start(
|
||||
}
|
||||
};
|
||||
|
||||
println!("Clipboard update détecté");
|
||||
|
||||
let db_clone = Arc::clone(&db);
|
||||
std::thread::spawn(move || {
|
||||
let db_lock = db_clone.lock().unwrap();
|
||||
if let Err(e) = db_lock.append(entry) {
|
||||
eprintln!("SQLite error : {e}");
|
||||
}
|
||||
});
|
||||
if tx.send(entry).is_err() {
|
||||
eprintln!("Wayland : writer thread disparu, arrêt");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
use crate::database::Database;
|
||||
use crate::models::{ClipboardData, ClipboardEntry, Image};
|
||||
use arboard::Clipboard;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::error::Error;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, SystemTime};
|
||||
use uuid::Uuid;
|
||||
@ -22,7 +21,10 @@ fn hash_bytes(data: &[u8]) -> u64 {
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
pub fn start(db: Arc<Mutex<Database>>, mut clipboard: Clipboard) -> Result<(), Box<dyn Error>> {
|
||||
pub fn start(
|
||||
tx: mpsc::Sender<ClipboardEntry>,
|
||||
mut clipboard: Clipboard,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let (conn, screen_num) =
|
||||
RustConnection::connect(None).map_err(|e| format!("Connexion X11 impossible : {e}"))?;
|
||||
|
||||
@ -49,15 +51,14 @@ pub fn start(db: Arc<Mutex<Database>>, mut clipboard: Clipboard) -> Result<(), B
|
||||
.reply()?;
|
||||
|
||||
let clipboard_atom = conn.intern_atom(false, b"CLIPBOARD")?.reply()?.atom;
|
||||
|
||||
conn.xfixes_select_selection_input(
|
||||
win,
|
||||
clipboard_atom,
|
||||
SelectionEventMask::SET_SELECTION_OWNER,
|
||||
)?
|
||||
.check()?;
|
||||
|
||||
conn.flush()?;
|
||||
|
||||
println!("Clipboard monitor démarré (X11 XFIXES — zéro polling)");
|
||||
|
||||
let mut last_text: Option<String> = None;
|
||||
@ -68,14 +69,14 @@ pub fn start(db: Arc<Mutex<Database>>, mut clipboard: Clipboard) -> Result<(), B
|
||||
|
||||
if let Event::XfixesSelectionNotify(_) = event {
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
handle_clipboard_event(&mut clipboard, &db, &mut last_text, &mut last_image_hash);
|
||||
handle_clipboard_event(&mut clipboard, &tx, &mut last_text, &mut last_image_hash);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_clipboard_event(
|
||||
clipboard: &mut Clipboard,
|
||||
db: &Arc<Mutex<Database>>,
|
||||
tx: &mpsc::Sender<ClipboardEntry>,
|
||||
last_text: &mut Option<String>,
|
||||
last_image_hash: &mut Option<u64>,
|
||||
) {
|
||||
@ -89,13 +90,15 @@ fn handle_clipboard_event(
|
||||
*last_image_hash = None;
|
||||
println!("Clipboard update (texte)");
|
||||
|
||||
spawn_db_write(
|
||||
Arc::clone(db),
|
||||
ClipboardEntry {
|
||||
if tx
|
||||
.send(ClipboardEntry {
|
||||
content: ClipboardData::Text(text),
|
||||
timestamp: SystemTime::now(),
|
||||
},
|
||||
);
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
eprintln!("X11 : writer thread disparu");
|
||||
}
|
||||
}
|
||||
|
||||
Err(_) => {
|
||||
@ -124,29 +127,20 @@ fn handle_clipboard_event(
|
||||
*last_text = None;
|
||||
println!("Clipboard update (image)");
|
||||
|
||||
spawn_db_write(
|
||||
Arc::clone(db),
|
||||
ClipboardEntry {
|
||||
content: ClipboardData::Image(crate::models::Image {
|
||||
if tx
|
||||
.send(ClipboardEntry {
|
||||
content: ClipboardData::Image(Image {
|
||||
raw_pixels: Some(img_data.bytes.into_owned()),
|
||||
width: img_data.width as u32,
|
||||
height: img_data.height as u32,
|
||||
id: Uuid::new_v4(),
|
||||
}),
|
||||
timestamp: SystemTime::now(),
|
||||
},
|
||||
);
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
eprintln!("X11 : writer thread disparu");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_db_write(db: Arc<Mutex<Database>>, entry: ClipboardEntry) {
|
||||
thread::spawn(move || {
|
||||
let lock = db.lock().unwrap();
|
||||
if let Err(e) = lock.append(entry) {
|
||||
eprintln!("SQLite write error: {e}");
|
||||
} else {
|
||||
println!("SQLite updated!");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
159
src/app.rs
159
src/app.rs
@ -3,19 +3,22 @@ use crate::ipc::{self, HistoryItem};
|
||||
use chrono::{Local, NaiveDate, TimeZone};
|
||||
use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
|
||||
use image::DynamicImage;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::ListState;
|
||||
use ratatui_image::{picker::Picker, protocol};
|
||||
use regex::Regex;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use syntect::highlighting::ThemeSet;
|
||||
use syntect::easy::HighlightLines;
|
||||
use syntect::highlighting::{FontStyle as SynFontStyle, ThemeSet};
|
||||
use syntect::parsing::SyntaxSet;
|
||||
use syntect::util::LinesWithEndings;
|
||||
|
||||
const PREVIEW_MAX_WIDTH: u32 = 1280;
|
||||
const PREVIEW_MAX_HEIGHT: u32 = 720;
|
||||
|
||||
const IMAGE_CACHE_MAX: usize = 8;
|
||||
|
||||
const PAGE_SIZE: usize = 50;
|
||||
|
||||
#[derive(PartialEq, Clone)]
|
||||
@ -56,7 +59,9 @@ pub struct App {
|
||||
pub type_filter: TypeFilter,
|
||||
pub loaded_count: usize,
|
||||
pub has_more: bool,
|
||||
image_cache: HashMap<String, DynamicImage>,
|
||||
pub preview_highlighted: Option<Vec<Line<'static>>>,
|
||||
pub preview_lang: Option<String>,
|
||||
image_cache: HashMap<String, Arc<DynamicImage>>,
|
||||
image_cache_order: VecDeque<String>,
|
||||
}
|
||||
|
||||
@ -85,6 +90,53 @@ impl TypeFilter {
|
||||
}
|
||||
}
|
||||
|
||||
fn syn_color(c: syntect::highlighting::Color) -> Color {
|
||||
Color::Rgb(c.r, c.g, c.b)
|
||||
}
|
||||
|
||||
pub fn highlight_code(
|
||||
content: &str,
|
||||
syntax_set: &SyntaxSet,
|
||||
theme_set: &ThemeSet,
|
||||
) -> Vec<Line<'static>> {
|
||||
let theme = &theme_set.themes["base16-ocean.dark"];
|
||||
let syntax = syntax_set
|
||||
.find_syntax_by_first_line(content)
|
||||
.unwrap_or_else(|| syntax_set.find_syntax_plain_text());
|
||||
|
||||
let mut h = HighlightLines::new(syntax, theme);
|
||||
let mut lines = Vec::new();
|
||||
|
||||
for (no, line) in LinesWithEndings::from(content).enumerate() {
|
||||
let ranges = h.highlight_line(line, syntax_set).unwrap_or_default();
|
||||
let mut spans = vec![Span::styled(
|
||||
format!("{:>4} │ ", no + 1),
|
||||
Style::default().fg(Color::Rgb(80, 80, 100)),
|
||||
)];
|
||||
for (style, text) in &ranges {
|
||||
let mut s = Style::default().fg(syn_color(style.foreground));
|
||||
if style.font_style.contains(SynFontStyle::BOLD) {
|
||||
s = s.add_modifier(Modifier::BOLD);
|
||||
}
|
||||
if style.font_style.contains(SynFontStyle::ITALIC) {
|
||||
s = s.add_modifier(Modifier::ITALIC);
|
||||
}
|
||||
spans.push(Span::styled(text.trim_end_matches('\n').to_string(), s));
|
||||
}
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
pub fn detect_lang(content: &str, syntax_set: &SyntaxSet) -> Option<String> {
|
||||
let s = syntax_set.find_syntax_by_first_line(content)?;
|
||||
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();
|
||||
@ -94,9 +146,19 @@ impl App {
|
||||
|
||||
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 salt = match directories::ProjectDirs::from("com", "zefad", "rklipd") {
|
||||
Some(dirs) => match Crypto::load_or_create_salt(dirs.data_dir()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("Erreur sel cryptographique : {e}");
|
||||
vec![0u8; 32]
|
||||
}
|
||||
},
|
||||
None => {
|
||||
eprintln!("Impossible de déterminer le répertoire de données");
|
||||
vec![0u8; 32]
|
||||
}
|
||||
};
|
||||
|
||||
let mut app = Self {
|
||||
mode: Mode::Normal,
|
||||
@ -120,6 +182,8 @@ impl App {
|
||||
type_filter: TypeFilter::All,
|
||||
loaded_count: PAGE_SIZE,
|
||||
has_more,
|
||||
preview_highlighted: None,
|
||||
preview_lang: None,
|
||||
image_cache: HashMap::new(),
|
||||
image_cache_order: VecDeque::new(),
|
||||
};
|
||||
@ -169,29 +233,34 @@ impl App {
|
||||
&mut self,
|
||||
filename: &str,
|
||||
base_dir: &std::path::Path,
|
||||
) -> Option<DynamicImage> {
|
||||
if !self.image_cache.contains_key(filename) {
|
||||
let path = base_dir.join("images").join(filename);
|
||||
if !path.exists() {
|
||||
return None;
|
||||
}
|
||||
let img = image::open(&path).ok()?;
|
||||
let img = if img.width() > PREVIEW_MAX_WIDTH || img.height() > PREVIEW_MAX_HEIGHT {
|
||||
img.thumbnail(PREVIEW_MAX_WIDTH, PREVIEW_MAX_HEIGHT)
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
) -> Option<Arc<DynamicImage>> {
|
||||
if self.image_cache.contains_key(filename) {
|
||||
self.image_cache_order.retain(|k| k != filename);
|
||||
self.image_cache_order.push_back(filename.to_string());
|
||||
self.image_cache.insert(filename.to_string(), img);
|
||||
return self.image_cache.get(filename).cloned();
|
||||
}
|
||||
|
||||
self.image_cache.get(filename).cloned()
|
||||
let path = base_dir.join("images").join(filename);
|
||||
if !path.exists() {
|
||||
return None;
|
||||
}
|
||||
let img = image::open(&path).ok()?;
|
||||
let img = if img.width() > PREVIEW_MAX_WIDTH || img.height() > PREVIEW_MAX_HEIGHT {
|
||||
img.thumbnail(PREVIEW_MAX_WIDTH, PREVIEW_MAX_HEIGHT)
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
let arc = Arc::new(img);
|
||||
self.image_cache_order.push_back(filename.to_string());
|
||||
self.image_cache
|
||||
.insert(filename.to_string(), Arc::clone(&arc));
|
||||
Some(arc)
|
||||
}
|
||||
|
||||
pub fn format_timestamp(ts_ms: i64) -> String {
|
||||
@ -238,7 +307,15 @@ impl App {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
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")
|
||||
}
|
||||
}
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
@ -289,12 +366,6 @@ 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 {
|
||||
@ -313,16 +384,12 @@ impl App {
|
||||
|
||||
if current >= last {
|
||||
if self.try_load_more() {
|
||||
// try_load_more restaure la sélection sur le même item ;
|
||||
// on peut maintenant avancer d'un cran
|
||||
let current = self.list_state.selected().unwrap_or(0);
|
||||
if current + 1 < self.filtered_items.len() {
|
||||
self.list_state.select(Some(current + 1));
|
||||
self.update_preview();
|
||||
}
|
||||
// Sinon (le filtre actif masque les nouveaux items) : on reste
|
||||
} else {
|
||||
// Fin réelle — wrap vers le haut
|
||||
self.list_state.select(Some(0));
|
||||
self.update_preview();
|
||||
}
|
||||
@ -367,13 +434,13 @@ impl App {
|
||||
self.list_state.select(new_sel);
|
||||
}
|
||||
}
|
||||
self.last_selected_index = None;
|
||||
self.update_preview();
|
||||
}
|
||||
|
||||
pub fn undo_delete(&mut self) {
|
||||
if let Some(item) = self.undo_stack.pop() {
|
||||
ipc::add_entry(item.content.clone());
|
||||
// Re-sync depuis le daemon pour avoir l'ordre chronologique correct
|
||||
if let Some(new_items) = ipc::fetch_history(self.loaded_count) {
|
||||
self.has_more = new_items.len() == self.loaded_count;
|
||||
self.all_items = new_items;
|
||||
@ -545,6 +612,8 @@ impl App {
|
||||
self.current_image = None;
|
||||
self.image_cache.clear();
|
||||
self.image_cache_order.clear();
|
||||
self.preview_highlighted = None;
|
||||
self.preview_lang = None;
|
||||
self.loaded_count = PAGE_SIZE;
|
||||
self.has_more = false;
|
||||
self.set_status("Historique effacé".into());
|
||||
@ -561,6 +630,8 @@ impl App {
|
||||
self.last_selected_index = idx;
|
||||
self.current_image = None;
|
||||
self.preview_scroll = 0;
|
||||
self.preview_highlighted = None;
|
||||
self.preview_lang = None;
|
||||
|
||||
let content = match self.get_selected_item().map(|i| i.content.clone()) {
|
||||
Some(c) => c,
|
||||
@ -569,12 +640,15 @@ impl App {
|
||||
|
||||
if content.ends_with(".jpg") || content.ends_with(".png") {
|
||||
if let Some(dirs) = directories::ProjectDirs::from("com", "zefad", "rklipd") {
|
||||
if let Some(img) = self.get_cached_image(&content, dirs.data_dir()) {
|
||||
// new_resize_protocol attend un DynamicImage — on clone depuis le cache
|
||||
// (l'image est déjà redimensionnée ≤ 1280×720, clone = ~3,5 Mo max)
|
||||
if let Some(arc_img) = self.get_cached_image(&content, dirs.data_dir()) {
|
||||
let img = (*arc_img).clone();
|
||||
self.current_image = Some(self.picker.new_resize_protocol(img));
|
||||
}
|
||||
}
|
||||
} else if !Crypto::is_any_encrypted(&content) {
|
||||
self.preview_lang = detect_lang(&content, &self.syntax_set);
|
||||
self.preview_highlighted =
|
||||
Some(highlight_code(&content, &self.syntax_set, &self.theme_set));
|
||||
}
|
||||
}
|
||||
|
||||
@ -596,7 +670,6 @@ impl App {
|
||||
return;
|
||||
};
|
||||
|
||||
// Mise à jour du flag has_more lors de chaque sync
|
||||
self.has_more = new.len() == self.loaded_count;
|
||||
|
||||
let changed = self.all_items.len() != new.len()
|
||||
|
||||
@ -34,6 +34,14 @@ impl Crypto {
|
||||
if bytes.len() == SALT_LEN {
|
||||
return Ok(bytes);
|
||||
}
|
||||
return Err(format!(
|
||||
"Fichier sel corrompu ({} octets au lieu de {}). \
|
||||
Supprimez {:?} manuellement si vous souhaitez réinitialiser le chiffrement.",
|
||||
bytes.len(),
|
||||
SALT_LEN,
|
||||
path
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let mut salt = vec![0u8; SALT_LEN];
|
||||
OsRng.fill_bytes(&mut salt);
|
||||
|
||||
@ -63,8 +63,11 @@ pub fn set_clipboard(content: String) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn delete_entry(content: String) {
|
||||
let _ = send_request(&IpcRequest::DeleteEntry { content });
|
||||
pub fn delete_entry(content: String) -> bool {
|
||||
matches!(
|
||||
send_request(&IpcRequest::DeleteEntry { content }),
|
||||
Some(IpcResponse::Ok)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn update_entry(old_content: String, new_content: String) -> bool {
|
||||
|
||||
10
src/main.rs
10
src/main.rs
@ -177,8 +177,14 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
|
||||
Mode::ConfirmDelete => match key.code {
|
||||
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
|
||||
if let Some(item) = app.get_selected_item() {
|
||||
ipc::delete_entry(item.content.clone());
|
||||
app.delete_selected();
|
||||
let content = item.content.clone();
|
||||
if ipc::delete_entry(content) {
|
||||
app.delete_selected();
|
||||
} else {
|
||||
app.set_error(
|
||||
"Erreur : daemon injoignable, entrée non supprimée".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
app.mode = Mode::Normal;
|
||||
}
|
||||
|
||||
86
src/ui.rs
86
src/ui.rs
@ -1,4 +1,4 @@
|
||||
use crate::app::{App, Mode};
|
||||
use crate::app::{App, Mode, detect_lang, highlight_code};
|
||||
use crate::crypto::Crypto;
|
||||
use ratatui::{
|
||||
Frame,
|
||||
@ -8,55 +8,6 @@ use ratatui::{
|
||||
widgets::{Block, BorderType, Borders, List, ListItem, Padding, Paragraph},
|
||||
};
|
||||
use ratatui_image::StatefulImage;
|
||||
use syntect::easy::HighlightLines;
|
||||
use syntect::highlighting::FontStyle as SynFontStyle;
|
||||
use syntect::util::LinesWithEndings;
|
||||
|
||||
fn syn_color(c: syntect::highlighting::Color) -> Color {
|
||||
Color::Rgb(c.r, c.g, c.b)
|
||||
}
|
||||
|
||||
fn highlight_code(content: &str, app: &App) -> Vec<Line<'static>> {
|
||||
let ps = &app.syntax_set;
|
||||
let ts = &app.theme_set;
|
||||
let theme = &ts.themes["base16-ocean.dark"];
|
||||
|
||||
let syntax = ps
|
||||
.find_syntax_by_first_line(content)
|
||||
.unwrap_or_else(|| ps.find_syntax_plain_text());
|
||||
|
||||
let mut h = HighlightLines::new(syntax, theme);
|
||||
let mut lines = Vec::new();
|
||||
|
||||
for (no, line) in LinesWithEndings::from(content).enumerate() {
|
||||
let ranges = h.highlight_line(line, ps).unwrap_or_default();
|
||||
let mut spans = vec![Span::styled(
|
||||
format!("{:>4} │ ", no + 1),
|
||||
Style::default().fg(Color::Rgb(80, 80, 100)),
|
||||
)];
|
||||
for (style, text) in &ranges {
|
||||
let mut s = Style::default().fg(syn_color(style.foreground));
|
||||
if style.font_style.contains(SynFontStyle::BOLD) {
|
||||
s = s.add_modifier(Modifier::BOLD);
|
||||
}
|
||||
if style.font_style.contains(SynFontStyle::ITALIC) {
|
||||
s = s.add_modifier(Modifier::ITALIC);
|
||||
}
|
||||
spans.push(Span::styled(text.trim_end_matches('\n').to_string(), s));
|
||||
}
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn detect_lang(content: &str, app: &App) -> Option<String> {
|
||||
let s = app.syntax_set.find_syntax_by_first_line(content)?;
|
||||
if s.name == "Plain Text" {
|
||||
None
|
||||
} else {
|
||||
Some(s.name.clone())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(f: &mut Frame, app: &mut App) {
|
||||
let outer = Layout::default()
|
||||
@ -103,7 +54,7 @@ pub fn render(f: &mut Frame, app: &mut App) {
|
||||
let preview: String = item
|
||||
.content
|
||||
.lines()
|
||||
.next()
|
||||
.find(|l| !l.trim().is_empty())
|
||||
.unwrap_or("")
|
||||
.chars()
|
||||
.take(28)
|
||||
@ -139,12 +90,7 @@ pub fn render(f: &mut Frame, app: &mut App) {
|
||||
|
||||
let selected_content = app.get_selected_item().map(|i| i.content.clone());
|
||||
|
||||
let lang = selected_content
|
||||
.as_deref()
|
||||
.filter(|c| !Crypto::is_any_encrypted(c) && !c.ends_with(".jpg") && !c.ends_with(".png"))
|
||||
.and_then(|c| detect_lang(c, app));
|
||||
|
||||
let preview_title = match &lang {
|
||||
let preview_title = match &app.preview_lang {
|
||||
Some(l) => format!(" Prévisualisation — {} ", l),
|
||||
None => " Prévisualisation ".to_string(),
|
||||
};
|
||||
@ -165,20 +111,19 @@ pub fn render(f: &mut Frame, app: &mut App) {
|
||||
let inner = preview_block.inner(panels[1]);
|
||||
f.render_widget(preview_block, panels[1]);
|
||||
|
||||
if app.current_image.is_some() {
|
||||
let state = app.current_image.as_mut().unwrap();
|
||||
let scroll = (app.preview_scroll, 0);
|
||||
|
||||
if let Some(state) = app.current_image.as_mut() {
|
||||
f.render_stateful_widget(StatefulImage::default(), inner, state);
|
||||
} else if let Some(content) = &selected_content {
|
||||
let scroll = (app.preview_scroll, 0);
|
||||
if Crypto::is_any_encrypted(content) {
|
||||
f.render_widget(
|
||||
Paragraph::new("🔒 Contenu chiffré\n\nAppuyez sur [e] pour déchiffrer.")
|
||||
.scroll(scroll),
|
||||
inner,
|
||||
);
|
||||
} else {
|
||||
let lines = highlight_code(content, app);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -190,20 +135,6 @@ pub fn render(f: &mut Frame, app: &mut App) {
|
||||
Mode::PasswordInput => (" MOT DE PASSE ", Color::Magenta),
|
||||
};
|
||||
|
||||
let extra = match &app.mode {
|
||||
Mode::Search => format!(" /{}", app.input_buffer),
|
||||
Mode::Command => format!(" :{}", app.input_buffer),
|
||||
Mode::PasswordInput => format!(" {}", "●".repeat(app.input_buffer.len())),
|
||||
_ => 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()),
|
||||
@ -216,7 +147,6 @@ pub fn render(f: &mut Frame, app: &mut App) {
|
||||
} 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 {
|
||||
|
||||
Reference in New Issue
Block a user