@@ -10,6 +10,9 @@ use gpui::{
1010
1111use crate :: models:: { ClipboardContentType , ClipboardItem } ;
1212
13+ /// Maximum detail preview text length.
14+ pub ( crate ) const DETAIL_PREVIEW_LENGTH : usize = 420 ;
15+
1316/// Catppuccin Mocha color palette.
1417mod colors {
1518 pub const BASE : u32 = 0x1E_1E2E ;
@@ -158,7 +161,7 @@ impl Render for ClipboardItemView {
158161 rgb ( colors:: BASE )
159162 } ;
160163
161- let preview = self . item . preview ( ) ;
164+ let preview = privacy_safe_preview ( & self . item . content_type , MAX_PREVIEW_LENGTH ) ;
162165 let time = self . relative_time ( ) ;
163166 let pinned = self . item . is_pinned ;
164167
@@ -301,13 +304,101 @@ fn render_metadata(pinned: bool, time: String) -> impl IntoElement {
301304 )
302305}
303306
304- /// Truncates text to a maximum length.
307+ /// Returns a display label for a clipboard content type.
308+ pub ( crate ) const fn content_type_label ( content_type : & ClipboardContentType ) -> & ' static str {
309+ match content_type {
310+ ClipboardContentType :: Text { .. } => "Text" ,
311+ ClipboardContentType :: RichText { .. } => "Rich text" ,
312+ ClipboardContentType :: Image { .. } => "Image" ,
313+ ClipboardContentType :: File { .. } => "File" ,
314+ ClipboardContentType :: Link { .. } => "Link" ,
315+ ClipboardContentType :: Color { .. } => "Color" ,
316+ }
317+ }
318+
319+ /// Returns a truthful preview without rendering image pixels or full filesystem paths.
320+ pub ( crate ) fn privacy_safe_preview ( content_type : & ClipboardContentType , max_len : usize ) -> String {
321+ match content_type {
322+ ClipboardContentType :: Text { content, .. } => truncate_text ( content, max_len) ,
323+ ClipboardContentType :: RichText { plain, .. } => truncate_text ( plain, max_len) ,
324+ ClipboardContentType :: Image {
325+ dimensions,
326+ size_bytes,
327+ ..
328+ } => format ! (
329+ "Image preview hidden • {}x{} • {}" ,
330+ dimensions. 0 ,
331+ dimensions. 1 ,
332+ format_bytes( * size_bytes)
333+ ) ,
334+ ClipboardContentType :: File {
335+ paths, total_size, ..
336+ } => format ! (
337+ "{} • {}" ,
338+ file_names_summary( paths, 3 ) ,
339+ format_bytes( * total_size)
340+ ) ,
341+ ClipboardContentType :: Link { url, title, .. } => {
342+ let label = title. as_deref ( ) . unwrap_or ( url) ;
343+ truncate_text ( label, max_len)
344+ } ,
345+ ClipboardContentType :: Color {
346+ hex, display_name, ..
347+ } => truncate_text ( display_name. as_deref ( ) . unwrap_or ( hex) , max_len) ,
348+ }
349+ }
350+
351+ /// Summarizes file names without exposing full directory paths.
352+ pub ( crate ) fn file_names_summary ( paths : & [ std:: path:: PathBuf ] , max_names : usize ) -> String {
353+ let names: Vec < _ > = paths
354+ . iter ( )
355+ . filter_map ( |path| path. file_name ( ) )
356+ . filter_map ( |name| name. to_str ( ) )
357+ . take ( max_names)
358+ . collect ( ) ;
359+
360+ let base = if names. is_empty ( ) {
361+ format ! (
362+ "{} file{}" ,
363+ paths. len( ) ,
364+ if paths. len( ) == 1 { "" } else { "s" }
365+ )
366+ } else {
367+ names. join ( ", " )
368+ } ;
369+
370+ if paths. len ( ) > max_names {
371+ format ! ( "{} +{} more" , base, paths. len( ) - max_names)
372+ } else {
373+ base
374+ }
375+ }
376+
377+ /// Formats byte counts for UI metadata.
378+ pub ( crate ) fn format_bytes ( size_bytes : u64 ) -> String {
379+ const KB : u64 = 1024 ;
380+ const MB : u64 = 1024 * 1024 ;
381+
382+ if size_bytes < KB {
383+ format ! ( "{} B" , size_bytes)
384+ } else if size_bytes < MB {
385+ let whole = size_bytes / KB ;
386+ let decimal = ( size_bytes % KB ) * 10 / KB ;
387+ format ! ( "{whole}.{decimal} KB" )
388+ } else {
389+ let whole = size_bytes / MB ;
390+ let decimal = ( size_bytes % MB ) * 10 / MB ;
391+ format ! ( "{whole}.{decimal} MB" )
392+ }
393+ }
394+
395+ /// Truncates text to a maximum character length.
305396fn truncate_text ( text : & str , max_len : usize ) -> String {
306- // Normalize whitespace
307397 let normalized: String = text. split_whitespace ( ) . collect :: < Vec < _ > > ( ) . join ( " " ) ;
308398
309- if normalized. len ( ) > max_len {
310- format ! ( "{}..." , & normalized[ ..max_len] )
399+ if normalized. chars ( ) . count ( ) > max_len {
400+ let truncated: String = normalized. chars ( ) . take ( max_len) . collect ( ) ;
401+ format ! ( "{}..." , truncated)
311402 } else {
312403 normalized
313404 }
@@ -388,6 +479,46 @@ mod tests {
388479 assert ! ( !no_match. has_highlights( ) ) ;
389480 }
390481
482+ #[ test]
483+ fn test_privacy_safe_preview_does_not_expose_image_path ( ) {
484+ let content = ClipboardContentType :: image (
485+ std:: path:: PathBuf :: from ( "/private/source/secret.png" ) ,
486+ std:: path:: PathBuf :: from ( "/private/thumb/secret.png" ) ,
487+ 2048 ,
488+ ( 640 , 480 ) ,
489+ ) ;
490+
491+ let preview = privacy_safe_preview ( & content, DETAIL_PREVIEW_LENGTH ) ;
492+
493+ assert_eq ! ( preview, "Image preview hidden • 640x480 • 2.0 KB" ) ;
494+ assert ! ( !preview. contains( "/private" ) ) ;
495+ }
496+
497+ #[ test]
498+ fn test_file_preview_uses_names_not_paths ( ) {
499+ let content = ClipboardContentType :: file (
500+ vec ! [
501+ std:: path:: PathBuf :: from( "/tmp/photoncast-task-8-safe-alpha.txt" ) ,
502+ std:: path:: PathBuf :: from( "/Users/example/private/beta.txt" ) ,
503+ ] ,
504+ Vec :: new ( ) ,
505+ 1536 ,
506+ ) ;
507+
508+ let preview = privacy_safe_preview ( & content, DETAIL_PREVIEW_LENGTH ) ;
509+
510+ assert_eq ! (
511+ preview,
512+ "photoncast-task-8-safe-alpha.txt, beta.txt • 1.5 KB"
513+ ) ;
514+ assert ! ( !preview. contains( "/Users/example/private" ) ) ;
515+ }
516+
517+ #[ test]
518+ fn test_text_preview_truncates_on_char_boundaries ( ) {
519+ assert_eq ! ( truncate_text( "αβγδε" , 3 ) , "αβγ..." ) ;
520+ }
521+
391522 #[ test]
392523 fn test_highlighted_text_empty_query ( ) {
393524 let ht = HighlightedText :: from_search ( "Hello World" , "" ) ;
0 commit comments