Skip to content

Commit 7d3f7a9

Browse files
committed
Improve quick view and favorites storage
1 parent 15a6e4b commit 7d3f7a9

12 files changed

Lines changed: 374 additions & 137 deletions

File tree

Cargo.lock

Lines changed: 0 additions & 36 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,5 @@ little_exif = "0.6.2"
1414
resvg = "0.44"
1515
textwrap = "0.16.2"
1616
trash = "5.2.0"
17-
tree_magic_mini = "3.1.6"
1817
jpgfromrawlib = { package = "jpgfromraw", git = "https://github.com/ioma8/jpgfromrawlib" }
1918
tokio = { version = "1", features = ["rt"] }

docs/CLEAN_ARCH.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ src/
127127
- `Ctrl+D` opens favorites list
128128
- `Ctrl+Shift+D` adds current directory to favorites
129129
- Navigate with arrow keys, select with `Enter`
130-
- Stored in `favorites.txt`
130+
- Stored in `~/.fishez/favorites.txt`
131131

132132
---
133133

src/application/state.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ pub struct PanelState {
4949
pub multi_selected: HashSet<usize>,
5050
pub dir_total: SizeFigure,
5151
pub dir_total_generation: u64,
52+
pub quick_view_generation: u64,
5253
pub selection_total: SizeFigure,
5354
pub selection_total_generation: u64,
5455
pub selection_cancel: Option<Arc<AtomicBool>>,
@@ -79,6 +80,7 @@ impl PanelState {
7980
multi_selected: HashSet::new(),
8081
dir_total: SizeFigure::Idle,
8182
dir_total_generation: 0,
83+
quick_view_generation: 0,
8284
selection_total: SizeFigure::Idle,
8385
selection_total_generation: 0,
8486
selection_cancel: None,

src/application/use_cases/quick_view.rs

Lines changed: 143 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,13 @@ use image::{DynamicImage, codecs::jpeg::JpegEncoder, imageops, load_from_memory}
66
use little_exif::exif_tag::ExifTag;
77
use little_exif::metadata::Metadata;
88
use std::fs;
9+
use std::io::Read;
910
use std::path::{Path, PathBuf};
1011

1112
const MIN_WRAP_WIDTH: u16 = 20;
1213
const 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

2017
pub 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

7186
fn 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

7999
fn 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

95138
fn 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-
219251
fn 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\tcargo 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

Comments
 (0)