Skip to main content

claude_utils/clipboard/
processor.rs

1use chrono::Local;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4use tokio::fs;
5use tokio::sync::mpsc;
6use tracing::{error, info, warn};
7
8use super::{
9    watcher::{platform::DualClipboard, ClipboardEvent},
10    ClipboardContent,
11};
12use crate::{file_manager::FileManager, Result};
13
14#[derive(Debug, Clone)]
15pub struct ProcessorConfig {
16    pub symlink_dir: PathBuf,
17    pub symlink_prefix: String,
18    pub keep_symlinks: usize,
19    pub enable_dual_format: bool,
20    pub enable_notifications: bool,
21}
22
23impl Default for ProcessorConfig {
24    fn default() -> Self {
25        let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
26
27        Self {
28            symlink_dir: home.join("Desktop"),
29            symlink_prefix: "claude-paste".to_string(),
30            keep_symlinks: 5,
31            enable_dual_format: true,
32            enable_notifications: true,
33        }
34    }
35}
36
37pub struct ClipboardProcessor {
38    config: ProcessorConfig,
39    file_manager: Arc<FileManager>,
40    clipboard_manager: Arc<super::ClipboardManager>,
41}
42
43impl ClipboardProcessor {
44    pub fn new(
45        config: ProcessorConfig,
46        file_manager: Arc<FileManager>,
47        clipboard_manager: Arc<super::ClipboardManager>,
48    ) -> Self {
49        Self {
50            config,
51            file_manager,
52            clipboard_manager,
53        }
54    }
55
56    pub async fn start_processing(self, mut event_rx: mpsc::Receiver<ClipboardEvent>) {
57        info!("Clipboard processor started");
58
59        while let Some(mut event) = event_rx.recv().await {
60            if let Err(e) = self.process_event(&mut event).await {
61                error!("Failed to process clipboard event: {}", e);
62            }
63        }
64    }
65
66    async fn process_event(&self, event: &mut ClipboardEvent) -> Result<()> {
67        match &event.content.content {
68            ClipboardContent::ImagePng { .. } | ClipboardContent::ImageJpeg { .. } => {
69                self.process_image_event(event).await?;
70            }
71            ClipboardContent::Text { data, .. } if data.len() > crate::MAX_INLINE_SIZE => {
72                self.process_large_text_event(event).await?;
73            }
74            _ => {
75                // Small text passes through unchanged
76                debug!("Small text content, no processing needed");
77            }
78        }
79
80        Ok(())
81    }
82
83    async fn process_image_event(&self, event: &mut ClipboardEvent) -> Result<()> {
84        info!("Processing image clipboard event");
85
86        // Get raw image data
87        let image_data = self.clipboard_manager.get_raw_image()?;
88
89        // Stage the image
90        let format = match &event.content.content {
91            ClipboardContent::ImagePng { .. } => "png",
92            ClipboardContent::ImageJpeg { .. } => "jpeg",
93            _ => unreachable!(),
94        };
95
96        let staged = self.file_manager.stage_image(&image_data, format).await?;
97        event.staged_path = Some(staged.path.clone());
98
99        // Create timestamped symlink
100        let symlink_path = self.create_symlink(&staged.path, format).await?;
101        event.symlink_path = Some(symlink_path.clone());
102
103        // Set dual clipboard if enabled
104        if self.config.enable_dual_format {
105            let path_str = symlink_path.to_string_lossy();
106
107            #[cfg(target_os = "macos")]
108            {
109                if let Err(e) = DualClipboard::set_dual_content(&path_str, &image_data) {
110                    warn!("Failed to set dual clipboard format: {}", e);
111                    // Fallback to text-only
112                    self.set_text_clipboard(&path_str)?;
113                } else {
114                    info!("Set dual clipboard: text path + original image");
115                }
116            }
117
118            #[cfg(not(target_os = "macos"))]
119            {
120                // On other platforms, just set text
121                self.set_text_clipboard(&path_str)?;
122            }
123        }
124
125        // Clean up old symlinks
126        self.cleanup_old_symlinks().await?;
127
128        // Show notification if enabled
129        if self.config.enable_notifications {
130            let notification_path = symlink_path.to_string_lossy();
131            self.show_notification("Image ready for Claude Code", &notification_path);
132        }
133
134        info!("Image processed: {}", symlink_path.display());
135        Ok(())
136    }
137
138    async fn process_large_text_event(&self, event: &mut ClipboardEvent) -> Result<()> {
139        info!("Processing large text clipboard event");
140
141        if let ClipboardContent::Text { data, .. } = &event.content.content {
142            // Stage the text
143            let staged = self.file_manager.stage_text(data).await?;
144            event.staged_path = Some(staged.path.clone());
145
146            // Create symlink
147            let symlink_path = self.create_symlink(&staged.path, "txt").await?;
148            event.symlink_path = Some(symlink_path.clone());
149
150            // Update clipboard with path
151            let path_str = symlink_path.to_string_lossy();
152            self.set_text_clipboard(&path_str)?;
153
154            // Clean up old symlinks
155            self.cleanup_old_symlinks().await?;
156
157            info!("Large text processed: {}", symlink_path.display());
158        }
159
160        Ok(())
161    }
162
163    async fn create_symlink(&self, target: &Path, extension: &str) -> Result<PathBuf> {
164        // Generate timestamped filename
165        let timestamp = Local::now().format("%Y%m%d-%H%M%S");
166        let filename = format!("{}-{}.{}", self.config.symlink_prefix, timestamp, extension);
167        let symlink_path = self.config.symlink_dir.join(&filename);
168
169        // Create symlink
170        #[cfg(unix)]
171        {
172            use std::os::unix::fs::symlink;
173            symlink(target, &symlink_path)?;
174        }
175
176        #[cfg(windows)]
177        {
178            use std::os::windows::fs::symlink_file;
179            symlink_file(target, &symlink_path)?;
180        }
181
182        // Also create a "latest" symlink for convenience
183        let latest_name = format!("{}.{}", self.config.symlink_prefix, extension);
184        let latest_path = self.config.symlink_dir.join(&latest_name);
185
186        // Remove old latest symlink if exists
187        let _ = fs::remove_file(&latest_path).await;
188
189        #[cfg(unix)]
190        {
191            use std::os::unix::fs::symlink;
192            symlink(target, &latest_path)?;
193        }
194
195        #[cfg(windows)]
196        {
197            use std::os::windows::fs::symlink_file;
198            symlink_file(target, &latest_path)?;
199        }
200
201        Ok(symlink_path)
202    }
203
204    async fn cleanup_old_symlinks(&self) -> Result<()> {
205        let pattern = format!("{}-*", self.config.symlink_prefix);
206        let mut entries = fs::read_dir(&self.config.symlink_dir).await?;
207        let mut symlinks = Vec::new();
208
209        while let Some(entry) = entries.next_entry().await? {
210            let path = entry.path();
211            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
212                if name.starts_with(&pattern) && name.contains('-') {
213                    if let Ok(metadata) = entry.metadata().await {
214                        if metadata.file_type().is_symlink() {
215                            if let Ok(modified) = metadata.modified() {
216                                symlinks.push((path, modified));
217                            }
218                        }
219                    }
220                }
221            }
222        }
223
224        // Sort by modification time (newest first)
225        symlinks.sort_by(|a, b| b.1.cmp(&a.1));
226
227        // Remove old symlinks beyond keep limit
228        for (path, _) in symlinks.into_iter().skip(self.config.keep_symlinks) {
229            if let Err(e) = fs::remove_file(&path).await {
230                warn!("Failed to remove old symlink: {}", e);
231            } else {
232                debug!("Removed old symlink: {}", path.display());
233            }
234        }
235
236        Ok(())
237    }
238
239    fn set_text_clipboard(&self, text: &str) -> Result<()> {
240        self.clipboard_manager.set_content(&ClipboardContent::Text {
241            data: text.to_string(),
242            truncated: None,
243        })
244    }
245
246    fn show_notification(&self, title: &str, body: &str) {
247        #[cfg(target_os = "macos")]
248        {
249            use std::process::Command;
250            let script = format!(
251                r#"display notification "{body}" with title "Claude-Utils" subtitle "{title}""#
252            );
253            let _ = Command::new("osascript").arg("-e").arg(&script).output();
254        }
255
256        #[cfg(target_os = "linux")]
257        {
258            use std::process::Command;
259            let _ = Command::new("notify-send")
260                .arg("Claude-Utils")
261                .arg(&format!("{}\n{}", title, body))
262                .output();
263        }
264
265        #[cfg(target_os = "windows")]
266        {
267            // Windows notifications require more setup, skip for now
268            info!("Notification: {} - {}", title, body);
269        }
270    }
271}
272
273use tracing::debug;