From ab6939c0e1eb227ff9e8573bc29c33e7243d4cea Mon Sep 17 00:00:00 2001 From: PluginMike <65072741+PluginMike@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:04:56 +0200 Subject: [PATCH 1/2] Open HEIC and camera RAW/DNG photos --- README.md | 10 +++++----- src-tauri/src/preview.rs | 22 +++++++++++++++++++--- src/preview.ts | 11 ++++++++--- src/util.ts | 2 +- 4 files changed, 33 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index df0fb7d..017c850 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ bind = SUPER, E, exec, whale-cabinet Needs Rust (via [rustup](https://rustup.rs)), Node ≥ 20, WebKitGTK 4.1 and the usual build tools. Required at runtime (install.sh checks): zoxide (jump box, Frequent), fd (quick open), git (badges), gvfs with its smb/dav/nfs backends (network locations) and polkit (administrator actions). -The optional tools light up extra features: poppler (PDF previews), ffmpegthumbnailer + ffmpeg (video thumbnails, +The optional tools light up extra features: poppler (PDF previews), libvips (HEIC and camera RAW/DNG photos), ffmpegthumbnailer + ffmpeg (video thumbnails, media details), libarchive/zip/7-Zip (compress/extract), wl-clipboard (copy/paste with other apps), udisks2 (devices), libsecret's secret-tool (remembering network passwords). @@ -79,7 +79,7 @@ libsecret's secret-tool (remembering network passwords). ```sh sudo pacman -S --needed base-devel webkit2gtk-4.1 curl wget file openssl librsvg libappindicator-gtk3 nodejs npm rustup sudo pacman -S --needed zoxide fd git gvfs gvfs-smb gvfs-dnssd gvfs-nfs polkit -sudo pacman -S --needed poppler ffmpegthumbnailer ffmpeg libarchive zip 7zip wl-clipboard udisks2 # optional +sudo pacman -S --needed poppler libvips libheif libraw ffmpegthumbnailer ffmpeg libarchive zip 7zip wl-clipboard udisks2 # optional ``` **Fedora** @@ -87,14 +87,14 @@ sudo pacman -S --needed poppler ffmpegthumbnailer ffmpeg libarchive zip 7zip wl- sudo dnf install webkit2gtk4.1-devel openssl-devel curl wget file libappindicator-gtk3-devel librsvg2-devel libxdo-devel nodejs npm sudo dnf group install c-development sudo dnf install zoxide fd-find git gvfs gvfs-smb gvfs-fuse polkit -sudo dnf install poppler-utils ffmpegthumbnailer ffmpeg-free bsdtar zip p7zip wl-clipboard udisks2 # optional +sudo dnf install poppler-utils vips-tools vips-heif ffmpegthumbnailer ffmpeg-free bsdtar zip p7zip wl-clipboard udisks2 # optional ``` **Debian / Ubuntu** ```sh sudo apt install build-essential libwebkit2gtk-4.1-dev libssl-dev libxdo-dev libayatana-appindicator3-dev librsvg2-dev curl wget file nodejs npm sudo apt install zoxide fd-find git gvfs gvfs-backends gvfs-fuse pkexec -sudo apt install poppler-utils ffmpegthumbnailer ffmpeg libarchive-tools zip 7zip wl-clipboard udisks2 # optional +sudo apt install poppler-utils libvips-tools ffmpegthumbnailer ffmpeg libarchive-tools zip 7zip wl-clipboard udisks2 # optional ``` Then: @@ -287,7 +287,7 @@ selects the extra copies keeping the newest, the oldest or the one in a folder y - Image, video and PDF thumbnails load lazily in the list and grid, using the shared freedesktop cache (`~/.cache/thumbnails`, same as Dolphin/Nautilus). Missing ones are generated (videos need `ffmpegthumbnailer`, - PDFs `pdftoppm` from poppler). + PDFs `pdftoppm` from poppler, HEIC and RAW/DNG photos `vipsthumbnail` from libvips). - The info panel (F11) previews the selection: images, video/audio players, the first page of PDFs, syntax-highlighted text/code, and rendered Markdown (tables, task lists, code blocks, relative images and links; "View source" toggles). Markdown is sanitized before it's shown. diff --git a/src-tauri/src/preview.rs b/src-tauri/src/preview.rs index 7c0a420..3366445 100644 --- a/src-tauri/src/preview.rs +++ b/src-tauri/src/preview.rs @@ -33,6 +33,8 @@ fn kind(path: &Path) -> &'static str { "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff" | "ico" => "image", "mp4" | "mkv" | "webm" | "avi" | "mov" | "wmv" | "flv" | "m4v" | "mpg" | "mpeg" => "video", "pdf" => "pdf", + // image-rs can't decode these; libvips can (libheif / libraw), and applies the EXIF rotation + "heic" | "heif" | "dng" | "raw" | "cr2" | "cr3" | "nef" | "arw" | "raf" | "orf" | "rw2" | "pef" | "srw" => "photo", _ => match infer::get_from_path(path).ok().flatten().map(|t| t.matcher_type()) { Some(infer::MatcherType::Image) => "image", Some(infer::MatcherType::Video) => "video", @@ -45,18 +47,20 @@ fn kind(path: &Path) -> &'static str { fn render(src: &Path, size: u32) -> Result { match kind(src) { "image" => image::open(src).map(|i| i.thumbnail(size, size)).map_err(|e| e.to_string()), - k @ ("video" | "pdf") => { + k @ ("video" | "pdf" | "photo") => { let tmp = tempfile_path(); let ok = if k == "video" { Command::new("ffmpegthumbnailer").arg("-i").arg(src).arg("-o").arg(&tmp).args(["-s", &size.to_string(), "-c", "png"]).output() + } else if k == "photo" { + Command::new("vipsthumbnail").arg(src).arg("-o").arg(&tmp).args(["-s", &format!("{size}>")]).output() // ">": shrink only } else { // pdftoppm appends ".png" to the output root with -singlefile Command::new("pdftoppm").args(["-png", "-singlefile", "-f", "1", "-l", "1", "-scale-to", &size.to_string()]).arg(src).arg(tmp.with_extension("")).output() } - .map_err(|e| format!("{e} (is {} installed?)", if k == "video" { "ffmpegthumbnailer" } else { "poppler" }))?; + .map_err(|e| format!("{e} (is {} installed?)", match k { "video" => "ffmpegthumbnailer", "photo" => "libvips", _ => "poppler" }))?; let img = image::open(&tmp).map_err(|e| format!("{}: {e}", String::from_utf8_lossy(&ok.stderr).trim())); let _ = fs::remove_file(&tmp); - img.map(|i| i.thumbnail(size, size)) + if k == "photo" { img } else { img.map(|i| i.thumbnail(size, size)) } } _ => Err("no thumbnailer for this type".into()), } @@ -264,6 +268,18 @@ trailer<>\n%%EOF\n").unwrap(); assert!(thumbnail(&root, &img, 800).unwrap().starts_with(root.join("whale-cabinet/previews/800"))); } + #[test] + fn heic_through_libvips() { + let t = tempfile::tempdir().unwrap(); + let (png, heic) = (t.path().join("a.png"), t.path().join("IMG_0001.HEIC")); + image::DynamicImage::new_rgb8(600, 300).save(&png).unwrap(); + if Command::new("vips").arg("copy").arg(&png).arg(&heic).status().map_or(true, |s| !s.success()) { + return eprintln!("skipped: no libvips with HEIF"); + } + let th = thumbnail(&t.path().join("cache"), &heic, 1200).unwrap(); + assert_eq!(image::image_dimensions(&th).unwrap(), (600, 300), "never upscaled"); + } + #[test] fn failures_are_remembered() { let t = tempfile::tempdir().unwrap(); diff --git a/src/preview.ts b/src/preview.ts index 64b6fa6..6e0cc0d 100644 --- a/src/preview.ts +++ b/src/preview.ts @@ -138,12 +138,12 @@ export function copyButton(text: () => string, label = "Copy") { } const tools = (...bs: HTMLElement[]) => { const t = document.createElement("div"); t.className = "pv-tools"; t.append(...bs); return t; }; -// WebKit can't show these itself (they still get thumbnails where a thumbnailer can) -const NO_WEB = new Set(["heic", "heif", "raw", "cr2", "nef", "tif", "tiff"]); +// WebKit can't show these itself: the viewer shows a big decoded PNG from the thumbnailer (libvips / image-rs) +const NO_WEB = new Set("heic heif raw dng cr2 cr3 nef arw raf orf rw2 pef srw tif tiff".split(" ")); const TEXTY = new Set(["pdf", "md", "markdown", "txt", "log", "csv", "conf", "ini", "desktop"]); /** Can the viewer show this one properly? (decides "open in the viewer" for Enter / double-click) */ export const previewable = (e: Entry) => !e.dir && !e.special && !e.broken && !e.trashId && - (!!e.preview || (["img", "vid", "aud", "code"].includes(kindOf(e)) && !NO_WEB.has(ext(e))) || TEXTY.has(ext(e))); + (!!e.preview || ["img", "vid", "aud", "code"].includes(kindOf(e)) || TEXTY.has(ext(e))); /** Every page of a PDF, each rendered (poppler) as it scrolls near. */ async function pdfPages(e: Entry) { @@ -178,6 +178,11 @@ async function previewOf(e: Entry, big: boolean): Promise { const url = assetUrl(e.path); if (e.preview) { const i = new Image(); i.src = e.preview; i.draggable = false; return { el: i }; } if (e.dir || e.special || e.broken) return null; + if (k === "img" && NO_WEB.has(x)) { + const p = await invoke("thumbnail", { path: e.path, size: big ? 2400 : 600 }).catch(() => ""); + if (!p) return null; + const i = new Image(); i.src = assetUrl(p); i.draggable = false; return { el: i }; + } if (k === "img") { const i = new Image(); i.src = url; i.draggable = false; return { el: i }; } if (k === "vid") { const v = document.createElement("video"); v.src = url; v.controls = true; v.preload = "metadata"; return { el: v }; } if (k === "aud") { const a = document.createElement("audio"); a.src = url; a.controls = true; a.preload = "metadata"; return { el: a }; } diff --git a/src/util.ts b/src/util.ts index f98cca9..f362c24 100644 --- a/src/util.ts +++ b/src/util.ts @@ -33,7 +33,7 @@ export const fmtDate = (ms: number) => (ms ? dateFmt.format(ms) : ""); export const KINDS: Record = {}; for (const [k, list] of Object.entries({ - img: "png jpg jpeg gif webp svg bmp tiff tif avif heic ico jxl raw cr2 nef", + img: "png jpg jpeg gif webp svg bmp tiff tif avif heic heif ico jxl raw dng cr2 cr3 nef arw raf orf rw2 pef srw", vid: "mp4 mkv webm avi mov wmv flv m4v mpg mpeg", aud: "mp3 flac ogg opus wav m4a aac wma", arc: "zip tar gz xz bz2 zst 7z rar tgz iso deb rpm appimage", From 0ad55b6f3e23c089cce99398cce2f5838aa7a811 Mon Sep 17 00:00:00 2001 From: PluginMike <65072741+PluginMike@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:06:41 +0200 Subject: [PATCH 2/2] Bump version to 1.1.0 --- package-lock.json | 5 +++-- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index c5d893b..a8bef69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { "name": "whale-cabinet", - "version": "0.1.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "whale-cabinet", - "version": "0.1.0", + "version": "1.1.0", + "license": "GPL-3.0-or-later", "dependencies": { "@crabnebula/tauri-plugin-drag": "^2.1.0", "@tauri-apps/api": "^2.12.0", diff --git a/package.json b/package.json index 917f1aa..e5b5393 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "whale-cabinet", "private": true, - "version": "1.0.0", + "version": "1.1.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b116314..eaa3bc4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4725,7 +4725,7 @@ checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" [[package]] name = "whale-cabinet" -version = "1.0.0" +version = "1.1.0" dependencies = [ "crc32fast", "dirs 6.0.0", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 224c24f..cd36f64 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "whale-cabinet" -version = "1.0.0" +version = "1.1.0" edition = "2021" description = "A Linux file manager with a flat filing-cabinet look" license = "GPL-3.0-or-later" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5e9cbe3..14141c2 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "productName": "Whale Cabinet", "mainBinaryName": "whale-cabinet", - "version": "1.0.0", + "version": "1.1.0", "identifier": "whale-cabinet", "build": { "beforeDevCommand": "npm run dev",