add fav + opti
This commit is contained in:
@ -5,6 +5,7 @@ use image::{ExtendedColorType, ImageEncoder};
|
|||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::io::Cursor;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@ -29,25 +30,44 @@ impl Database {
|
|||||||
PRAGMA foreign_keys=ON;",
|
PRAGMA foreign_keys=ON;",
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
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);",
|
|
||||||
)?;
|
|
||||||
|
|
||||||
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,
|
||||||
|
pinned INTEGER NOT NULL DEFAULT 0
|
||||||
)",
|
)",
|
||||||
[],
|
[],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
let version: i64 = conn
|
||||||
|
.query_row("SELECT version FROM schema_version", [], |r| r.get(0))
|
||||||
|
.unwrap_or(1);
|
||||||
|
|
||||||
|
if version < 2 {
|
||||||
|
let col_exists: bool = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('history') WHERE name='pinned'",
|
||||||
|
[],
|
||||||
|
|r| r.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(0)
|
||||||
|
> 0;
|
||||||
|
|
||||||
|
if !col_exists {
|
||||||
|
conn.execute_batch(
|
||||||
|
"ALTER TABLE history ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0;",
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
conn.execute("UPDATE schema_version SET version = 2", [])?;
|
||||||
|
println!("DB migrée → schema v2 (colonne `pinned`)");
|
||||||
|
}
|
||||||
|
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_history_content ON history(content);
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_history_content ON history(content);
|
||||||
CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp DESC);",
|
CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_history_pinned ON history(pinned);",
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
@ -85,25 +105,25 @@ impl Database {
|
|||||||
.flat_map(|rgba| [rgba[0], rgba[1], rgba[2]])
|
.flat_map(|rgba| [rgba[0], rgba[1], rgba[2]])
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let mut jpeg_buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
JpegEncoder::new_with_quality(&mut jpeg_buf, 70).write_image(
|
JpegEncoder::new_with_quality(Cursor::new(&mut buf), 70).write_image(
|
||||||
&rgb,
|
&rgb,
|
||||||
img.width,
|
img.width,
|
||||||
img.height,
|
img.height,
|
||||||
ExtendedColorType::Rgb8,
|
ExtendedColorType::Rgb8,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
if jpeg_buf.len() > self.max_entry_size_bytes {
|
if buf.len() > self.max_entry_size_bytes {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"Image rejetée dans DB : JPEG {} Ko > limite {} Ko",
|
"Image rejetée : JPEG {} Ko > limite {} Ko",
|
||||||
jpeg_buf.len() / 1024,
|
buf.len() / 1024,
|
||||||
self.max_entry_size_bytes / 1024
|
self.max_entry_size_bytes / 1024
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let path = img.file_path(&self.dir_path);
|
let path = img.file_path(&self.dir_path);
|
||||||
fs::write(&path, &jpeg_buf)?;
|
fs::write(&path, &buf)?;
|
||||||
}
|
}
|
||||||
None => return Ok(()),
|
None => return Ok(()),
|
||||||
}
|
}
|
||||||
@ -112,7 +132,13 @@ impl Database {
|
|||||||
};
|
};
|
||||||
|
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"INSERT OR REPLACE INTO history (type, content, timestamp) VALUES (?1, ?2, ?3)",
|
"INSERT OR REPLACE INTO history (type, content, timestamp, pinned)
|
||||||
|
VALUES (?1, ?2, ?3,
|
||||||
|
COALESCE(
|
||||||
|
(SELECT pinned FROM history WHERE content = ?2),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
)",
|
||||||
(kind, &content, ts),
|
(kind, &content, ts),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
@ -130,10 +156,11 @@ impl Database {
|
|||||||
let image_files: Vec<String> = {
|
let image_files: Vec<String> = {
|
||||||
let mut stmt = tx.prepare(
|
let mut stmt = tx.prepare(
|
||||||
"SELECT content FROM history
|
"SELECT content FROM history
|
||||||
WHERE type = 'image'
|
WHERE type = 'image' AND pinned = 0
|
||||||
AND id NOT IN (
|
AND id NOT IN (
|
||||||
SELECT id FROM history ORDER BY timestamp DESC LIMIT ?1
|
SELECT id FROM history WHERE pinned = 0
|
||||||
)",
|
ORDER BY timestamp DESC LIMIT ?1
|
||||||
|
)",
|
||||||
)?;
|
)?;
|
||||||
stmt.query_map([self.max_entries as i64], |row| row.get(0))?
|
stmt.query_map([self.max_entries as i64], |row| row.get(0))?
|
||||||
.filter_map(|r| r.ok())
|
.filter_map(|r| r.ok())
|
||||||
@ -141,8 +168,11 @@ impl Database {
|
|||||||
};
|
};
|
||||||
|
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"DELETE FROM history WHERE id NOT IN (
|
"DELETE FROM history
|
||||||
SELECT id FROM history ORDER BY timestamp DESC LIMIT ?1
|
WHERE pinned = 0
|
||||||
|
AND id NOT IN (
|
||||||
|
SELECT id FROM history WHERE pinned = 0
|
||||||
|
ORDER BY timestamp DESC LIMIT ?1
|
||||||
)",
|
)",
|
||||||
[self.max_entries as i64],
|
[self.max_entries as i64],
|
||||||
)?;
|
)?;
|
||||||
@ -163,7 +193,10 @@ impl Database {
|
|||||||
|
|
||||||
pub fn read_history(&self, limit: usize) -> Result<Vec<ClipboardEntry>, Box<dyn Error>> {
|
pub fn read_history(&self, limit: usize) -> Result<Vec<ClipboardEntry>, Box<dyn Error>> {
|
||||||
let mut stmt = self.conn.prepare(
|
let mut stmt = self.conn.prepare(
|
||||||
"SELECT type, content, timestamp FROM history ORDER BY timestamp DESC LIMIT ?1",
|
"SELECT type, content, timestamp, pinned
|
||||||
|
FROM history
|
||||||
|
ORDER BY pinned DESC, timestamp DESC
|
||||||
|
LIMIT ?1",
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let rows = stmt.query_map([limit as i64], |row| {
|
let rows = stmt.query_map([limit as i64], |row| {
|
||||||
@ -171,12 +204,13 @@ impl Database {
|
|||||||
row.get::<_, String>(0)?,
|
row.get::<_, String>(0)?,
|
||||||
row.get::<_, String>(1)?,
|
row.get::<_, String>(1)?,
|
||||||
row.get::<_, i64>(2)?,
|
row.get::<_, i64>(2)?,
|
||||||
|
row.get::<_, bool>(3)?,
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mut entries = Vec::new();
|
let mut entries = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let (ty, content, ts_ms) = row?;
|
let (ty, content, ts_ms, pinned) = row?;
|
||||||
let timestamp = UNIX_EPOCH + Duration::from_millis(ts_ms as u64);
|
let timestamp = UNIX_EPOCH + Duration::from_millis(ts_ms as u64);
|
||||||
let data = if ty == "text" {
|
let data = if ty == "text" {
|
||||||
ClipboardData::Text(content)
|
ClipboardData::Text(content)
|
||||||
@ -192,11 +226,23 @@ impl Database {
|
|||||||
entries.push(ClipboardEntry {
|
entries.push(ClipboardEntry {
|
||||||
content: data,
|
content: data,
|
||||||
timestamp,
|
timestamp,
|
||||||
|
pinned,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_pin(&self, content: &str, pinned: bool) -> Result<(), Box<dyn Error>> {
|
||||||
|
let rows = self.conn.execute(
|
||||||
|
"UPDATE history SET pinned = ?1 WHERE content = ?2",
|
||||||
|
(pinned as i32, content),
|
||||||
|
)?;
|
||||||
|
if rows == 0 {
|
||||||
|
return Err(format!("Entrée introuvable pour pin : {content}").into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn delete_entry_by_content(&self, content: &str) -> Result<(), Box<dyn Error>> {
|
pub fn delete_entry_by_content(&self, content: &str) -> Result<(), Box<dyn Error>> {
|
||||||
self.conn
|
self.conn
|
||||||
.execute("DELETE FROM history WHERE content = ?1", [content])?;
|
.execute("DELETE FROM history WHERE content = ?1", [content])?;
|
||||||
@ -221,14 +267,19 @@ impl Database {
|
|||||||
let tx = self.conn.unchecked_transaction()?;
|
let tx = self.conn.unchecked_transaction()?;
|
||||||
|
|
||||||
let image_files: Vec<String> = {
|
let image_files: Vec<String> = {
|
||||||
let mut stmt =
|
let mut stmt = tx.prepare(
|
||||||
tx.prepare("SELECT content FROM history WHERE type = 'image' AND timestamp < ?1")?;
|
"SELECT content FROM history
|
||||||
|
WHERE type = 'image' AND pinned = 0 AND timestamp < ?1",
|
||||||
|
)?;
|
||||||
stmt.query_map([cutoff_ms], |row| row.get(0))?
|
stmt.query_map([cutoff_ms], |row| row.get(0))?
|
||||||
.filter_map(|r| r.ok())
|
.filter_map(|r| r.ok())
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
let count = tx.execute("DELETE FROM history WHERE timestamp < ?1", [cutoff_ms])?;
|
let count = tx.execute(
|
||||||
|
"DELETE FROM history WHERE timestamp < ?1 AND pinned = 0",
|
||||||
|
[cutoff_ms],
|
||||||
|
)?;
|
||||||
|
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::os::unix::net::UnixListener;
|
use std::os::unix::net::UnixListener;
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
@ -16,6 +16,8 @@ const IPC_MAX_REQUEST_BYTES: usize = 4 * 1024 * 1024;
|
|||||||
pub struct HistoryItem {
|
pub struct HistoryItem {
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub timestamp: i64,
|
pub timestamp: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub pinned: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
@ -36,6 +38,10 @@ pub enum IpcRequest {
|
|||||||
AddEntry {
|
AddEntry {
|
||||||
content: String,
|
content: String,
|
||||||
},
|
},
|
||||||
|
PinEntry {
|
||||||
|
content: String,
|
||||||
|
pinned: bool,
|
||||||
|
},
|
||||||
ClearHistory,
|
ClearHistory,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,7 +58,12 @@ fn reply(stream: &mut std::os::unix::net::UnixStream, resp: IpcResponse) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn start_server(db: Arc<Mutex<Database>>, crypto: Arc<Crypto>, socket_path: &Path) {
|
pub fn start_server(
|
||||||
|
db: Arc<Mutex<Database>>,
|
||||||
|
crypto: Arc<Crypto>,
|
||||||
|
socket_path: &Path,
|
||||||
|
data_dir: Arc<PathBuf>,
|
||||||
|
) {
|
||||||
if socket_path.exists() {
|
if socket_path.exists() {
|
||||||
let _ = fs::remove_file(socket_path);
|
let _ = fs::remove_file(socket_path);
|
||||||
}
|
}
|
||||||
@ -84,188 +95,194 @@ pub fn start_server(db: Arc<Mutex<Database>>, crypto: Arc<Crypto>, socket_path:
|
|||||||
|
|
||||||
let db_clone = Arc::clone(&db);
|
let db_clone = Arc::clone(&db);
|
||||||
let crypto_clone = Arc::clone(&crypto);
|
let crypto_clone = Arc::clone(&crypto);
|
||||||
|
let data_dir_clone = Arc::clone(&data_dir);
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let mut buf = Vec::new();
|
handle_connection(&mut stream, db_clone, crypto_clone, data_dir_clone);
|
||||||
let mut tmp = [0u8; 4096];
|
|
||||||
|
|
||||||
loop {
|
|
||||||
match stream.read(&mut tmp) {
|
|
||||||
Ok(0) => break,
|
|
||||||
Ok(n) => {
|
|
||||||
buf.extend_from_slice(&tmp[..n]);
|
|
||||||
if buf.len() > IPC_MAX_REQUEST_BYTES {
|
|
||||||
eprintln!("IPC : requête trop grande, abandon");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e)
|
|
||||||
if e.kind() == std::io::ErrorKind::WouldBlock
|
|
||||||
|| e.kind() == std::io::ErrorKind::TimedOut =>
|
|
||||||
{
|
|
||||||
eprintln!("IPC : timeout de lecture");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("IPC read error : {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let buf_str = match String::from_utf8(buf) {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("IPC : requête non-UTF8 : {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let req = match serde_json::from_str::<IpcRequest>(&buf_str) {
|
|
||||||
Ok(r) => r,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("IPC parse error : {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match req {
|
|
||||||
IpcRequest::GetHistory { limit } => {
|
|
||||||
// Limite à 1000 pour éviter les requêtes abusives
|
|
||||||
let limit = limit.min(1000);
|
|
||||||
let lock = db_clone.lock().unwrap();
|
|
||||||
let history = lock.read_history(limit).unwrap_or_default();
|
|
||||||
let items: Vec<HistoryItem> = history
|
|
||||||
.into_iter()
|
|
||||||
.map(|e| {
|
|
||||||
let content = match e.content {
|
|
||||||
ClipboardData::Text(t) => t,
|
|
||||||
ClipboardData::Image(img) => format!("{}.jpg", img.id),
|
|
||||||
};
|
|
||||||
let ts = e
|
|
||||||
.timestamp
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_millis()
|
|
||||||
as i64;
|
|
||||||
HistoryItem {
|
|
||||||
content,
|
|
||||||
timestamp: ts,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
reply(&mut stream, IpcResponse::History(items));
|
|
||||||
}
|
|
||||||
|
|
||||||
IpcRequest::SetClipboard { 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) => {
|
|
||||||
if actual.ends_with(".jpg") || actual.ends_with(".png") {
|
|
||||||
if let Some(dirs) =
|
|
||||||
directories::ProjectDirs::from("com", "zefad", "rklipd")
|
|
||||||
{
|
|
||||||
let path = dirs.data_dir().join("images").join(&actual);
|
|
||||||
if let Ok(img) = image::open(&path) {
|
|
||||||
let rgba = img.into_rgba8();
|
|
||||||
let (w, h) =
|
|
||||||
(rgba.width() as usize, rgba.height() as usize);
|
|
||||||
let _ = cb.set_image(arboard::ImageData {
|
|
||||||
width: w,
|
|
||||||
height: h,
|
|
||||||
bytes: std::borrow::Cow::Owned(rgba.into_raw()),
|
|
||||||
});
|
|
||||||
reply(&mut stream, IpcResponse::Ok);
|
|
||||||
} else {
|
|
||||||
reply(
|
|
||||||
&mut stream,
|
|
||||||
IpcResponse::Error(format!(
|
|
||||||
"Image introuvable : {actual}"
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let _ = cb.set_text(actual);
|
|
||||||
reply(&mut stream, IpcResponse::Ok);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => reply(&mut stream, IpcResponse::Error(e.to_string())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
IpcRequest::DeleteEntry { content } => {
|
|
||||||
{
|
|
||||||
let lock = db_clone.lock().unwrap();
|
|
||||||
let _ = lock.delete_entry_by_content(&content);
|
|
||||||
}
|
|
||||||
if !content.starts_with("enc:")
|
|
||||||
&& !content.starts_with("enc2:")
|
|
||||||
&& (content.ends_with(".jpg") || content.ends_with(".png"))
|
|
||||||
{
|
|
||||||
if let Some(dirs) =
|
|
||||||
directories::ProjectDirs::from("com", "zefad", "rklipd")
|
|
||||||
{
|
|
||||||
let p = dirs.data_dir().join("images").join(&content);
|
|
||||||
if p.exists() {
|
|
||||||
let _ = fs::remove_file(p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reply(&mut stream, IpcResponse::Ok);
|
|
||||||
}
|
|
||||||
|
|
||||||
IpcRequest::UpdateEntry {
|
|
||||||
old_content,
|
|
||||||
new_content,
|
|
||||||
} => {
|
|
||||||
let lock = db_clone.lock().unwrap();
|
|
||||||
match lock.update_entry_content(&old_content, &new_content) {
|
|
||||||
Ok(_) => reply(&mut stream, IpcResponse::Ok),
|
|
||||||
Err(e) => reply(&mut stream, IpcResponse::Error(e.to_string())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
IpcRequest::AddEntry { content } => {
|
|
||||||
let entry = ClipboardEntry {
|
|
||||||
content: ClipboardData::Text(content),
|
|
||||||
timestamp: SystemTime::now(),
|
|
||||||
};
|
|
||||||
let lock = db_clone.lock().unwrap();
|
|
||||||
match lock.append(entry) {
|
|
||||||
Ok(_) => reply(&mut stream, IpcResponse::Ok),
|
|
||||||
Err(e) => reply(&mut stream, IpcResponse::Error(e.to_string())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
IpcRequest::ClearHistory => {
|
|
||||||
let lock = db_clone.lock().unwrap();
|
|
||||||
match lock.clear_history() {
|
|
||||||
Ok(_) => reply(&mut stream, IpcResponse::Ok),
|
|
||||||
Err(e) => reply(&mut stream, IpcResponse::Error(e.to_string())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => eprintln!("Erreur connexion IPC : {e}"),
|
Err(e) => eprintln!("Erreur connexion IPC : {e}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_connection(
|
||||||
|
stream: &mut std::os::unix::net::UnixStream,
|
||||||
|
db: Arc<Mutex<Database>>,
|
||||||
|
crypto: Arc<Crypto>,
|
||||||
|
data_dir: Arc<PathBuf>,
|
||||||
|
) {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut tmp = [0u8; 4096];
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match stream.read(&mut tmp) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(n) => {
|
||||||
|
buf.extend_from_slice(&tmp[..n]);
|
||||||
|
if buf.len() > IPC_MAX_REQUEST_BYTES {
|
||||||
|
eprintln!("IPC : requête trop grande, abandon");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e)
|
||||||
|
if e.kind() == std::io::ErrorKind::WouldBlock
|
||||||
|
|| e.kind() == std::io::ErrorKind::TimedOut =>
|
||||||
|
{
|
||||||
|
eprintln!("IPC : timeout de lecture");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("IPC read error : {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let buf_str = match String::from_utf8(buf) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("IPC : requête non-UTF8 : {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let req = match serde_json::from_str::<IpcRequest>(&buf_str) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("IPC parse error : {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match req {
|
||||||
|
IpcRequest::GetHistory { limit } => {
|
||||||
|
let limit = limit.min(1000);
|
||||||
|
let lock = db.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
let history = lock.read_history(limit).unwrap_or_default();
|
||||||
|
let items: Vec<HistoryItem> = history
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| {
|
||||||
|
let content = match e.content {
|
||||||
|
ClipboardData::Text(t) => t,
|
||||||
|
ClipboardData::Image(img) => format!("{}.jpg", img.id),
|
||||||
|
};
|
||||||
|
let ts = e
|
||||||
|
.timestamp
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as i64;
|
||||||
|
HistoryItem {
|
||||||
|
content,
|
||||||
|
timestamp: ts,
|
||||||
|
pinned: e.pinned,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
reply(stream, IpcResponse::History(items));
|
||||||
|
}
|
||||||
|
|
||||||
|
IpcRequest::SetClipboard { content } => {
|
||||||
|
let actual = if Crypto::is_legacy_encrypted(&content) {
|
||||||
|
crypto.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(
|
||||||
|
stream,
|
||||||
|
IpcResponse::Error(
|
||||||
|
"Entrée chiffrée par mot de passe : déchiffrez côté client avant de coller"
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
content
|
||||||
|
};
|
||||||
|
|
||||||
|
match arboard::Clipboard::new() {
|
||||||
|
Ok(mut cb) => {
|
||||||
|
if actual.ends_with(".jpg") || actual.ends_with(".png") {
|
||||||
|
let path = data_dir.join("images").join(&actual);
|
||||||
|
if let Ok(img) = image::open(&path) {
|
||||||
|
let rgba = img.into_rgba8();
|
||||||
|
let (w, h) = (rgba.width() as usize, rgba.height() as usize);
|
||||||
|
let _ = cb.set_image(arboard::ImageData {
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
bytes: std::borrow::Cow::Owned(rgba.into_raw()),
|
||||||
|
});
|
||||||
|
reply(stream, IpcResponse::Ok);
|
||||||
|
} else {
|
||||||
|
reply(
|
||||||
|
stream,
|
||||||
|
IpcResponse::Error(format!("Image introuvable : {actual}")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let _ = cb.set_text(actual);
|
||||||
|
reply(stream, IpcResponse::Ok);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => reply(stream, IpcResponse::Error(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IpcRequest::DeleteEntry { content } => {
|
||||||
|
{
|
||||||
|
let lock = db.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
let _ = lock.delete_entry_by_content(&content);
|
||||||
|
}
|
||||||
|
if !Crypto::is_any_encrypted(&content)
|
||||||
|
&& (content.ends_with(".jpg") || content.ends_with(".png"))
|
||||||
|
{
|
||||||
|
let p = data_dir.join("images").join(&content);
|
||||||
|
if p.exists() {
|
||||||
|
let _ = fs::remove_file(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reply(stream, IpcResponse::Ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
IpcRequest::UpdateEntry {
|
||||||
|
old_content,
|
||||||
|
new_content,
|
||||||
|
} => {
|
||||||
|
let lock = db.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
match lock.update_entry_content(&old_content, &new_content) {
|
||||||
|
Ok(_) => reply(stream, IpcResponse::Ok),
|
||||||
|
Err(e) => reply(stream, IpcResponse::Error(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IpcRequest::AddEntry { content } => {
|
||||||
|
let entry = ClipboardEntry {
|
||||||
|
content: ClipboardData::Text(content),
|
||||||
|
timestamp: SystemTime::now(),
|
||||||
|
pinned: false,
|
||||||
|
};
|
||||||
|
let lock = db.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
match lock.append(entry) {
|
||||||
|
Ok(_) => reply(stream, IpcResponse::Ok),
|
||||||
|
Err(e) => reply(stream, IpcResponse::Error(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IpcRequest::PinEntry { content, pinned } => {
|
||||||
|
let lock = db.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
match lock.set_pin(&content, pinned) {
|
||||||
|
Ok(_) => reply(stream, IpcResponse::Ok),
|
||||||
|
Err(e) => reply(stream, IpcResponse::Error(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IpcRequest::ClearHistory => {
|
||||||
|
let lock = db.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
match lock.clear_history() {
|
||||||
|
Ok(_) => reply(stream, IpcResponse::Ok),
|
||||||
|
Err(e) => reply(stream, IpcResponse::Error(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -31,17 +31,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
let proj_dirs =
|
let proj_dirs =
|
||||||
ProjectDirs::from("com", "zefad", "rklipd").expect("Impossible d'ouvrir le répertoire");
|
ProjectDirs::from("com", "zefad", "rklipd").expect("Impossible d'ouvrir le répertoire");
|
||||||
let dir_path = proj_dirs.data_dir();
|
let dir_path = proj_dirs.data_dir().to_path_buf();
|
||||||
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, &config)?));
|
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");
|
||||||
let db_for_ipc = Arc::clone(&db);
|
let db_for_ipc = Arc::clone(&db);
|
||||||
let crypto_for_ipc = Arc::clone(&crypto);
|
let crypto_for_ipc = Arc::clone(&crypto);
|
||||||
|
let data_dir = Arc::new(dir_path.clone());
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
crate::ipc::start_server(db_for_ipc, crypto_for_ipc, &socket_path);
|
crate::ipc::start_server(db_for_ipc, crypto_for_ipc, &socket_path, data_dir);
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some(days) = config.expiry_days {
|
if let Some(days) = config.expiry_days {
|
||||||
@ -49,12 +50,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
loop {
|
loop {
|
||||||
{
|
{
|
||||||
let lock = db_for_expiry.lock().unwrap();
|
let lock = db_for_expiry.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
match lock.delete_entries_older_than(days) {
|
match lock.delete_entries_older_than(days) {
|
||||||
Ok(0) => {}
|
Ok(0) => {}
|
||||||
Ok(n) => {
|
Ok(n) => println!("Expiration : {n} entrée(s) > {days} jours supprimée(s)"),
|
||||||
println!("Expiration : {n} entrée(s) supprimée(s) (> {days} jours)")
|
|
||||||
}
|
|
||||||
Err(e) => eprintln!("Erreur expiration : {e}"),
|
Err(e) => eprintln!("Erreur expiration : {e}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ use uuid::Uuid;
|
|||||||
pub struct ClipboardEntry {
|
pub struct ClipboardEntry {
|
||||||
pub content: ClipboardData,
|
pub content: ClipboardData,
|
||||||
pub timestamp: SystemTime,
|
pub timestamp: SystemTime,
|
||||||
|
pub pinned: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|||||||
@ -9,13 +9,10 @@ pub fn start(db: Arc<Mutex<Database>>, clipboard: Clipboard) -> Result<(), Box<d
|
|||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
for entry in rx {
|
for entry in rx {
|
||||||
let lock = match db.lock() {
|
let lock = db.lock().unwrap_or_else(|poisoned| {
|
||||||
Ok(l) => l,
|
eprintln!("Mutex DB empoisonné, récupération forcée");
|
||||||
Err(poisoned) => {
|
poisoned.into_inner()
|
||||||
eprintln!("Mutex DB empoisonné, récupération forcée");
|
});
|
||||||
poisoned.into_inner()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if let Err(e) = lock.append(entry) {
|
if let Err(e) = lock.append(entry) {
|
||||||
eprintln!("SQLite write error: {e}");
|
eprintln!("SQLite write error: {e}");
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -45,6 +45,7 @@ pub fn start(
|
|||||||
ClipboardEntry {
|
ClipboardEntry {
|
||||||
content: ClipboardData::Text(text),
|
content: ClipboardData::Text(text),
|
||||||
timestamp: SystemTime::now(),
|
timestamp: SystemTime::now(),
|
||||||
|
pinned: false,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let hash = hash_bytes(data);
|
let hash = hash_bytes(data);
|
||||||
@ -83,6 +84,7 @@ pub fn start(
|
|||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
}),
|
}),
|
||||||
timestamp: SystemTime::now(),
|
timestamp: SystemTime::now(),
|
||||||
|
pinned: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@ -94,6 +94,7 @@ fn handle_clipboard_event(
|
|||||||
.send(ClipboardEntry {
|
.send(ClipboardEntry {
|
||||||
content: ClipboardData::Text(text),
|
content: ClipboardData::Text(text),
|
||||||
timestamp: SystemTime::now(),
|
timestamp: SystemTime::now(),
|
||||||
|
pinned: false,
|
||||||
})
|
})
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
@ -137,6 +138,7 @@ fn handle_clipboard_event(
|
|||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
}),
|
}),
|
||||||
timestamp: SystemTime::now(),
|
timestamp: SystemTime::now(),
|
||||||
|
pinned: false,
|
||||||
})
|
})
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
|
|||||||
186
src/app.rs
186
src/app.rs
@ -9,7 +9,7 @@ use ratatui::widgets::ListState;
|
|||||||
use ratatui_image::{picker::Picker, protocol};
|
use ratatui_image::{picker::Picker, protocol};
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, OnceLock};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use syntect::easy::HighlightLines;
|
use syntect::easy::HighlightLines;
|
||||||
use syntect::highlighting::{FontStyle as SynFontStyle, ThemeSet};
|
use syntect::highlighting::{FontStyle as SynFontStyle, ThemeSet};
|
||||||
@ -28,6 +28,26 @@ pub fn is_image(s: &str) -> bool {
|
|||||||
s.ends_with(".jpg") || s.ends_with(".png")
|
s.ends_with(".jpg") || s.ends_with(".png")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_url_only(content: &str) -> bool {
|
||||||
|
let t = content.trim();
|
||||||
|
!t.contains('\n')
|
||||||
|
&& !t.contains(' ')
|
||||||
|
&& (t.starts_with("http://") || t.starts_with("https://"))
|
||||||
|
&& t.len() > 10
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extract_url(content: &str) -> Option<String> {
|
||||||
|
static RE: OnceLock<Regex> = OnceLock::new();
|
||||||
|
let re = RE
|
||||||
|
.get_or_init(|| Regex::new(r#"https?://[^\s<>"'()\[\]{}]+"#).expect("URL regex invalide"));
|
||||||
|
re.find(content).map(|m| {
|
||||||
|
let url = m
|
||||||
|
.as_str()
|
||||||
|
.trim_end_matches(|c: char| matches!(c, '.' | ',' | ';' | ':' | '!' | '?'));
|
||||||
|
url.to_string()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(PartialEq, Clone)]
|
#[derive(PartialEq, Clone)]
|
||||||
pub enum Mode {
|
pub enum Mode {
|
||||||
Normal,
|
Normal,
|
||||||
@ -35,6 +55,7 @@ pub enum Mode {
|
|||||||
Search,
|
Search,
|
||||||
ConfirmDelete,
|
ConfirmDelete,
|
||||||
PasswordInput,
|
PasswordInput,
|
||||||
|
Help,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@ -59,7 +80,6 @@ impl TypeFilter {
|
|||||||
Self::Image => Self::All,
|
Self::Image => Self::All,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn label(self) -> &'static str {
|
pub fn label(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::All => "Tous",
|
Self::All => "Tous",
|
||||||
@ -102,6 +122,40 @@ fn syn_color(c: syntect::highlighting::Color) -> Color {
|
|||||||
Color::Rgb(c.r, c.g, c.b)
|
Color::Rgb(c.r, c.g, c.b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn detect_syntax<'a>(
|
||||||
|
content: &str,
|
||||||
|
syntax_set: &'a SyntaxSet,
|
||||||
|
) -> &'a syntect::parsing::SyntaxReference {
|
||||||
|
if let Some(s) = syntax_set.find_syntax_by_first_line(content) {
|
||||||
|
if s.name != "Plain Text" {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for line in content.lines().take(3) {
|
||||||
|
if let Some(word) = line.split_whitespace().last() {
|
||||||
|
if let Some(ext) = word.rsplit('.').next() {
|
||||||
|
if (1..=6).contains(&ext.len()) && ext.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||||
|
if let Some(s) = syntax_set.find_syntax_by_extension(ext) {
|
||||||
|
if s.name != "Plain Text" {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
syntax_set.find_syntax_plain_text()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detect_lang(content: &str, syntax_set: &SyntaxSet) -> Option<String> {
|
||||||
|
let s = detect_syntax(content, syntax_set);
|
||||||
|
if s.name == "Plain Text" {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(s.name.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn highlight_code(
|
pub fn highlight_code(
|
||||||
content: &str,
|
content: &str,
|
||||||
syntax_set: &SyntaxSet,
|
syntax_set: &SyntaxSet,
|
||||||
@ -112,6 +166,8 @@ pub fn highlight_code(
|
|||||||
let mut h = HighlightLines::new(syntax, theme);
|
let mut h = HighlightLines::new(syntax, theme);
|
||||||
let mut lines = Vec::new();
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
|
let total_lines = content.lines().count();
|
||||||
|
|
||||||
for (no, line) in LinesWithEndings::from(content)
|
for (no, line) in LinesWithEndings::from(content)
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.take(MAX_HIGHLIGHT_LINES)
|
.take(MAX_HIGHLIGHT_LINES)
|
||||||
@ -134,7 +190,6 @@ pub fn highlight_code(
|
|||||||
lines.push(Line::from(spans));
|
lines.push(Line::from(spans));
|
||||||
}
|
}
|
||||||
|
|
||||||
let total_lines = content.lines().count();
|
|
||||||
if total_lines > MAX_HIGHLIGHT_LINES {
|
if total_lines > MAX_HIGHLIGHT_LINES {
|
||||||
lines.push(Line::from(Span::styled(
|
lines.push(Line::from(Span::styled(
|
||||||
format!(
|
format!(
|
||||||
@ -148,43 +203,6 @@ pub fn highlight_code(
|
|||||||
lines
|
lines
|
||||||
}
|
}
|
||||||
|
|
||||||
fn detect_syntax<'a>(
|
|
||||||
content: &str,
|
|
||||||
syntax_set: &'a SyntaxSet,
|
|
||||||
) -> &'a syntect::parsing::SyntaxReference {
|
|
||||||
if let Some(s) = syntax_set.find_syntax_by_first_line(content) {
|
|
||||||
if s.name != "Plain Text" {
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for line in content.lines().take(3) {
|
|
||||||
let trimmed = line.trim_start_matches(|c: char| !c.is_alphanumeric() && c != '.');
|
|
||||||
if let Some(word) = trimmed.split_whitespace().last() {
|
|
||||||
if let Some(ext) = word.rsplit('.').next() {
|
|
||||||
if ext.len() <= 6 && ext.chars().all(|c| c.is_ascii_alphanumeric()) {
|
|
||||||
if let Some(s) = syntax_set.find_syntax_by_extension(ext) {
|
|
||||||
if s.name != "Plain Text" {
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
syntax_set.find_syntax_plain_text()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn detect_lang(content: &str, syntax_set: &SyntaxSet) -> Option<String> {
|
|
||||||
let s = detect_syntax(content, syntax_set);
|
|
||||||
if s.name == "Plain Text" {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(s.name.clone())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let items = ipc::fetch_history(PAGE_SIZE).unwrap_or_default();
|
let items = ipc::fetch_history(PAGE_SIZE).unwrap_or_default();
|
||||||
@ -244,24 +262,19 @@ impl App {
|
|||||||
if !self.has_more {
|
if !self.has_more {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let new_limit = self.loaded_count + PAGE_SIZE;
|
let new_limit = self.loaded_count + PAGE_SIZE;
|
||||||
let Some(items) = ipc::fetch_history(new_limit) else {
|
let Some(items) = ipc::fetch_history(new_limit) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
if items.len() <= self.all_items.len() {
|
if items.len() <= self.all_items.len() {
|
||||||
self.has_more = false;
|
self.has_more = false;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.has_more = items.len() == new_limit;
|
self.has_more = items.len() == new_limit;
|
||||||
self.loaded_count = new_limit;
|
self.loaded_count = new_limit;
|
||||||
|
|
||||||
let selected_content = self.get_selected_item().map(|i| i.content.clone());
|
let selected_content = self.get_selected_item().map(|i| i.content.clone());
|
||||||
self.all_items = items;
|
self.all_items = items;
|
||||||
self.update_search();
|
self.update_search();
|
||||||
|
|
||||||
if let Some(content) = selected_content {
|
if let Some(content) = selected_content {
|
||||||
if let Some(pos) = self
|
if let Some(pos) = self
|
||||||
.filtered_items
|
.filtered_items
|
||||||
@ -273,7 +286,6 @@ impl App {
|
|||||||
self.update_preview();
|
self.update_preview();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.set_status(format!("{} entrées chargées", self.all_items.len()));
|
self.set_status(format!("{} entrées chargées", self.all_items.len()));
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@ -288,7 +300,6 @@ impl App {
|
|||||||
self.image_cache_order.push_back(filename.to_string());
|
self.image_cache_order.push_back(filename.to_string());
|
||||||
return self.image_cache.get(filename).cloned();
|
return self.image_cache.get(filename).cloned();
|
||||||
}
|
}
|
||||||
|
|
||||||
let path = base_dir.join("images").join(filename);
|
let path = base_dir.join("images").join(filename);
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return None;
|
return None;
|
||||||
@ -299,7 +310,6 @@ impl App {
|
|||||||
} else {
|
} else {
|
||||||
img
|
img
|
||||||
};
|
};
|
||||||
|
|
||||||
if self.image_cache.len() >= IMAGE_CACHE_MAX {
|
if self.image_cache.len() >= IMAGE_CACHE_MAX {
|
||||||
if let Some(oldest) = self.image_cache_order.pop_front() {
|
if let Some(oldest) = self.image_cache_order.pop_front() {
|
||||||
self.image_cache.remove(&oldest);
|
self.image_cache.remove(&oldest);
|
||||||
@ -318,9 +328,7 @@ impl App {
|
|||||||
match Local.timestamp_opt(secs, nsecs) {
|
match Local.timestamp_opt(secs, nsecs) {
|
||||||
chrono::LocalResult::Single(dt) => {
|
chrono::LocalResult::Single(dt) => {
|
||||||
let today = Local::now().date_naive();
|
let today = Local::now().date_naive();
|
||||||
let entry_date = dt.date_naive();
|
let diff_days = (today - dt.date_naive()).num_days();
|
||||||
let diff_days = (today - entry_date).num_days();
|
|
||||||
|
|
||||||
if diff_days == 0 {
|
if diff_days == 0 {
|
||||||
dt.format("%H:%M:%S").to_string()
|
dt.format("%H:%M:%S").to_string()
|
||||||
} else if diff_days < 365 {
|
} else if diff_days < 365 {
|
||||||
@ -405,9 +413,9 @@ impl App {
|
|||||||
let mut matched: Vec<(i64, HistoryItem)> = base
|
let mut matched: Vec<(i64, HistoryItem)> = base
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|item| {
|
.filter_map(|item| {
|
||||||
matcher
|
let score = matcher.fuzzy_match(&search_str(&item), &text_query)?;
|
||||||
.fuzzy_match(&search_str(&item), &text_query)
|
let adjusted = score + if item.pinned { 1000 } else { 0 };
|
||||||
.map(|s| (s, item))
|
Some((adjusted, item))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
matched.sort_by(|a, b| b.0.cmp(&a.0));
|
matched.sort_by(|a, b| b.0.cmp(&a.0));
|
||||||
@ -419,7 +427,6 @@ impl App {
|
|||||||
} else {
|
} else {
|
||||||
Some(0)
|
Some(0)
|
||||||
});
|
});
|
||||||
|
|
||||||
self.update_preview();
|
self.update_preview();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -429,7 +436,6 @@ impl App {
|
|||||||
}
|
}
|
||||||
let current = self.list_state.selected().unwrap_or(0);
|
let current = self.list_state.selected().unwrap_or(0);
|
||||||
let last = self.filtered_items.len() - 1;
|
let last = self.filtered_items.len() - 1;
|
||||||
|
|
||||||
if current >= last {
|
if current >= last {
|
||||||
if self.try_load_more() {
|
if self.try_load_more() {
|
||||||
let current = self.list_state.selected().unwrap_or(0);
|
let current = self.list_state.selected().unwrap_or(0);
|
||||||
@ -508,6 +514,69 @@ impl App {
|
|||||||
self.update_preview();
|
self.update_preview();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn toggle_pin(&mut self) {
|
||||||
|
let item = match self.get_selected_item() {
|
||||||
|
Some(i) => i.clone(),
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let new_pinned = !item.pinned;
|
||||||
|
|
||||||
|
if !ipc::pin_entry(item.content.clone(), new_pinned) {
|
||||||
|
self.set_error("Erreur pin : daemon injoignable".into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for e in self.all_items.iter_mut() {
|
||||||
|
if e.content == item.content {
|
||||||
|
e.pinned = new_pinned;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.all_items
|
||||||
|
.sort_by(|a, b| b.pinned.cmp(&a.pinned).then(b.timestamp.cmp(&a.timestamp)));
|
||||||
|
|
||||||
|
let sel_content = item.content.clone();
|
||||||
|
self.update_search();
|
||||||
|
|
||||||
|
if let Some(pos) = self
|
||||||
|
.filtered_items
|
||||||
|
.iter()
|
||||||
|
.position(|x| x.content == sel_content)
|
||||||
|
{
|
||||||
|
self.list_state.select(Some(pos));
|
||||||
|
self.last_selected_index = None;
|
||||||
|
self.update_preview();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.set_status(if new_pinned {
|
||||||
|
"★ Épinglé".into()
|
||||||
|
} else {
|
||||||
|
"Désépinglé".into()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_url_selected(&mut self) {
|
||||||
|
let content = match self.get_selected_item().map(|i| i.content.clone()) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
match extract_url(&content) {
|
||||||
|
Some(url) => match std::process::Command::new("xdg-open").arg(&url).spawn() {
|
||||||
|
Ok(_) => {
|
||||||
|
let preview = if url.len() > 48 {
|
||||||
|
format!("{}…", &url[..48])
|
||||||
|
} else {
|
||||||
|
url.clone()
|
||||||
|
};
|
||||||
|
self.set_status(format!("Ouverture : {preview}"));
|
||||||
|
}
|
||||||
|
Err(e) => self.set_error(format!("xdg-open : {e}")),
|
||||||
|
},
|
||||||
|
None => self.set_error("Aucune URL trouvée dans cette entrée".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn toggle_encrypt(&mut self) {
|
pub fn toggle_encrypt(&mut self) {
|
||||||
let content = match self.get_selected_item() {
|
let content = match self.get_selected_item() {
|
||||||
Some(i) => i.content.clone(),
|
Some(i) => i.content.clone(),
|
||||||
@ -703,7 +772,6 @@ impl App {
|
|||||||
pub fn scroll_preview_down(&mut self) {
|
pub fn scroll_preview_down(&mut self) {
|
||||||
self.preview_scroll = self.preview_scroll.saturating_add(3);
|
self.preview_scroll = self.preview_scroll.saturating_add(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn scroll_preview_up(&mut self) {
|
pub fn scroll_preview_up(&mut self) {
|
||||||
self.preview_scroll = self.preview_scroll.saturating_sub(3);
|
self.preview_scroll = self.preview_scroll.saturating_sub(3);
|
||||||
}
|
}
|
||||||
@ -731,13 +799,12 @@ impl App {
|
|||||||
.all_items
|
.all_items
|
||||||
.iter()
|
.iter()
|
||||||
.zip(&new)
|
.zip(&new)
|
||||||
.any(|(a, b)| a.content != b.content);
|
.any(|(a, b)| a.content != b.content || a.pinned != b.pinned);
|
||||||
|
|
||||||
if changed {
|
if changed {
|
||||||
let selected_content = self.get_selected_item().map(|i| i.content.clone());
|
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(content) = selected_content {
|
||||||
if let Some(pos) = self
|
if let Some(pos) = self
|
||||||
.filtered_items
|
.filtered_items
|
||||||
@ -755,7 +822,6 @@ impl App {
|
|||||||
pub fn set_error(&mut self, msg: String) {
|
pub fn set_error(&mut self, msg: String) {
|
||||||
self.error_message = Some((msg, Instant::now()));
|
self.error_message = Some((msg, Instant::now()));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_status(&mut self, msg: String) {
|
pub fn set_status(&mut self, msg: String) {
|
||||||
self.status_message = Some((msg, Instant::now()));
|
self.status_message = Some((msg, Instant::now()));
|
||||||
}
|
}
|
||||||
|
|||||||
13
src/ipc.rs
13
src/ipc.rs
@ -6,6 +6,8 @@ use std::os::unix::net::UnixStream;
|
|||||||
pub struct HistoryItem {
|
pub struct HistoryItem {
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub timestamp: i64,
|
pub timestamp: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub pinned: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
@ -26,6 +28,10 @@ pub enum IpcRequest {
|
|||||||
AddEntry {
|
AddEntry {
|
||||||
content: String,
|
content: String,
|
||||||
},
|
},
|
||||||
|
PinEntry {
|
||||||
|
content: String,
|
||||||
|
pinned: bool,
|
||||||
|
},
|
||||||
ClearHistory,
|
ClearHistory,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,6 +90,13 @@ pub fn add_entry(content: String) {
|
|||||||
let _ = send_request(&IpcRequest::AddEntry { content });
|
let _ = send_request(&IpcRequest::AddEntry { content });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn pin_entry(content: String, pinned: bool) -> bool {
|
||||||
|
matches!(
|
||||||
|
send_request(&IpcRequest::PinEntry { content, pinned }),
|
||||||
|
Some(IpcResponse::Ok)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn clear_history() -> bool {
|
pub fn clear_history() -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
send_request(&IpcRequest::ClearHistory),
|
send_request(&IpcRequest::ClearHistory),
|
||||||
|
|||||||
194
src/main.rs
194
src/main.rs
@ -43,7 +43,8 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
|
|||||||
|
|
||||||
if event::poll(Duration::from_millis(250))? {
|
if event::poll(Duration::from_millis(250))? {
|
||||||
if let Event::Key(key) = event::read()? {
|
if let Event::Key(key) = event::read()? {
|
||||||
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
// Ctrl+j / Ctrl+k : scroll prévisualisation (tous modes sauf aide)
|
||||||
|
if key.modifiers.contains(KeyModifiers::CONTROL) && app.mode != Mode::Help {
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Char('j') => {
|
KeyCode::Char('j') => {
|
||||||
app.scroll_preview_down();
|
app.scroll_preview_down();
|
||||||
@ -58,75 +59,18 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
|
|||||||
}
|
}
|
||||||
|
|
||||||
match app.mode {
|
match app.mode {
|
||||||
Mode::Normal => match key.code {
|
// ----------------------------------------------------------
|
||||||
KeyCode::Enter => app.paste_selected(),
|
Mode::Help => {
|
||||||
KeyCode::Char('j') | KeyCode::Down => app.next(),
|
// N'importe quelle touche ferme l'aide
|
||||||
KeyCode::Char('k') | KeyCode::Up => app.previous(),
|
app.mode = Mode::Normal;
|
||||||
KeyCode::Char('G') => {
|
}
|
||||||
if !app.filtered_items.is_empty() {
|
|
||||||
let l = app.filtered_items.len() - 1;
|
|
||||||
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));
|
|
||||||
app.update_preview();
|
|
||||||
}
|
|
||||||
last_g = false;
|
|
||||||
} else {
|
|
||||||
last_g = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Char('d') => {
|
|
||||||
last_g = false;
|
|
||||||
if last_d {
|
|
||||||
app.mode = Mode::ConfirmDelete;
|
|
||||||
last_d = false;
|
|
||||||
} else {
|
|
||||||
last_d = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
Mode::Normal => {
|
||||||
|
last_d = handle_normal(app, key.code, last_d, &mut last_g);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
Mode::Search => match key.code {
|
Mode::Search => match key.code {
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
app.mode = Mode::Normal;
|
app.mode = Mode::Normal;
|
||||||
@ -136,6 +80,10 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
|
|||||||
KeyCode::Enter => app.paste_selected(),
|
KeyCode::Enter => app.paste_selected(),
|
||||||
KeyCode::Down => app.next(),
|
KeyCode::Down => app.next(),
|
||||||
KeyCode::Up => app.previous(),
|
KeyCode::Up => app.previous(),
|
||||||
|
KeyCode::Char('o') if app.input_buffer.is_empty() => {
|
||||||
|
// `o` sans texte saisi → ouvre URL
|
||||||
|
app.open_url_selected();
|
||||||
|
}
|
||||||
KeyCode::Char(c) => {
|
KeyCode::Char(c) => {
|
||||||
app.input_buffer.push(c);
|
app.input_buffer.push(c);
|
||||||
app.update_search();
|
app.update_search();
|
||||||
@ -147,11 +95,11 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
|
|||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
Mode::Command => match key.code {
|
Mode::Command => match key.code {
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
app.mode = Mode::Normal;
|
app.mode = Mode::Normal;
|
||||||
app.input_buffer.clear();
|
app.input_buffer.clear();
|
||||||
app.update_search();
|
|
||||||
}
|
}
|
||||||
KeyCode::Char(c) => app.input_buffer.push(c),
|
KeyCode::Char(c) => app.input_buffer.push(c),
|
||||||
KeyCode::Backspace => {
|
KeyCode::Backspace => {
|
||||||
@ -168,12 +116,13 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
|
|||||||
app.pending_action = None;
|
app.pending_action = None;
|
||||||
app.mode = Mode::PasswordInput;
|
app.mode = Mode::PasswordInput;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => app.set_error(format!("Commande inconnue : {cmd}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
Mode::ConfirmDelete => match key.code {
|
Mode::ConfirmDelete => match key.code {
|
||||||
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
|
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
|
||||||
if let Some(item) = app.get_selected_item() {
|
if let Some(item) = app.get_selected_item() {
|
||||||
@ -194,6 +143,7 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
|
|||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
Mode::PasswordInput => match key.code {
|
Mode::PasswordInput => match key.code {
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
app.mode = Mode::Normal;
|
app.mode = Mode::Normal;
|
||||||
@ -223,3 +173,105 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gère les touches en mode Normal. Retourne le nouvel état de `last_d`.
|
||||||
|
fn handle_normal(app: &mut App, code: KeyCode, last_d: bool, last_g: &mut bool) -> bool {
|
||||||
|
match code {
|
||||||
|
KeyCode::Char('?') => {
|
||||||
|
app.mode = Mode::Help;
|
||||||
|
*last_g = false;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
app.paste_selected();
|
||||||
|
*last_g = false;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
KeyCode::Char('j') | KeyCode::Down => {
|
||||||
|
app.next();
|
||||||
|
*last_g = false;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
KeyCode::Char('k') | KeyCode::Up => {
|
||||||
|
app.previous();
|
||||||
|
*last_g = false;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
KeyCode::Char('G') => {
|
||||||
|
if !app.filtered_items.is_empty() {
|
||||||
|
let l = app.filtered_items.len() - 1;
|
||||||
|
app.list_state.select(Some(l));
|
||||||
|
app.update_preview();
|
||||||
|
}
|
||||||
|
*last_g = false;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
KeyCode::Char('g') => {
|
||||||
|
if *last_g {
|
||||||
|
if !app.filtered_items.is_empty() {
|
||||||
|
app.list_state.select(Some(0));
|
||||||
|
app.update_preview();
|
||||||
|
}
|
||||||
|
*last_g = false;
|
||||||
|
} else {
|
||||||
|
*last_g = true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
KeyCode::Char('d') => {
|
||||||
|
*last_g = false;
|
||||||
|
if last_d {
|
||||||
|
app.mode = Mode::ConfirmDelete;
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true // dernier appui était 'd'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('u') => {
|
||||||
|
app.undo_delete();
|
||||||
|
*last_g = false;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
KeyCode::Char('p') => {
|
||||||
|
app.toggle_pin();
|
||||||
|
*last_g = false;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
KeyCode::Char('o') => {
|
||||||
|
app.open_url_selected();
|
||||||
|
*last_g = false;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
KeyCode::Char('e') => {
|
||||||
|
app.toggle_encrypt();
|
||||||
|
*last_g = false;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
KeyCode::Char('t') => {
|
||||||
|
app.cycle_type_filter();
|
||||||
|
*last_g = false;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
KeyCode::Char('/') => {
|
||||||
|
app.mode = Mode::Search;
|
||||||
|
app.input_buffer.clear();
|
||||||
|
app.update_search();
|
||||||
|
*last_g = false;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
KeyCode::Char(':') => {
|
||||||
|
app.mode = Mode::Command;
|
||||||
|
app.input_buffer.clear();
|
||||||
|
*last_g = false;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
KeyCode::Char('q') => {
|
||||||
|
app.should_quit = true;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
*last_g = false;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ use uuid::Uuid;
|
|||||||
pub struct ClipboardEntry {
|
pub struct ClipboardEntry {
|
||||||
pub content: ClipboardData,
|
pub content: ClipboardData,
|
||||||
pub timestamp: SystemTime,
|
pub timestamp: SystemTime,
|
||||||
|
pub pinned: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@ -29,7 +30,6 @@ impl Image {
|
|||||||
.join("images")
|
.join("images")
|
||||||
.join(format!("{}.jpg", self.id))
|
.join(format!("{}.jpg", self.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_bytes(&self, dir_path: &str) -> io::Result<Vec<u8>> {
|
pub fn load_bytes(&self, dir_path: &str) -> io::Result<Vec<u8>> {
|
||||||
fs::read(self.file_path(dir_path))
|
fs::read(self.file_path(dir_path))
|
||||||
}
|
}
|
||||||
|
|||||||
246
src/ui.rs
246
src/ui.rs
@ -1,14 +1,18 @@
|
|||||||
use crate::app::{App, Mode, detect_lang, highlight_code};
|
use crate::app::{App, Mode, detect_lang, highlight_code, is_image, is_url_only};
|
||||||
use crate::crypto::Crypto;
|
use crate::crypto::Crypto;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::{Alignment, Constraint, Direction, Layout},
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||||
style::{Color, Modifier, Style},
|
style::{Color, Modifier, Style},
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{Block, BorderType, Borders, List, ListItem, Padding, Paragraph},
|
widgets::{Block, BorderType, Borders, Clear, List, ListItem, Padding, Paragraph},
|
||||||
};
|
};
|
||||||
use ratatui_image::StatefulImage;
|
use ratatui_image::StatefulImage;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Point d'entrée
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
pub fn render(f: &mut Frame, app: &mut App) {
|
pub fn render(f: &mut Frame, app: &mut App) {
|
||||||
let outer = Layout::default()
|
let outer = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
@ -20,6 +24,26 @@ pub fn render(f: &mut Frame, app: &mut App) {
|
|||||||
.constraints([Constraint::Length(46), Constraint::Min(0)])
|
.constraints([Constraint::Length(46), Constraint::Min(0)])
|
||||||
.split(outer[0]);
|
.split(outer[0]);
|
||||||
|
|
||||||
|
// ---- Liste ----
|
||||||
|
render_list(f, app, panels[0]);
|
||||||
|
|
||||||
|
// ---- Prévisualisation ----
|
||||||
|
render_preview(f, app, panels[1]);
|
||||||
|
|
||||||
|
// ---- Barre de statut ----
|
||||||
|
render_statusbar(f, app, outer[1]);
|
||||||
|
|
||||||
|
// ---- Overlay aide (par-dessus tout le reste) ----
|
||||||
|
if app.mode == Mode::Help {
|
||||||
|
render_help_overlay(f, f.area());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Liste
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn render_list(f: &mut Frame, app: &mut App, area: Rect) {
|
||||||
let items: Vec<ListItem> = app
|
let items: Vec<ListItem> = app
|
||||||
.filtered_items
|
.filtered_items
|
||||||
.iter()
|
.iter()
|
||||||
@ -30,8 +54,21 @@ pub fn render(f: &mut Frame, app: &mut App) {
|
|||||||
Style::default().fg(Color::Rgb(90, 90, 110)),
|
Style::default().fg(Color::Rgb(90, 90, 110)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Indicateur d'épingle (largeur fixe pour garder l'alignement)
|
||||||
|
let pin_span = if item.pinned {
|
||||||
|
Span::styled(
|
||||||
|
"★ ",
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Span::raw(" ")
|
||||||
|
};
|
||||||
|
|
||||||
if Crypto::is_any_encrypted(&item.content) {
|
if Crypto::is_any_encrypted(&item.content) {
|
||||||
ListItem::new(Line::from(vec![
|
ListItem::new(Line::from(vec![
|
||||||
|
pin_span,
|
||||||
ts_span,
|
ts_span,
|
||||||
Span::styled(
|
Span::styled(
|
||||||
"🔒 [Chiffré]",
|
"🔒 [Chiffré]",
|
||||||
@ -40,8 +77,9 @@ pub fn render(f: &mut Frame, app: &mut App) {
|
|||||||
.add_modifier(Modifier::ITALIC),
|
.add_modifier(Modifier::ITALIC),
|
||||||
),
|
),
|
||||||
]))
|
]))
|
||||||
} else if item.content.ends_with(".jpg") || item.content.ends_with(".png") {
|
} else if is_image(&item.content) {
|
||||||
ListItem::new(Line::from(vec![
|
ListItem::new(Line::from(vec![
|
||||||
|
pin_span,
|
||||||
ts_span,
|
ts_span,
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!("🖼 {}", &item.content),
|
format!("🖼 {}", &item.content),
|
||||||
@ -50,6 +88,19 @@ pub fn render(f: &mut Frame, app: &mut App) {
|
|||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
]))
|
]))
|
||||||
|
} else if is_url_only(&item.content) {
|
||||||
|
let preview: String = item.content.chars().take(26).collect();
|
||||||
|
ListItem::new(Line::from(vec![
|
||||||
|
pin_span,
|
||||||
|
ts_span,
|
||||||
|
Span::styled(
|
||||||
|
"[URL] ",
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(preview, Style::default().fg(Color::Rgb(100, 180, 255))),
|
||||||
|
]))
|
||||||
} else {
|
} else {
|
||||||
let preview: String = item
|
let preview: String = item
|
||||||
.content
|
.content
|
||||||
@ -57,9 +108,9 @@ pub fn render(f: &mut Frame, app: &mut App) {
|
|||||||
.find(|l| !l.trim().is_empty())
|
.find(|l| !l.trim().is_empty())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.chars()
|
.chars()
|
||||||
.take(28)
|
.take(26)
|
||||||
.collect();
|
.collect();
|
||||||
ListItem::new(Line::from(vec![ts_span, Span::raw(preview)]))
|
ListItem::new(Line::from(vec![pin_span, ts_span, Span::raw(preview)]))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@ -86,8 +137,14 @@ pub fn render(f: &mut Frame, app: &mut App) {
|
|||||||
)
|
)
|
||||||
.highlight_symbol("▶ ");
|
.highlight_symbol("▶ ");
|
||||||
|
|
||||||
f.render_stateful_widget(list, panels[0], &mut app.list_state);
|
f.render_stateful_widget(list, area, &mut app.list_state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Prévisualisation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn render_preview(f: &mut Frame, app: &mut App, area: Rect) {
|
||||||
let selected_content = app.get_selected_item().map(|i| i.content.clone());
|
let selected_content = app.get_selected_item().map(|i| i.content.clone());
|
||||||
|
|
||||||
let preview_title = match &app.preview_lang {
|
let preview_title = match &app.preview_lang {
|
||||||
@ -108,8 +165,8 @@ pub fn render(f: &mut Frame, app: &mut App) {
|
|||||||
.title_alignment(Alignment::Center)
|
.title_alignment(Alignment::Center)
|
||||||
.padding(Padding::uniform(1));
|
.padding(Padding::uniform(1));
|
||||||
|
|
||||||
let inner = preview_block.inner(panels[1]);
|
let inner = preview_block.inner(area);
|
||||||
f.render_widget(preview_block, panels[1]);
|
f.render_widget(preview_block, area);
|
||||||
|
|
||||||
let scroll = (app.preview_scroll, 0);
|
let scroll = (app.preview_scroll, 0);
|
||||||
|
|
||||||
@ -122,17 +179,40 @@ pub fn render(f: &mut Frame, app: &mut App) {
|
|||||||
.scroll(scroll),
|
.scroll(scroll),
|
||||||
inner,
|
inner,
|
||||||
);
|
);
|
||||||
|
} else if is_url_only(content) {
|
||||||
|
// Affiche l'URL complète + hint
|
||||||
|
let lines = vec![
|
||||||
|
Line::from(Span::styled(
|
||||||
|
content.trim(),
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::UNDERLINED),
|
||||||
|
)),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
" [o] Ouvrir dans le navigateur",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
f.render_widget(Paragraph::new(lines).scroll(scroll), inner);
|
||||||
} else if let Some(lines) = &app.preview_highlighted {
|
} else if let Some(lines) = &app.preview_highlighted {
|
||||||
f.render_widget(Paragraph::new(lines.clone()).scroll(scroll), inner);
|
f.render_widget(Paragraph::new(lines.clone()).scroll(scroll), inner);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Barre de statut
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn render_statusbar(f: &mut Frame, app: &mut App, area: Rect) {
|
||||||
let (mode_label, mode_color) = match &app.mode {
|
let (mode_label, mode_color) = match &app.mode {
|
||||||
Mode::Normal => (" NORMAL ", Color::Green),
|
Mode::Normal => (" NORMAL ", Color::Green),
|
||||||
Mode::Search => (" RECHERCHE ", Color::Cyan),
|
Mode::Search => (" RECHERCHE ", Color::Cyan),
|
||||||
Mode::Command => (" COMMANDE ", Color::Yellow),
|
Mode::Command => (" COMMANDE ", Color::Yellow),
|
||||||
Mode::ConfirmDelete => (" SUPPRIMER ? y/n ", Color::Red),
|
Mode::ConfirmDelete => (" SUPPRIMER ? y/n ", Color::Red),
|
||||||
Mode::PasswordInput => (" MOT DE PASSE ", Color::Magenta),
|
Mode::PasswordInput => (" MOT DE PASSE ", Color::Magenta),
|
||||||
|
Mode::Help => (" AIDE ", Color::Blue),
|
||||||
};
|
};
|
||||||
|
|
||||||
let filter_hint = match app.type_filter {
|
let filter_hint = match app.type_filter {
|
||||||
@ -156,6 +236,7 @@ pub fn render(f: &mut Frame, app: &mut App) {
|
|||||||
}
|
}
|
||||||
Mode::Command => format!(" :{}", app.input_buffer),
|
Mode::Command => format!(" :{}", app.input_buffer),
|
||||||
Mode::PasswordInput => format!(" {}", "●".repeat(app.input_buffer.len())),
|
Mode::PasswordInput => format!(" {}", "●".repeat(app.input_buffer.len())),
|
||||||
|
Mode::Help => " ? ou Esc pour fermer".to_string(),
|
||||||
_ => filter_hint,
|
_ => filter_hint,
|
||||||
};
|
};
|
||||||
Span::raw(extra)
|
Span::raw(extra)
|
||||||
@ -177,7 +258,7 @@ pub fn render(f: &mut Frame, app: &mut App) {
|
|||||||
let status_cols = Layout::default()
|
let status_cols = Layout::default()
|
||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Horizontal)
|
||||||
.constraints([Constraint::Min(0), Constraint::Length(clen)])
|
.constraints([Constraint::Min(0), Constraint::Length(clen)])
|
||||||
.split(outer[1]);
|
.split(area);
|
||||||
|
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
Paragraph::new(Line::from(vec![
|
Paragraph::new(Line::from(vec![
|
||||||
@ -192,6 +273,7 @@ pub fn render(f: &mut Frame, app: &mut App) {
|
|||||||
])),
|
])),
|
||||||
status_cols[0],
|
status_cols[0],
|
||||||
);
|
);
|
||||||
|
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
Paragraph::new(Line::from(Span::styled(
|
Paragraph::new(Line::from(Span::styled(
|
||||||
counter,
|
counter,
|
||||||
@ -203,3 +285,147 @@ pub fn render(f: &mut Frame, app: &mut App) {
|
|||||||
status_cols[1],
|
status_cols[1],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Overlay d'aide
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
|
||||||
|
let x = area.x + (area.width.saturating_sub(width)) / 2;
|
||||||
|
let y = area.y + (area.height.saturating_sub(height)) / 2;
|
||||||
|
Rect::new(x, y, width.min(area.width), height.min(area.height))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn help_lines() -> Vec<Line<'static>> {
|
||||||
|
let k = |s: &'static str| {
|
||||||
|
Span::styled(
|
||||||
|
s,
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let d = |s: &'static str| Span::styled(s, Style::default().fg(Color::White));
|
||||||
|
let sep = || Span::raw(" ");
|
||||||
|
let h = |s: &'static str| {
|
||||||
|
Span::styled(
|
||||||
|
s,
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let dim = |s: &'static str| Span::styled(s, Style::default().fg(Color::DarkGray));
|
||||||
|
|
||||||
|
vec![
|
||||||
|
Line::from(vec![h("Navigation")]),
|
||||||
|
Line::from(vec![
|
||||||
|
k(" j / ↓"),
|
||||||
|
sep(),
|
||||||
|
d("Bas"),
|
||||||
|
sep(),
|
||||||
|
sep(),
|
||||||
|
k("k / ↑"),
|
||||||
|
sep(),
|
||||||
|
d("Haut"),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
k(" g g"),
|
||||||
|
sep(),
|
||||||
|
d("Premier"),
|
||||||
|
sep(),
|
||||||
|
sep(),
|
||||||
|
k("G"),
|
||||||
|
sep(),
|
||||||
|
d("Dernier"),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
k(" Ctrl+j"),
|
||||||
|
sep(),
|
||||||
|
d("Scroll prévisualisation ↓"),
|
||||||
|
sep(),
|
||||||
|
k("Ctrl+k"),
|
||||||
|
sep(),
|
||||||
|
d("↑"),
|
||||||
|
]),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![h("Actions")]),
|
||||||
|
Line::from(vec![k(" Entrée"), sep(), d("Coller & quitter")]),
|
||||||
|
Line::from(vec![
|
||||||
|
k(" d d"),
|
||||||
|
sep(),
|
||||||
|
d("Supprimer (demande confirmation)"),
|
||||||
|
]),
|
||||||
|
Line::from(vec![k(" u"), sep(), d("Annuler la suppression")]),
|
||||||
|
Line::from(vec![k(" p"), sep(), d("★ Épingler / désépingler")]),
|
||||||
|
Line::from(vec![k(" e"), sep(), d("🔒 Chiffrer / déchiffrer")]),
|
||||||
|
Line::from(vec![k(" o"), sep(), d("Ouvrir l'URL dans le navigateur")]),
|
||||||
|
Line::from(vec![
|
||||||
|
k(" t"),
|
||||||
|
sep(),
|
||||||
|
d("Filtrer par type (Tous → Texte → Image)"),
|
||||||
|
]),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![h("Recherche (mode /)")]),
|
||||||
|
Line::from(vec![
|
||||||
|
k(" /texte"),
|
||||||
|
sep(),
|
||||||
|
d("Fuzzy search"),
|
||||||
|
sep(),
|
||||||
|
k("//regex"),
|
||||||
|
sep(),
|
||||||
|
d("Regex (préfixe /)"),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
k(" after:YYYY-MM-DD"),
|
||||||
|
sep(),
|
||||||
|
d("Après date"),
|
||||||
|
sep(),
|
||||||
|
k("before:..."),
|
||||||
|
sep(),
|
||||||
|
d("Avant date"),
|
||||||
|
]),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![h("Commandes (:)")]),
|
||||||
|
Line::from(vec![
|
||||||
|
k(" :clear"),
|
||||||
|
sep(),
|
||||||
|
d("Effacer tout l'historique"),
|
||||||
|
sep(),
|
||||||
|
k(":password"),
|
||||||
|
sep(),
|
||||||
|
d("Mot de passe session"),
|
||||||
|
sep(),
|
||||||
|
k(":q"),
|
||||||
|
sep(),
|
||||||
|
d("Quitter"),
|
||||||
|
]),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![dim(" ? / Esc pour fermer cette aide")]),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_help_overlay(f: &mut Frame, area: Rect) {
|
||||||
|
let popup = centered_rect(68, 25, area);
|
||||||
|
f.render_widget(Clear, popup);
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.title(Span::styled(
|
||||||
|
" ? Aide — Raccourcis clavier ",
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::White)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
))
|
||||||
|
.title_alignment(Alignment::Center)
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_type(BorderType::Rounded)
|
||||||
|
.border_style(Style::default().fg(Color::Cyan))
|
||||||
|
.style(Style::default().bg(Color::Rgb(18, 18, 28)));
|
||||||
|
|
||||||
|
let inner = block.inner(popup);
|
||||||
|
f.render_widget(block, popup);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(help_lines()).style(Style::default().bg(Color::Rgb(18, 18, 28))),
|
||||||
|
inner,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user