gardesk/garclip / 3b2ee0c

Browse files

clipboard: add file URI support for clipboard persistence

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
3b2ee0cea19347d866287777666b698806bed8b8
Parents
a76ef42
Tree
dd69bf6

8 changed files

StatusFile+-
M Cargo.lock 6 6
M garclip/src/clipboard/entry.rs 59 0
M garclip/src/clipboard/filter.rs 8 0
M garclip/src/clipboard/history.rs 4 0
M garclip/src/daemon/state.rs 13 0
M garclip/src/ipc/protocol.rs 8 0
M garclip/src/x11/atoms.rs 36 0
M garclip/src/x11/transfer.rs 121 2
Cargo.lockmodified
@@ -428,7 +428,7 @@ dependencies = [
428428
 
429429
 [[package]]
430430
 name = "garclip"
431
-version = "0.1.0"
431
+version = "0.2.0"
432432
 dependencies = [
433433
  "anyhow",
434434
  "base64",
@@ -450,7 +450,7 @@ dependencies = [
450450
 
451451
 [[package]]
452452
 name = "garclip-picker"
453
-version = "0.1.0"
453
+version = "0.2.0"
454454
 dependencies = [
455455
  "anyhow",
456456
  "garclip",
@@ -467,7 +467,7 @@ dependencies = [
467467
 
468468
 [[package]]
469469
 name = "garclipctl"
470
-version = "0.1.0"
470
+version = "0.2.0"
471471
 dependencies = [
472472
  "anyhow",
473473
  "clap",
@@ -479,7 +479,7 @@ dependencies = [
479479
 
480480
 [[package]]
481481
 name = "gartk-core"
482
-version = "0.1.0"
482
+version = "0.3.0"
483483
 dependencies = [
484484
  "serde",
485485
  "thiserror",
@@ -487,7 +487,7 @@ dependencies = [
487487
 
488488
 [[package]]
489489
 name = "gartk-render"
490
-version = "0.1.0"
490
+version = "0.3.0"
491491
 dependencies = [
492492
  "cairo-rs",
493493
  "gartk-core",
@@ -501,7 +501,7 @@ dependencies = [
501501
 
502502
 [[package]]
503503
 name = "gartk-x11"
504
-version = "0.1.0"
504
+version = "0.3.0"
505505
 dependencies = [
506506
  "gartk-core",
507507
  "thiserror",
garclip/src/clipboard/entry.rsmodified
@@ -15,6 +15,14 @@ pub enum ClipboardContent {
1515
         /// MIME type (e.g., "image/png")
1616
         mime_type: String,
1717
     },
18
+
19
+    /// File URIs (for file manager copy/paste)
20
+    Files {
21
+        /// File URIs (e.g., "file:///home/user/file.txt")
22
+        uris: Vec<String>,
23
+        /// Whether this is a cut operation (move vs copy)
24
+        is_cut: bool,
25
+    },
1826
 }
1927
 
2028
 impl ClipboardContent {
@@ -36,6 +44,21 @@ impl ClipboardContent {
3644
             ClipboardContent::Image { data, mime_type } => {
3745
                 format!("[Image: {}, {} bytes]", mime_type, data.len())
3846
             }
47
+            ClipboardContent::Files { uris, is_cut } => {
48
+                let action = if *is_cut { "Cut" } else { "Copy" };
49
+                let count = uris.len();
50
+                if count == 1 {
51
+                    // Show the filename for single file
52
+                    let path = uris[0].strip_prefix("file://").unwrap_or(&uris[0]);
53
+                    let name = std::path::Path::new(path)
54
+                        .file_name()
55
+                        .map(|n| n.to_string_lossy())
56
+                        .unwrap_or_else(|| path.into());
57
+                    format!("[{}: {}]", action, name)
58
+                } else {
59
+                    format!("[{}: {} files]", action, count)
60
+                }
61
+            }
3962
         }
4063
     }
4164
 
@@ -49,6 +72,11 @@ impl ClipboardContent {
4972
         matches!(self, ClipboardContent::Image { .. })
5073
     }
5174
 
75
+    /// Check if this content is file URIs
76
+    pub fn is_files(&self) -> bool {
77
+        matches!(self, ClipboardContent::Files { .. })
78
+    }
79
+
5280
     /// Get text content if this is text
5381
     pub fn as_text(&self) -> Option<&str> {
5482
         match self {
@@ -65,6 +93,14 @@ impl ClipboardContent {
6593
         }
6694
     }
6795
 
96
+    /// Get file URIs if this is files
97
+    pub fn as_files(&self) -> Option<(&[String], bool)> {
98
+        match self {
99
+            ClipboardContent::Files { uris, is_cut } => Some((uris, *is_cut)),
100
+            _ => None,
101
+        }
102
+    }
103
+
68104
     /// Get the content hash for deduplication
69105
     pub fn hash(&self) -> String {
70106
         match self {
@@ -76,6 +112,16 @@ impl ClipboardContent {
76112
                 let hash = blake3::hash(data);
77113
                 hash.to_hex().to_string()
78114
             }
115
+            ClipboardContent::Files { uris, is_cut } => {
116
+                // Hash URIs and cut flag together
117
+                let mut hasher = blake3::Hasher::new();
118
+                for uri in uris {
119
+                    hasher.update(uri.as_bytes());
120
+                    hasher.update(b"\n");
121
+                }
122
+                hasher.update(if *is_cut { b"cut" } else { b"copy" });
123
+                hasher.finalize().to_hex().to_string()
124
+            }
79125
         }
80126
     }
81127
 
@@ -84,6 +130,9 @@ impl ClipboardContent {
84130
         match self {
85131
             ClipboardContent::Text(text) => text.len(),
86132
             ClipboardContent::Image { data, .. } => data.len(),
133
+            ClipboardContent::Files { uris, .. } => {
134
+                uris.iter().map(|u| u.len()).sum()
135
+            }
87136
         }
88137
     }
89138
 }
@@ -102,6 +151,16 @@ impl PartialEq for ClipboardContent {
102151
                     mime_type: mb,
103152
                 },
104153
             ) => a == b && ma == mb,
154
+            (
155
+                ClipboardContent::Files {
156
+                    uris: a,
157
+                    is_cut: ca,
158
+                },
159
+                ClipboardContent::Files {
160
+                    uris: b,
161
+                    is_cut: cb,
162
+                },
163
+            ) => a == b && ca == cb,
105164
             _ => false,
106165
         }
107166
     }
garclip/src/clipboard/filter.rsmodified
@@ -69,6 +69,14 @@ impl ContentFilter {
6969
         match content {
7070
             ClipboardContent::Text(text) => self.should_filter_text(text),
7171
             ClipboardContent::Image { data, .. } => self.should_filter_image(data),
72
+            ClipboardContent::Files { uris, .. } => {
73
+                // Never filter file URIs - they're important
74
+                if uris.is_empty() {
75
+                    tracing::debug!("Filtering empty file list");
76
+                    return true;
77
+                }
78
+                false
79
+            }
7280
         }
7381
     }
7482
 
garclip/src/clipboard/history.rsmodified
@@ -191,6 +191,10 @@ impl ClipboardHistory {
191191
             .filter(|e| match &e.content {
192192
                 ClipboardContent::Text(text) => text.to_lowercase().contains(&query_lower),
193193
                 ClipboardContent::Image { .. } => false,
194
+                ClipboardContent::Files { uris, .. } => {
195
+                    // Search by file paths/names
196
+                    uris.iter().any(|uri| uri.to_lowercase().contains(&query_lower))
197
+                }
194198
             })
195199
             .take(limit)
196200
             .collect()
garclip/src/daemon/state.rsmodified
@@ -228,6 +228,8 @@ impl DaemonState {
228228
                     text: Some(text.clone()),
229229
                     image_data: None,
230230
                     mime_type: None,
231
+                    file_uris: None,
232
+                    is_cut: None,
231233
                 },
232234
                 ClipboardContent::Image { data, mime_type } => PasteResponse {
233235
                     id: entry.id,
@@ -235,6 +237,17 @@ impl DaemonState {
235237
                     text: None,
236238
                     image_data: Some(STANDARD.encode(data)),
237239
                     mime_type: Some(mime_type.clone()),
240
+                    file_uris: None,
241
+                    is_cut: None,
242
+                },
243
+                ClipboardContent::Files { uris, is_cut } => PasteResponse {
244
+                    id: entry.id,
245
+                    content_type: "files".to_string(),
246
+                    text: None,
247
+                    image_data: None,
248
+                    mime_type: None,
249
+                    file_uris: Some(uris.clone()),
250
+                    is_cut: Some(*is_cut),
238251
                 },
239252
             };
240253
             Response::ok_with_data(response)
garclip/src/ipc/protocol.rsmodified
@@ -239,4 +239,12 @@ pub struct PasteResponse {
239239
     /// Image MIME type (if image)
240240
     #[serde(skip_serializing_if = "Option::is_none")]
241241
     pub mime_type: Option<String>,
242
+
243
+    /// File URIs (if files)
244
+    #[serde(skip_serializing_if = "Option::is_none")]
245
+    pub file_uris: Option<Vec<String>>,
246
+
247
+    /// Whether this is a cut operation (if files)
248
+    #[serde(skip_serializing_if = "Option::is_none")]
249
+    pub is_cut: Option<bool>,
242250
 }
garclip/src/x11/atoms.rsmodified
@@ -32,6 +32,11 @@ pub struct Atoms {
3232
     pub image_bmp: Atom,
3333
     pub image_webp: Atom,
3434
 
35
+    // File clipboard targets
36
+    pub text_uri_list: Atom,
37
+    pub gnome_copied_files: Atom,
38
+    pub kde_cut_selection: Atom,
39
+
3540
     // Clipboard manager atoms
3641
     pub clipboard_manager: Atom,
3742
     pub save_targets: Atom,
@@ -65,6 +70,10 @@ impl Atoms {
6570
         let image_bmp = conn.intern_atom(false, b"image/bmp")?;
6671
         let image_webp = conn.intern_atom(false, b"image/webp")?;
6772
 
73
+        let text_uri_list = conn.intern_atom(false, b"text/uri-list")?;
74
+        let gnome_copied_files = conn.intern_atom(false, b"x-special/gnome-copied-files")?;
75
+        let kde_cut_selection = conn.intern_atom(false, b"application/x-kde-cutselection")?;
76
+
6877
         let clipboard_manager = conn.intern_atom(false, b"CLIPBOARD_MANAGER")?;
6978
         let save_targets = conn.intern_atom(false, b"SAVE_TARGETS")?;
7079
 
@@ -95,6 +104,10 @@ impl Atoms {
95104
             image_bmp: image_bmp.reply()?.atom,
96105
             image_webp: image_webp.reply()?.atom,
97106
 
107
+            text_uri_list: text_uri_list.reply()?.atom,
108
+            gnome_copied_files: gnome_copied_files.reply()?.atom,
109
+            kde_cut_selection: kde_cut_selection.reply()?.atom,
110
+
98111
             clipboard_manager: clipboard_manager.reply()?.atom,
99112
             save_targets: save_targets.reply()?.atom,
100113
 
@@ -127,6 +140,11 @@ impl Atoms {
127140
             || atom == self.image_webp
128141
     }
129142
 
143
+    /// Check if an atom is a file clipboard target
144
+    pub fn is_file_target(&self, atom: Atom) -> bool {
145
+        atom == self.text_uri_list || atom == self.gnome_copied_files
146
+    }
147
+
130148
     /// Get the preferred text target from a list of targets
131149
     pub fn preferred_text_target(&self, targets: &[Atom]) -> Option<Atom> {
132150
         // Prefer UTF8_STRING, then text/plain;charset=utf-8, then STRING
@@ -165,6 +183,19 @@ impl Atoms {
165183
         None
166184
     }
167185
 
186
+    /// Get the preferred file target from a list of targets
187
+    pub fn preferred_file_target(&self, targets: &[Atom]) -> Option<Atom> {
188
+        // Prefer gnome-copied-files (includes cut/copy info), then text/uri-list
189
+        let preference = [self.gnome_copied_files, self.text_uri_list];
190
+
191
+        for preferred in preference {
192
+            if targets.contains(&preferred) {
193
+                return Some(preferred);
194
+            }
195
+        }
196
+        None
197
+    }
198
+
168199
     /// Get the list of targets we support for text
169200
     pub fn supported_text_targets(&self) -> Vec<Atom> {
170201
         vec![
@@ -186,4 +217,9 @@ impl Atoms {
186217
             self.image_bmp,
187218
         ]
188219
     }
220
+
221
+    /// Get the list of targets we support for file URIs
222
+    pub fn supported_file_targets(&self) -> Vec<Atom> {
223
+        vec![self.text_uri_list, self.gnome_copied_files]
224
+    }
189225
 }
garclip/src/x11/transfer.rsmodified
@@ -166,7 +166,7 @@ impl<'a> TransferManager<'a> {
166166
         Ok(Some((data, mime_type)))
167167
     }
168168
 
169
-    /// Request clipboard content (text or image)
169
+    /// Request clipboard content (files, images, or text)
170170
     pub fn request_content(&self, selection: Atom) -> Result<Option<ClipboardContent>> {
171171
         let atoms = self.atoms();
172172
 
@@ -176,7 +176,14 @@ impl<'a> TransferManager<'a> {
176176
             return Ok(None);
177177
         }
178178
 
179
-        // Prefer images over text (images often have text alternatives)
179
+        // Prefer files first (most specialized content type)
180
+        if let Some(file_target) = atoms.preferred_file_target(&targets) {
181
+            if let Some((uris, is_cut)) = self.request_files_target(selection, file_target)? {
182
+                return Ok(Some(ClipboardContent::Files { uris, is_cut }));
183
+            }
184
+        }
185
+
186
+        // Then images (images often have text alternatives)
180187
         if let Some(img_target) = atoms.preferred_image_target(&targets) {
181188
             if let Some((data, mime)) = self.request_image_target(selection, img_target)? {
182189
                 return Ok(Some(ClipboardContent::Image { data, mime_type: mime }));
@@ -242,6 +249,56 @@ impl<'a> TransferManager<'a> {
242249
         Ok(Some((data, mime_type)))
243250
     }
244251
 
252
+    /// Request file URIs from clipboard
253
+    fn request_files_target(
254
+        &self,
255
+        selection: Atom,
256
+        target: Atom,
257
+    ) -> Result<Option<(Vec<String>, bool)>> {
258
+        let atoms = self.atoms();
259
+
260
+        self.conn().convert_selection(
261
+            self.window(),
262
+            selection,
263
+            target,
264
+            atoms.garclip_data,
265
+            CURRENT_TIME,
266
+        )?;
267
+        self.conn().flush()?;
268
+
269
+        let event = self.wait_for_selection_notify(selection, target)?;
270
+        if event.property == x11rb::NONE {
271
+            return Ok(None);
272
+        }
273
+
274
+        let data = self.read_property_string(atoms.garclip_data)?;
275
+
276
+        // Parse based on format
277
+        if target == atoms.gnome_copied_files {
278
+            // Format: "copy\nfile:///path1\nfile:///path2" or "cut\n..."
279
+            let mut lines = data.lines();
280
+            let action = lines.next().unwrap_or("copy");
281
+            let is_cut = action == "cut";
282
+            let uris: Vec<String> = lines.map(|s| s.to_string()).collect();
283
+            if uris.is_empty() {
284
+                return Ok(None);
285
+            }
286
+            Ok(Some((uris, is_cut)))
287
+        } else {
288
+            // text/uri-list format: "file:///path1\r\nfile:///path2\r\n"
289
+            let uris: Vec<String> = data
290
+                .lines()
291
+                .map(|s| s.trim_end_matches('\r').to_string())
292
+                .filter(|s| !s.is_empty() && !s.starts_with('#'))
293
+                .collect();
294
+            if uris.is_empty() {
295
+                return Ok(None);
296
+            }
297
+            // text/uri-list doesn't indicate cut, assume copy
298
+            Ok(Some((uris, false)))
299
+        }
300
+    }
301
+
245302
     /// Wait for a SelectionNotify event
246303
     fn wait_for_selection_notify(
247304
         &self,
@@ -358,6 +415,15 @@ impl<'a> TransferManager<'a> {
358415
                         targets.push(atoms.image_png);
359416
                     }
360417
                 }
418
+                ClipboardContent::Files { is_cut, .. } => {
419
+                    targets.extend(atoms.supported_file_targets());
420
+                    // Also offer text formats for compatibility
421
+                    targets.extend(atoms.supported_text_targets());
422
+                    // Add KDE cut indicator if this is a cut operation
423
+                    if *is_cut {
424
+                        targets.push(atoms.kde_cut_selection);
425
+                    }
426
+                }
361427
             }
362428
 
363429
             self.conn().change_property32(
@@ -376,6 +442,48 @@ impl<'a> TransferManager<'a> {
376442
                 Atom::from(x11rb::protocol::xproto::AtomEnum::INTEGER),
377443
                 &[CURRENT_TIME],
378444
             )?;
445
+        } else if atoms.is_file_target(target) {
446
+            // Respond with file URIs
447
+            match content {
448
+                ClipboardContent::Files { uris, is_cut } => {
449
+                    let data = if target == atoms.gnome_copied_files {
450
+                        // x-special/gnome-copied-files format
451
+                        let action = if *is_cut { "cut" } else { "copy" };
452
+                        std::iter::once(action.to_string())
453
+                            .chain(uris.iter().cloned())
454
+                            .collect::<Vec<_>>()
455
+                            .join("\n")
456
+                    } else {
457
+                        // text/uri-list format
458
+                        uris.iter()
459
+                            .map(|uri| format!("{}\r\n", uri))
460
+                            .collect::<String>()
461
+                    };
462
+                    self.conn().change_property8(
463
+                        PropMode::REPLACE,
464
+                        event.requestor,
465
+                        property,
466
+                        target,
467
+                        data.as_bytes(),
468
+                    )?;
469
+                }
470
+                _ => success = false,
471
+            }
472
+        } else if target == atoms.kde_cut_selection {
473
+            // KDE cut selection indicator
474
+            match content {
475
+                ClipboardContent::Files { is_cut, .. } => {
476
+                    let data = if *is_cut { "1" } else { "0" };
477
+                    self.conn().change_property8(
478
+                        PropMode::REPLACE,
479
+                        event.requestor,
480
+                        property,
481
+                        target,
482
+                        data.as_bytes(),
483
+                    )?;
484
+                }
485
+                _ => success = false,
486
+            }
379487
         } else if atoms.is_text_target(target) {
380488
             // Respond with text
381489
             match content {
@@ -388,6 +496,17 @@ impl<'a> TransferManager<'a> {
388496
                         text.as_bytes(),
389497
                     )?;
390498
                 }
499
+                ClipboardContent::Files { uris, .. } => {
500
+                    // For text targets, send URIs as newline-separated text
501
+                    let text = uris.join("\n");
502
+                    self.conn().change_property8(
503
+                        PropMode::REPLACE,
504
+                        event.requestor,
505
+                        property,
506
+                        target,
507
+                        text.as_bytes(),
508
+                    )?;
509
+                }
391510
                 _ => success = false,
392511
             }
393512
         } else if atoms.is_image_target(target) {