feat: add text preview for thumbnails and gallery
Preview text files (≤8 MiB) in grid and gallery: read up to 256 KiB, handle invalid UTF-8, skip empty files. Add unit tests. Lets users peek at .txt without opening; size caps avoid blocking the UI.
This commit is contained in:
parent
accb9fd418
commit
03e537abad
1 changed files with 121 additions and 11 deletions
132
src/tab.rs
132
src/tab.rs
|
|
@ -36,7 +36,7 @@ use std::error::Error;
|
|||
use std::fmt::{self, Display};
|
||||
use std::fs::{self, File, Metadata};
|
||||
use std::hash::Hash;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::path::{self, Path, PathBuf};
|
||||
|
|
@ -75,6 +75,11 @@ const MAX_SEARCH_LATENCY: Duration = Duration::from_millis(20);
|
|||
const MAX_SEARCH_RESULTS: usize = 200;
|
||||
//TODO: configurable thumbnail size?
|
||||
const THUMBNAIL_SIZE: u32 = (ICON_SIZE_GRID as u32) * (ICON_SCALE_MAX as u32);
|
||||
/// Maximum bytes of text to pass to the editor for preview; caps shaping work to avoid blocking.
|
||||
/// Files larger than this get a truncated preview (first N bytes only).
|
||||
const TEXT_PREVIEW_MAX_BYTES: usize = 256 * 1024; // 256 KiB
|
||||
/// Maximum file size (bytes) to attempt text preview; files larger than this are skipped entirely.
|
||||
const TEXT_PREVIEW_MAX_FILE_BYTES: u64 = 8 * 1000 * 1000; // 8 MiB
|
||||
|
||||
// Thumbnail generation semaphore - limits parallel thumbnail workers
|
||||
// Uses 4 workers for balanced throughput and memory usage
|
||||
|
|
@ -2070,17 +2075,37 @@ impl ItemThumbnail {
|
|||
log::warn!("failed to read {}: {}", path.display(), err);
|
||||
}
|
||||
}
|
||||
} else if mime.type_() == mime::TEXT && check_size("text", 8 * 1000 * 1000) {
|
||||
/*TODO: fix performance issues, widget::text_editor::Content::with_text forces all text to shape, which blocks rendering
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(data) => {
|
||||
return ItemThumbnail::Text(widget::text_editor::Content::with_text(&data));
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("failed to read {}: {}", path.display(), err);
|
||||
} else if mime.type_() == mime::TEXT && check_size("text", TEXT_PREVIEW_MAX_FILE_BYTES) {
|
||||
tried_supported_file = true;
|
||||
if size > 0 {
|
||||
// Reuse size from metadata above; cap allocation and read
|
||||
let read_cap = (size.min(TEXT_PREVIEW_MAX_BYTES as u64)) as usize;
|
||||
let mut buf = vec![0u8; read_cap];
|
||||
match File::open(path).and_then(|f| {
|
||||
let n = Read::read(&mut f.take(read_cap as u64), &mut buf)?;
|
||||
buf.truncate(n);
|
||||
Ok(())
|
||||
}) {
|
||||
Ok(()) => {
|
||||
let text = match std::str::from_utf8(&buf) {
|
||||
Ok(s) => s.to_string(),
|
||||
Err(e) => {
|
||||
// Use only the valid UTF-8 prefix (slice is guaranteed valid by valid_up_to())
|
||||
std::str::from_utf8(&buf[..e.valid_up_to()])
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
if !text.is_empty() {
|
||||
return Self::Text(widget::text_editor::Content::with_text(&text));
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("failed to read {}: {}", path.display(), err);
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
// size == 0: empty file or unknown size; skip read and allocation
|
||||
}
|
||||
|
||||
// If we weren't able to create a thumbnail, but we should have
|
||||
|
|
@ -7053,10 +7078,13 @@ mod tests {
|
|||
use cosmic::iced::runtime::keyboard::Modifiers;
|
||||
use cosmic::widget;
|
||||
use log::{debug, trace};
|
||||
use mime_guess::mime;
|
||||
use tempfile::TempDir;
|
||||
use test_log::test;
|
||||
|
||||
use super::{Location, Message, Tab, respond_to_scroll_direction, scan_path};
|
||||
use super::{
|
||||
ItemMetadata, ItemThumbnail, Location, Message, Tab, respond_to_scroll_direction, scan_path,
|
||||
};
|
||||
use crate::app::test_utils::{
|
||||
NAME_LEN, NUM_DIRS, NUM_FILES, NUM_HIDDEN, NUM_NESTED, assert_eq_tab_path, empty_fs,
|
||||
eq_path_item, filter_dirs, read_dir_sorted, simple_fs, tab_click_new,
|
||||
|
|
@ -7474,4 +7502,86 @@ mod tests {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_thumbnail_text_preview_small_utf8_returns_text() -> io::Result<()> {
|
||||
let dir = TempDir::new()?;
|
||||
let path = dir.path().join("preview.txt");
|
||||
fs::write(&path, "Hello, world!")?;
|
||||
let metadata = fs::metadata(&path)?;
|
||||
let item_metadata = ItemMetadata::Path {
|
||||
metadata,
|
||||
children_opt: None,
|
||||
};
|
||||
let thumb = ItemThumbnail::new(
|
||||
&path,
|
||||
item_metadata,
|
||||
mime::TEXT_PLAIN,
|
||||
128,
|
||||
100 * 1024 * 1024,
|
||||
1,
|
||||
8,
|
||||
);
|
||||
assert!(
|
||||
matches!(thumb, ItemThumbnail::Text(_)),
|
||||
"small text file should produce Text thumbnail"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_thumbnail_text_preview_empty_file_returns_not_image() -> io::Result<()> {
|
||||
let dir = TempDir::new()?;
|
||||
let path = dir.path().join("empty.txt");
|
||||
fs::File::create(&path)?;
|
||||
let metadata = fs::metadata(&path)?;
|
||||
let item_metadata = ItemMetadata::Path {
|
||||
metadata,
|
||||
children_opt: None,
|
||||
};
|
||||
let thumb = ItemThumbnail::new(
|
||||
&path,
|
||||
item_metadata,
|
||||
mime::TEXT_PLAIN,
|
||||
128,
|
||||
100 * 1024 * 1024,
|
||||
1,
|
||||
8,
|
||||
);
|
||||
assert!(
|
||||
matches!(thumb, ItemThumbnail::NotImage),
|
||||
"empty text file should produce NotImage (no read)"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_thumbnail_text_preview_invalid_utf8_uses_valid_prefix() -> io::Result<()> {
|
||||
let dir = TempDir::new()?;
|
||||
let path = dir.path().join("invalid_utf8.txt");
|
||||
// Valid UTF-8 "ab" then invalid byte sequence then "c"
|
||||
fs::write(&path, b"ab\xff\xfe\xfdc")?;
|
||||
let metadata = fs::metadata(&path)?;
|
||||
let item_metadata = ItemMetadata::Path {
|
||||
metadata,
|
||||
children_opt: None,
|
||||
};
|
||||
let thumb = ItemThumbnail::new(
|
||||
&path,
|
||||
item_metadata,
|
||||
mime::TEXT_PLAIN,
|
||||
128,
|
||||
100 * 1024 * 1024,
|
||||
1,
|
||||
8,
|
||||
);
|
||||
match &thumb {
|
||||
ItemThumbnail::Text(content) => {
|
||||
// Text editor content may add a trailing newline
|
||||
assert_eq!(content.text().trim_end(), "ab");
|
||||
}
|
||||
_ => panic!("expected Text thumbnail with valid prefix only, got {:?}", thumb),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue