use std::fs;
use std::path::Path;
use content_inspector::inspect;
use crate::app::state::{Entry, EntryKind};
use crate::preview::provider::{
sanitize, PreviewContent, PreviewCtx, PreviewOutcome, PreviewProvider,
};
pub const TEXT_SYNC_THRESHOLD: usize = 256 * 1024;
pub const TEXT_PREVIEW_MAX_BYTES: usize = TEXT_SYNC_THRESHOLD;
#[allow(dead_code)]
const TEXT_PREVIEW_MAX_LINES: usize = 500;
pub struct TextProvider;
impl PreviewProvider for TextProvider {
fn can_handle(&self, entry: &Entry) -> bool {
if entry.kind != EntryKind::File {
return false;
}
entry.is_text != Some(false)
}
fn preview(&self, entry: &Entry, ctx: &PreviewCtx) -> PreviewOutcome {
crate::workers::highlight::spawn_highlight(
entry.path.clone(),
ctx.generation,
ctx.worker_tx.clone(),
ctx.max_preview_lines,
ctx.text_sync_threshold_bytes,
);
PreviewOutcome::Deferred
}
}
pub fn is_text_file(path: &Path) -> bool {
use std::io::Read;
let mut buf = [0u8; 8192];
let Ok(mut f) = fs::File::open(path) else {
return false;
};
let n = f.read(&mut buf).unwrap_or(0);
inspect(&buf[..n]).is_text()
}
pub fn decode_text(bytes: &[u8]) -> String {
if let Some(rest) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
return decode_utf8_or_ansi(rest);
}
if let Some(rest) = bytes.strip_prefix(&[0xFF, 0xFE]) {
return decode_utf16(rest, u16::from_le_bytes);
}
if let Some(rest) = bytes.strip_prefix(&[0xFE, 0xFF]) {
return decode_utf16(rest, u16::from_be_bytes);
}
decode_utf8_or_ansi(bytes)
}
fn decode_utf8_or_ansi(bytes: &[u8]) -> String {
match std::str::from_utf8(bytes) {
Ok(text) => text.to_owned(),
Err(err) => {
let valid = &bytes[..err.valid_up_to()];
if err.error_len().is_none() {
return String::from_utf8_lossy(valid).into_owned();
}
if valid.iter().any(|b| !b.is_ascii()) {
return String::from_utf8_lossy(bytes).into_owned();
}
bytes.iter().map(|&b| windows_1252(b)).collect()
}
}
}
fn decode_utf16(bytes: &[u8], unit: fn([u8; 2]) -> u16) -> String {
let units = bytes.chunks_exact(2).map(|pair| unit([pair[0], pair[1]]));
char::decode_utf16(units)
.map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER))
.collect()
}
fn windows_1252(byte: u8) -> char {
const HIGH: [char; 32] = [
'€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8D}', 'Ž',
'\u{8F}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—', '˜', '™', 'š', '›', 'œ', '\u{9D}',
'ž', 'Ÿ',
];
match byte {
0x80..=0x9F => HIGH[usize::from(byte - 0x80)],
_ => char::from(byte),
}
}
#[allow(dead_code)]
pub fn build_text_preview(path: &Path) -> PreviewContent {
use std::io::Read;
let Ok(f) = fs::File::open(path) else {
return PreviewContent::Empty;
};
let mut buf = Vec::with_capacity(TEXT_PREVIEW_MAX_BYTES.min(65536));
let mut limited = f.take(TEXT_PREVIEW_MAX_BYTES as u64);
if limited.read_to_end(&mut buf).is_err() {
return PreviewContent::Empty;
}
let text = String::from_utf8_lossy(&buf);
let lines: Vec<String> = text
.lines()
.take(TEXT_PREVIEW_MAX_LINES)
.enumerate()
.map(|(i, line)| format!("{:>4} {}", i + 1, sanitize(line)))
.collect();
PreviewContent::Text(lines)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::NamedTempFile;
#[test]
fn text_preview_numbers_lines() {
let mut f = NamedTempFile::new().unwrap();
use std::io::Write;
writeln!(f, "hello").unwrap();
writeln!(f, "world").unwrap();
let content = build_text_preview(f.path());
if let PreviewContent::Text(lines) = content {
assert_eq!(lines.len(), 2);
assert!(lines[0].contains("hello"));
assert!(lines[0].contains(" 1"));
assert!(lines[1].contains("world"));
} else {
panic!("expected Text variant");
}
}
#[test]
fn is_text_file_detects_text() {
let mut f = NamedTempFile::new().unwrap();
use std::io::Write;
write!(f, "plain text content").unwrap();
assert!(is_text_file(f.path()));
}
#[test]
fn is_text_file_rejects_binary() {
let mut f = NamedTempFile::new().unwrap();
use std::io::Write;
f.write_all(&[0u8; 64]).unwrap();
assert!(!is_text_file(f.path()));
}
#[test]
fn decode_keeps_utf8_as_it_is() {
assert_eq!(decode_text("café ✓".as_bytes()), "café ✓");
assert_eq!(decode_text(b"\xEF\xBB\xBFbom"), "bom");
}
#[test]
fn decode_reads_non_utf8_as_windows_1252() {
assert_eq!(decode_text(b"caf\xe9 \x80 \x93ol\xe9\x94"), "café € “olé”");
}
#[test]
fn decode_reads_utf16_with_a_byte_order_mark() {
let mut le = vec![0xFF, 0xFE];
let mut be = vec![0xFE, 0xFF];
for unit in "año\r\n".encode_utf16() {
le.extend_from_slice(&unit.to_le_bytes());
be.extend_from_slice(&unit.to_be_bytes());
}
assert_eq!(decode_text(&le), "año\r\n");
assert_eq!(decode_text(&be), "año\r\n");
le.push(b'x');
assert_eq!(decode_text(&le), "año\r\n");
}
#[test]
fn decode_keeps_damaged_utf8_as_utf8() {
assert_eq!(decode_text(b"\xC3\xA9 \xFF ok"), "é \u{FFFD} ok");
}
#[test]
fn decode_drops_a_character_cut_by_the_ceiling() {
assert_eq!(decode_text(b"ab\xC3"), "ab");
}
}