@@ -6,16 +6,13 @@ use image::{DynamicImage, codecs::jpeg::JpegEncoder, imageops, load_from_memory}
66use little_exif:: exif_tag:: ExifTag ;
77use little_exif:: metadata:: Metadata ;
88use std:: fs;
9+ use std:: io:: Read ;
910use std:: path:: { Path , PathBuf } ;
1011
1112const MIN_WRAP_WIDTH : u16 = 20 ;
1213const DIR_PREVIEW_LIMIT : usize = 20 ;
13-
14- pub fn open ( panel : & mut PanelState , wrap_width : u16 ) {
15- if let Some ( path) = panel. get_selected_path ( ) {
16- panel. mode = PanelMode :: QuickView ( preview ( path, wrap_width) ) ;
17- }
18- }
14+ const TEXT_PREVIEW_MAX_BYTES : u64 = 1024 * 1024 ;
15+ const TEXT_PREVIEW_MAX_LINES : usize = 5_000 ;
1916
2017pub fn scroll ( panel : & mut PanelState , direction : isize , rows : u16 , header : u16 , footer : u16 ) {
2118 if let PanelMode :: QuickView ( QuickViewMode :: Text { lines, start } ) = & mut panel. mode {
@@ -53,43 +50,89 @@ fn get_type(path: &Path) -> FileType {
5350 . map ( |ext| ext. to_ascii_lowercase ( ) ) ;
5451 let extension_type = classify_extension ( extension. as_deref ( ) ) ;
5552
56- if matches ! ( extension_type, FileType :: Image ) {
57- return FileType :: Image ;
53+ if matches ! ( extension_type, FileType :: Image | FileType :: Text ) {
54+ return extension_type ;
5855 }
5956
60- if let Some ( mime) = tree_magic_mini:: from_filepath ( path) {
61- match mime. split ( '/' ) . next ( ) {
62- Some ( "text" ) => FileType :: Text ,
63- Some ( "image" ) => FileType :: Image ,
64- _ => extension_type,
65- }
57+ if sniff_is_text ( path) {
58+ FileType :: Text
6659 } else {
67- extension_type
60+ FileType :: Other
61+ }
62+ }
63+
64+ pub fn looks_like_image ( path : & Path ) -> bool {
65+ if raw_image:: supports_path ( path) {
66+ return true ;
6867 }
68+ let extension = path
69+ . extension ( )
70+ . and_then ( std:: ffi:: OsStr :: to_str)
71+ . map ( |ext| ext. to_ascii_lowercase ( ) ) ;
72+ matches ! ( classify_extension( extension. as_deref( ) ) , FileType :: Image )
73+ }
74+
75+ fn sniff_is_text ( path : & Path ) -> bool {
76+ let mut buf = [ 0u8 ; 8192 ] ;
77+ let Ok ( mut file) = fs:: File :: open ( path) else {
78+ return false ;
79+ } ;
80+ let Ok ( n) = file. read ( & mut buf) else {
81+ return false ;
82+ } ;
83+ n > 0 && !buf[ ..n] . contains ( & 0 )
6984}
7085
7186fn classify_extension ( extension : Option < & str > ) -> FileType {
7287 match extension {
73- Some ( "txt" ) | Some ( "md" ) | Some ( "rs" ) | Some ( "toml" ) => FileType :: Text ,
88+ Some (
89+ "txt" | "md" | "markdown" | "rs" | "toml" | "json" | "yaml" | "yml" | "js" | "ts"
90+ | "jsx" | "tsx" | "py" | "rb" | "go" | "c" | "h" | "cpp" | "hpp" | "java" | "kt"
91+ | "swift" | "sh" | "bash" | "zsh" | "fish" | "css" | "scss" | "html" | "xml" | "svg"
92+ | "sql" | "lock" | "cfg" | "conf" | "ini" | "env" | "gitignore" | "log" | "csv" | "tsv" ,
93+ ) => FileType :: Text ,
7494 Some ( "png" ) | Some ( "jpg" ) | Some ( "jpeg" ) | Some ( "gif" ) => FileType :: Image ,
7595 _ => FileType :: Other ,
7696 }
7797}
7898
7999fn preview_text ( path : & Path , wrap_width : u16 ) -> QuickViewMode {
80- if let Ok ( content) = fs:: read_to_string ( path) {
81- let width = wrap_width. saturating_sub ( 4 ) . max ( MIN_WRAP_WIDTH ) as usize ;
82- let lines: Vec < String > = textwrap:: wrap ( & content, width)
83- . into_iter ( )
84- . map ( |l| l. to_string ( ) )
85- . collect ( ) ;
86- QuickViewMode :: Text {
87- lines : highlight ( lines) ,
88- start : 0 ,
89- }
90- } else {
91- QuickViewMode :: NotSupported
100+ let Ok ( metadata) = fs:: metadata ( path) else {
101+ return QuickViewMode :: NotSupported ;
102+ } ;
103+ let Ok ( file) = fs:: File :: open ( path) else {
104+ return QuickViewMode :: NotSupported ;
105+ } ;
106+
107+ let mut buf = Vec :: new ( ) ;
108+ if file
109+ . take ( TEXT_PREVIEW_MAX_BYTES )
110+ . read_to_end ( & mut buf)
111+ . is_err ( )
112+ {
113+ return QuickViewMode :: NotSupported ;
92114 }
115+
116+ let mut capped = metadata. len ( ) > TEXT_PREVIEW_MAX_BYTES ;
117+ let mut content = String :: from_utf8_lossy ( & buf) . into_owned ( ) ;
118+ if capped && let Some ( last_newline) = content. rfind ( '\n' ) {
119+ content. truncate ( last_newline) ;
120+ }
121+
122+ let width = wrap_width. saturating_sub ( 4 ) . max ( MIN_WRAP_WIDTH ) as usize ;
123+ let mut lines: Vec < String > = textwrap:: wrap ( & content, width)
124+ . into_iter ( )
125+ . map ( |l| l. to_string ( ) )
126+ . collect ( ) ;
127+ if lines. len ( ) > TEXT_PREVIEW_MAX_LINES {
128+ lines. truncate ( TEXT_PREVIEW_MAX_LINES ) ;
129+ capped = true ;
130+ }
131+ let mut lines = highlight ( lines) ;
132+ if capped {
133+ lines. push ( "… preview truncated" . to_string ( ) ) ;
134+ }
135+ QuickViewMode :: Text { lines, start : 0 }
93136}
94137
95138fn highlight ( lines : Vec < String > ) -> Vec < String > {
@@ -183,16 +226,18 @@ fn preview_image(path: &Path, wrap_width: u16) -> QuickViewMode {
183226 let Ok ( buf) = fs:: read ( path) else {
184227 return QuickViewMode :: NotSupported ;
185228 } ;
186- let thumb = extract_thumbnail ( path) ;
187- let pixels = thumb
188- . map ( |t| t. to_rgb8 ( ) )
189- . or_else ( || load_from_memory ( & buf) . ok ( ) . map ( |i| i. to_rgb8 ( ) ) ) ;
190- if pixels. is_some ( ) {
191- let target = terminal_pixel_limit ( wrap_width) ;
192- let image_bytes = downscale_image_if_needed ( & buf, target) . unwrap_or ( buf) ;
193- QuickViewMode :: Image ( image_bytes)
194- } else {
195- QuickViewMode :: NotSupported
229+ let Some ( img) = extract_thumbnail ( path) . or_else ( || load_from_memory ( & buf) . ok ( ) ) else {
230+ return QuickViewMode :: NotSupported ;
231+ } ;
232+ let target = terminal_pixel_limit ( wrap_width) ;
233+ if img. width ( ) <= target && img. height ( ) <= target {
234+ return QuickViewMode :: Image ( buf) ;
235+ }
236+ let resized = imageops:: thumbnail ( & img, target, target) ;
237+ let mut output = Vec :: new ( ) ;
238+ match JpegEncoder :: new_with_quality ( & mut output, 80 ) . encode_image ( & resized) {
239+ Ok ( _) => QuickViewMode :: Image ( output) ,
240+ Err ( _) => QuickViewMode :: Image ( buf) ,
196241 }
197242}
198243
@@ -203,19 +248,6 @@ fn terminal_pixel_limit(wrap_width: u16) -> u32 {
203248 side. min ( MAX_SIDE )
204249}
205250
206- fn downscale_image_if_needed ( bytes : & [ u8 ] , max_side : u32 ) -> Option < Vec < u8 > > {
207- let img = image:: load_from_memory ( bytes) . ok ( ) ?;
208- if img. width ( ) <= max_side && img. height ( ) <= max_side {
209- return None ;
210- }
211- let resized = imageops:: thumbnail ( & img, max_side, max_side) ;
212- let mut output = Vec :: new ( ) ;
213- let _ = JpegEncoder :: new_with_quality ( & mut output, 80 )
214- . encode_image ( & resized)
215- . ok ( ) ?;
216- Some ( output)
217- }
218-
219251fn extract_thumbnail ( path : & Path ) -> Option < DynamicImage > {
220252 let meta = Metadata :: new_from_path ( path) . ok ( ) ?;
221253 let tag = meta
@@ -336,6 +368,66 @@ mod tests {
336368 assert ! ( matches!( get_type( & file_path) , FileType :: Other ) ) ;
337369 }
338370
371+ #[ test]
372+ fn test_get_type_extensionless_ascii_is_text ( ) {
373+ let base = create_temp_dir ( "quick_view_type_extensionless" ) ;
374+ let file_path = base. join ( "Makefile" ) ;
375+ fs:: write ( & file_path, "all:\n \t cargo test\n " ) . unwrap ( ) ;
376+
377+ assert ! ( matches!( get_type( & file_path) , FileType :: Text ) ) ;
378+ }
379+
380+ #[ test]
381+ fn test_get_type_nul_prefixed_file_is_other ( ) {
382+ let base = create_temp_dir ( "quick_view_type_nul" ) ;
383+ let file_path = base. join ( "blob" ) ;
384+ fs:: write ( & file_path, [ 0u8 , 0u8 , b'a' ] ) . unwrap ( ) ;
385+
386+ assert ! ( matches!( get_type( & file_path) , FileType :: Other ) ) ;
387+ }
388+
389+ #[ test]
390+ fn test_preview_text_caps_large_files ( ) {
391+ let base = create_temp_dir ( "quick_view_large_text" ) ;
392+ let file_path = base. join ( "large.txt" ) ;
393+ fs:: write ( & file_path, "word " . repeat ( 420_000 ) ) . unwrap ( ) ;
394+
395+ let mode = preview_text ( & file_path, 80 ) ;
396+
397+ if let QuickViewMode :: Text { lines, .. } = mode {
398+ assert ! ( lines. len( ) <= TEXT_PREVIEW_MAX_LINES + 1 ) ;
399+ assert_eq ! (
400+ lines. last( ) . map( String :: as_str) ,
401+ Some ( "… preview truncated" )
402+ ) ;
403+ } else {
404+ panic ! ( "Expected QuickView Text mode" ) ;
405+ }
406+ }
407+
408+ #[ test]
409+ fn test_preview_text_keeps_capped_single_line_content ( ) {
410+ let base = create_temp_dir ( "quick_view_large_single_line" ) ;
411+ let file_path = base. join ( "large.json" ) ;
412+ fs:: write (
413+ & file_path,
414+ "x" . repeat ( ( TEXT_PREVIEW_MAX_BYTES + 10 ) as usize ) ,
415+ )
416+ . unwrap ( ) ;
417+
418+ let mode = preview_text ( & file_path, 80 ) ;
419+
420+ if let QuickViewMode :: Text { lines, .. } = mode {
421+ assert ! ( lines. iter( ) . any( |line| line. contains( 'x' ) ) ) ;
422+ assert_eq ! (
423+ lines. last( ) . map( String :: as_str) ,
424+ Some ( "… preview truncated" )
425+ ) ;
426+ } else {
427+ panic ! ( "Expected QuickView Text mode" ) ;
428+ }
429+ }
430+
339431 #[ test]
340432 fn preview_image_downscales_large_images ( ) {
341433 let base = create_temp_dir ( "quick_view_image_downscale" ) ;
0 commit comments