diff --git a/src/clipboard/exec.rs b/src/clipboard/exec.rs index c1ac808..11c80e3 100644 --- a/src/clipboard/exec.rs +++ b/src/clipboard/exec.rs @@ -4,11 +4,11 @@ use std::process::{Command, Stdio}; use anyhow::{bail, Context, Result}; /// Checks if a clipboard command is available and working -/// +/// /// # Arguments /// * `name` - Name of the command to check (e.g., "pbcopy", "wl-copy") /// * `args` - Command line arguments for version/help check -/// +/// /// # Returns /// - `Ok(())` if command exists and works /// - `Err` if command is not found or not working @@ -20,12 +20,12 @@ pub fn check_command(name: &str, args: &[&str]) -> Result<()> { } /// Executes a command to write data to clipboard -/// +/// /// # Arguments /// * `name` - Name of the clipboard write command (e.g., "pbcopy", "wl-copy") /// * `args` - Optional command line arguments /// * `data` - Data to write to clipboard -/// +/// /// # Returns /// - `Ok(())` if write succeeds /// - `Err` if command fails or writing fails @@ -61,16 +61,16 @@ pub fn execute_write_command(name: &str, args: &[&str], data: &[u8]) -> Result<( } /// Executes a command to read data from clipboard -/// +/// /// # Arguments /// * `name` - Name of the clipboard read command (e.g., "pbpaste", "wl-paste") /// * `args` - Optional command line arguments -/// +/// /// # Returns /// - `Ok(Vec)` containing the clipboard data if read succeeds /// - `Ok(Vec::new())` if clipboard is empty (special case for some commands) /// - `Err` if command fails or reading fails -/// +/// /// # Special Cases /// Handles the special case where some clipboard commands return exit code 1 /// with "Nothing is copied" message to indicate empty clipboard diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 76715fb..76339f9 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -6,6 +6,7 @@ mod delete; mod get; mod put; mod read; +mod select; mod server; #[cfg(feature = "tray")] mod tray; @@ -33,40 +34,44 @@ pub struct App { #[derive(Subcommand)] pub enum Commands { + Cani(cani::CaniArgs), + Cb(cb::CbArgs), Config(config::ShowConfigArgs), Delete(delete::DeleteArgs), Get(get::GetArgs), Put(put::PutCommand), Read(read::ReadCommand), - Cb(cb::CbArgs), - Whoami(whoami::WhoamiArgs), - Cani(cani::CaniArgs), + Select(select::SelectArgs), Version(version::VersionArgs), + Whoami(whoami::WhoamiArgs), + #[cfg(feature = "tray")] Tray(tray::TrayArgs), - Server(server::ServerArgs), Daemon(daemon::DaemonArgs), + Server(server::ServerArgs), } #[async_trait] impl RunCommand for App { async fn run(&self) -> Result<()> { match &self.command { + Commands::Cani(args) => args.run().await, + Commands::Cb(args) => args.run().await, Commands::Config(args) => args.run().await, Commands::Delete(args) => args.run().await, Commands::Get(args) => args.run().await, Commands::Put(args) => args.run().await, Commands::Read(args) => args.run().await, - Commands::Cb(args) => args.run().await, - Commands::Whoami(args) => args.run().await, - Commands::Cani(args) => args.run().await, + Commands::Select(args) => args.run().await, Commands::Version(args) => args.run().await, + Commands::Whoami(args) => args.run().await, + #[cfg(feature = "tray")] Commands::Tray(args) => args.run().await, - Commands::Server(_) => unreachable!(), Commands::Daemon(_) => unreachable!(), + Commands::Server(_) => unreachable!(), } } } diff --git a/src/cmd/read/text.rs b/src/cmd/read/text.rs index ba8becb..72b72d7 100644 --- a/src/cmd/read/text.rs +++ b/src/cmd/read/text.rs @@ -6,6 +6,7 @@ use crate::client::factory::ClientFactory; use crate::client::Client; use crate::clipboard::Clipboard; use crate::cmd::{ConfigArgs, QueryArgs, RunCommand}; +use crate::types::text::truncate_text; /// Read text content from the server. This command allows reading multiple text contents /// for further filtering and other operations. @@ -104,9 +105,7 @@ impl TextArgs { for text in texts { let id = text.id; let content = text.content.unwrap(); - let line = Self::truncate_string(content, self.length); - - let line = line.replace("\n", " "); + let line = truncate_text(content, self.length); println!("{id}. {line}"); } @@ -128,19 +127,4 @@ impl TextArgs { input[..num_end].parse().context("parse id number") } - - pub fn truncate_string(mut s: String, max_len: usize) -> String { - if s.chars().count() <= max_len { - return s; - } - - s.truncate( - s.char_indices() - .nth(max_len) - .map(|(i, _)| i) - .unwrap_or(s.len()), - ); - s.push_str("..."); - s - } } diff --git a/src/cmd/select.rs b/src/cmd/select.rs new file mode 100644 index 0000000..632cabd --- /dev/null +++ b/src/cmd/select.rs @@ -0,0 +1,166 @@ +use std::io::{Read, Write}; +use std::process::{Command, Stdio}; + +use anyhow::{bail, Context, Result}; +use async_trait::async_trait; +use clap::Args; + +use crate::client::factory::ClientFactory; +use crate::client::Client; +use crate::clipboard::Clipboard; +use crate::config::CommonConfig; +use crate::daemon::client::DaemonClient; +use crate::daemon::config::DaemonConfig; +use crate::humanize::human_bytes; +use crate::types::text::truncate_text; + +use super::{ConfigArgs, QueryArgs, RunCommand}; + +/// Read the latest text content list and use an external command (e.g., fzf) for searching and selecting. +/// The selected result can be processed in different ways (clipboard, daemon, or stdout). +#[derive(Args)] +pub struct SelectArgs { + /// The external selection command to execute. Text content will be written to stdin, + /// and results will be read from stdout. stderr is inherited directly. + /// Each candidate will be written as a line to stdin, truncated according to --length option. + #[arg(short, long, default_value = "fzf")] + pub cmd: String, + + /// Maximum length of displayed text. Text longer than this will be truncated + #[arg(short, long, default_value = "100")] + pub length: usize, + + /// Send the selected text to the daemon server + #[arg(short, long)] + pub daemon: bool, + + /// Write the selected text to clipboard + #[arg(long)] + pub cb: bool, + + /// Don't trim the trailing newline from the selection command output + #[arg(long)] + pub no_trim_break: bool, + + #[command(flatten)] + pub query: QueryArgs, + + #[command(flatten)] + pub config: ConfigArgs, +} + +#[async_trait] +impl RunCommand for SelectArgs { + async fn run(&self) -> Result<()> { + let ps = self.config.build_path_set()?; + + let client_factory = ClientFactory::load(&ps)?; + let client = client_factory.build_client_with_token_file().await?; + + let text = self.select_text(client).await?; + + if self.daemon { + let cfg: DaemonConfig = ps.load_config("daemon", DaemonConfig::default)?; + let client = DaemonClient::new(cfg.port); + + let data = text.into_bytes(); + let size = human_bytes(data.len() as u64); + client + .send_data(data) + .await + .context("send data to daemon")?; + println!("Send {size} data to daemon server"); + return Ok(()); + } + + if self.cb { + let cb = Clipboard::load()?; + let size = human_bytes(text.len() as u64); + cb.write_text(text).context("write text to clipboard")?; + println!("Write {size} text to clipboard"); + return Ok(()); + } + + print!("{text}"); + Ok(()) + } +} + +impl SelectArgs { + const MAX_LENGTH: usize = 200; + + async fn select_text(&self, client: Client) -> Result { + if self.length == 0 { + bail!("Length must be greater than 0"); + } + if self.length > Self::MAX_LENGTH { + bail!("Length must be less than {}", Self::MAX_LENGTH); + } + + let mut texts = client.read_texts(self.query.build_query()?).await?; + + if texts.is_empty() { + bail!("No text found"); + } + + let mut items = Vec::with_capacity(texts.len()); + for text in texts.iter() { + let line = truncate_text(text.content.clone().unwrap(), self.length); + items.push(line); + } + + let idx = self.execute_cmd(&items)?; + let text = texts.remove(idx).content.unwrap(); + Ok(text) + } + + fn execute_cmd(&self, items: &[String]) -> Result { + let mut input = String::with_capacity(items.len()); + for item in items.iter() { + input.push_str(item); + input.push('\n'); + } + + let mut c = Command::new("bash"); + c.args(["-c", &self.cmd]); + + c.stdin(Stdio::piped()); + c.stdout(Stdio::piped()); + c.stderr(Stdio::inherit()); + + let mut child = c.spawn().context("launch select command")?; + + let handle = child.stdin.as_mut().unwrap(); + if let Err(err) = write!(handle, "{}", input) { + return Err(err).context("write data to select command"); + } + + drop(child.stdin.take()); + + let mut stdout = child.stdout.take().unwrap(); + + let mut out = String::new(); + stdout + .read_to_string(&mut out) + .context("read select command output")?; + + let result = if self.no_trim_break { + &out + } else { + if out.is_empty() { + bail!("select command output is empty"); + } + &out[..out.len() - 1] + }; + + let status = child.wait().context("wait select command done")?; + match status.code() { + Some(0) => match items.iter().position(|s| s == result) { + Some(idx) => Ok(idx), + None => bail!("could not find item '{result}'"), + }, + Some(code) => bail!("select command exited with code {code}"), + None => bail!("select command returned an unknown error"), + } + } +} diff --git a/src/tray/daemon.rs b/src/tray/daemon.rs index 91c9e29..47b94e3 100644 --- a/src/tray/daemon.rs +++ b/src/tray/daemon.rs @@ -12,7 +12,7 @@ use crate::clipboard::Clipboard; use crate::daemon::client::DaemonClient; use crate::time::current_timestamp; use crate::types::request::Query; -use crate::types::text::Text; +use crate::types::text::{truncate_text, Text}; use crate::types::token::TokenResponse; pub struct TrayDaemon { @@ -123,7 +123,7 @@ impl TrayDaemon { let mut items = Vec::with_capacity(texts.len()); for text in texts { let id = text.id.to_string(); - let text = self.truncate_string(text.content.unwrap()); + let text = truncate_text(text.content.unwrap(), self.truncate_size); let text = text.replace("\n", "\\n"); items.push((id, text)); } @@ -154,19 +154,4 @@ impl TrayDaemon { self.token = Some(resp); Ok(()) } - - fn truncate_string(&self, mut s: String) -> String { - if s.chars().count() <= self.truncate_size { - return s; - } - - s.truncate( - s.char_indices() - .nth(self.truncate_size) - .map(|(i, _)| i) - .unwrap_or(s.len()), - ); - s.push_str("..."); - s - } } diff --git a/src/tray/ui.rs b/src/tray/ui.rs index b3516bc..1474a73 100644 --- a/src/tray/ui.rs +++ b/src/tray/ui.rs @@ -1,20 +1,16 @@ #![allow(deprecated)] -use std::sync::Arc; - use anyhow::{Context, Result}; use log::{error, info}; use tauri::menu::{Menu, MenuItem}; use tauri::AppHandle; -use tokio::sync::{mpsc, Mutex}; +use tokio::sync::mpsc; pub async fn build_and_run_tray_ui( default_menu: Vec<(String, String)>, menu_rx: mpsc::Receiver>, write_tx: mpsc::Sender, ) -> Result<()> { - let write_tx = Arc::new(Mutex::new(write_tx)); - info!("Starting system tray event loop"); tauri::Builder::default() .setup(|app| { @@ -42,7 +38,7 @@ pub async fn build_and_run_tray_ui( let write_tx = write_tx.clone(); tokio::spawn(async move { info!("Sending menu item click event: {id}"); - write_tx.lock().await.send(id).await.unwrap(); + write_tx.send(id).await.unwrap(); }); }) .run(tauri::generate_context!()) diff --git a/src/types/text.rs b/src/types/text.rs index afb2d97..5a6ea77 100644 --- a/src/types/text.rs +++ b/src/types/text.rs @@ -61,3 +61,19 @@ impl TerminalDisplay for Text { .collect() } } + +pub fn truncate_text(text: String, max_len: usize) -> String { + let mut text = text.replace("\n", "\\n"); + if text.chars().count() <= max_len { + return text; + } + + text.truncate( + text.char_indices() + .nth(max_len) + .map(|(i, _)| i) + .unwrap_or(text.len()), + ); + text.push_str("..."); + text +}