runar/BLOG.md

21 KiB
Executable File

Building runar: a hybrid file manager for the post-DE world

I'm Jeremy Anderson, and I run a small corner of the web at dcos.net where I write about Linux desktop plumbing, Rust GUI experiments, and the kind of low-level tinker projects that don't fit anywhere else. This is the story of runar, a file manager I've been building since early 2026. The state of Linux file managers in 2026 is, depending on your perspective, either healthy or strange. Nautilus has spent the last decade shedding features until it's barely a file manager anymore — it's a GNOME Shell extension with a file viewer attached, and if your workflow doesn't match the GNOME workflow, Nautilus will quietly refuse to accommodate you. Thunar is still solid, still my daily driver on Xfce, but it's Xfce-bound in ways that go deeper than the toolkit: the assumptions about volume management, the D-Bus dependencies, the libxfce4ui coupling. PCManFM is showing its age — the GTK2/3 split, the lingering 32-bit assumptions, the maintainer burnout that's visible in the commit log. Nemo is fine, but it's Cinnamon-bound, and dragging Cinnamon's dependencies onto a non-Cinnamon system feels wrong.

What I noticed, somewhere around the third time I installed a fresh Arch or Alpine system and sighed at the file-manager situation, is that the lightweight-power-user niche is wide open. There's Nautilus for GNOME people, Dolphin for KDE people, Thunar for Xfce people, Nemo for Cinnamon people, and then... a long tail of abandoned GTK2 projects and GTK3 ports that never quite finished. Nobody is building a file manager for the person who knows what /proc/mounts is, who wants a static binary they can drop on a USB stick, who doesn't want to inherit a desktop environment's worth of D-Bus daemons just to browse their files. So I started building one.

The hybrid philosophy

runar is not a from-scratch reinvention. It's a deliberate hybrid, and the genealogy matters because each of its ancestors solved a real problem that the modern DE file managers have papered over. From Thunar I took the dual-pane/tree-and-grid layout, the breadcrumb pathbar that gets out of your way, and the keyboard-first navigation model where you never have to touch the mouse to move through a directory. Thunar's layout is, in my opinion, the high-water mark of file-manager UX — clean, discoverable, and unobtrusive. I wanted runar to feel like that within five seconds of launching it. From PCManFM I took the lightweight GTK ethos, which is to say the spirit of minimal-footprint, fast-boot, no-surprises file management. We kept the spirit and swapped the toolkit, but the design priorities are straight out of the PCManFM playbook: don't block the UI thread, don't allocate gratuitously, don't reach for a D-Bus daemon when a file will do.

From ROX-Filer I took the two ideas that genuinely matter. The first is the AppDir paradigm: a directory containing an executable AppRun script (or a file matching the directory's name) is treated as an application bundle. Double-click launches it. Shift+Double-Click enters it as a directory. No .desktop file, no xdg-mime query, no installation step, no package manager. AppDirs pre-date Flatpak by a decade and remain the simplest self-contained app packaging format on Linux. The second is the instant MIME action hook: instead of .desktop files and xdg-mime queries, ROX let you wire file types to shell commands in a small config file. runar does this with a TOML file. From Puppy Linux and DSL I took the broader lesson that ROX-Filer was, for those distributions, the desktop backbone — the file manager was the desktop, and AppDirs were the application format. That's a design posture I find genuinely inspiring. And from SliTaz I took the minimal-footprint, fast-boot, read-only-media philosophy: a file manager should run happily from a squashfs image on a CD-R, or from a USB stick with a read-only overlay, without complaint.

The key insight across all of these is that they solved real problems — packaging, MIME dispatch, removable media, fast boot — that GNOME Files and Dolphin have since buried under abstraction layers. runar keeps the solutions and drops the abstraction. The AppDir is still a better packaging format than a Flatpak for a single-file application. The TOML action hook is still a faster MIME dispatcher than the xdg-mime / mimeapps.list / .desktop stack. The /proc/mounts poller is still a more transparent volume monitor than gio::VolumeMonitor. None of these ideas are mine. I'm just the one putting them back together in Rust.

The clean sidebar canvas

The hardest design call in runar was deciding what not to put in the sidebar. Modern file managers shove XDG user directories at you the moment you open the window: Downloads, Documents, Pictures, Music, Videos. Then they add "Recent", "Starred", "Other Locations", "Trash", "Network". Then they add bookmarks you didn't ask for. By the time you finish scrolling the sidebar on a 13-inch laptop, you've forgotten what you came for, and the actual file view is off-screen below the fold. This is the UX failure mode of every DE-integrated file manager: the sidebar is treated as a discoverability surface for features the desktop team wants to surface, not as a navigation aid for the user's actual workflow.

runar's sidebar has three sections and nothing else. DEVICES is what's actually mounted, polled from /proc/mounts with pseudo-filesystems filtered out — you see your real filesystems, your USB sticks, your NFS mounts, and nothing else. LOCATIONS is a small fixed set of power-user paths: /mnt, /var/run/media, /opt, /usr/src, $HOME, and $HOME/Downloads. BOOKMARKS is purely user-added — empty on first run, no defaults injected, no "we thought you'd want these" presets. That's the whole sidebar. No "Recent files" because your shell history is faster. No "Starred" because that's what bookmarks are for. No "Other Locations" because that's what the pathbar is for. The sidebar fits on screen without scrolling on a 13-inch laptop, and on a 24-inch monitor it occupies maybe a quarter of the left edge.

The hardcoded LOCATIONS list is opinionated, and I'll defend every entry. /mnt and /var/run/media because that's where USB sticks and removable media show up on systemd systems — /media is legacy, /run/media/$USER is the udisks2 convention, and /var/run/media is the symlink target that shows up in /proc/mounts. /opt because that's where vendor packages live, and if you're the kind of person who installs vendor packages you want one click to get there. /usr/src because that's where kernel hackers live, and kernel hackers are part of runar's audience. $HOME and $HOME/Downloads because every workflow needs a scratch pad, and the browser still defaults to Downloads. If you don't like the list, the source is right there — fork it, change six lines, rebuild. The point of a hardcoded list isn't to impose my taste; it's to keep the sidebar short enough to be useful, which no configurable sidebar ever is.

The GTK4-to-iced pivot

The original project manifest specified gtk4-rs, and for the first week of prototyping that's what I used. After a week I switched to iced, and I want to explain why because I think it's the most contested decision in the whole project and I want to be fair to both sides. The promise of gtk4-rs is real: native X11 and Wayland rendering, native icon theme lookup, standard XDS drag-and-drop, hardware-accelerated grid views, accessibility via AT-SPI, and the full weight of GTK's 20-year polish in rendering and input handling. All of that is true, and none of it is trivial. If you're building a file manager that has to feel native on a Fedora GNOME desktop, gtk4-rs is the right answer.

The cost is what killed it for me. At build time you need libgtk-4-dev, libglib2.0-dev, libgraphene-1.0-dev, and a pile of headers. At runtime you depend on a system icon theme being installed — hicolor, Adwaita, Papirus, something — because GTK's icon lookup goes through the theme, and if the user is on a minimal Alpine install with no theme, your file manager looks broken out of the box. The shared library footprint is around 30 MB that has to be present at runtime. And then there's the Rust ergonomics problem: GLib's object system transposed into Rust via glib::Object, glib::MainContext, signal connections, raw pointer lifetimes. It's workable. The gtk4-rs maintainers have done heroic work. But it's not idiomatic Rust, and it's not the Elm-style functional update model that makes GUI code legible. Static linking is technically possible with gtk4 static libs but practically a nightmare — you end up rebuilding GTK itself, and the resulting binary still wants a theme at runtime.

The iced trade-off is the mirror image. iced is pure Rust — cargo build just works on any platform with a Rust toolchain, no system packages required. The release profile (lto=fat, codegen-units=1, strip=symbols, panic=abort) produces a 5 to 8 MB binary with zero shared library dependencies, and I mean zero: ldd runar returns "not a dynamic executable." Icons are inline SVG, baked into the binary, so there's no system theme dependency — runar looks the same on Ubuntu and on a from-scratch Alpine install, which is exactly the property I wanted. The architecture is async-native Elm: State, update(Message) -> Task<Message>, view(&State) -> Element<Message>, subscription(&State) -> Subscription<Message>. Clean separation, no signal spaghetti, no glib::clone! macro. The costs are real too: no native cross-process DND (XDS) — in-app drag-and-drop works, but you can't drag a file from runar to GIMP. No gio::VolumeMonitor, which I replaced with a 60-line /proc/mounts poller. And iced is immature at file-manager scale — untested at Nautilus traffic levels, and I won't pretend otherwise.

For runar's use case — a power-user file manager for people who already know what /proc/mounts is — the static binary wins. The cross-process DND gap is real but recoverable; a winit hook or a future iced release could close it. The icon theme gap is, for my audience, a feature rather than a bug: runar looks the same everywhere, and "looks the same everywhere" is a property I will trade a lot of polish for. And the Rust ergonomics argument alone is almost enough. Compare the same key-press handler in both toolkits. In gtk4-rs you create an EventControllerKey, connect_key_pressed with a closure that clones the app handle, returns a glib::Propagation, and you have to remember to add the controller to the widget. In iced it's a pure function from (Key, Modifiers) to Option<Message>, and the runtime handles dispatch. Same functionality, half the code, no clone macro, no propagation enum to remember. After a week of writing the gtk4 version, I rewrote it in iced in two days and the code was shorter and clearer. That settled it.

Architecture decisions

Non-blocking VFS (Phase 1)

The single most important architectural decision in runar is that scan_directory runs entirely on the tokio runtime and never touches the UI thread. When you open a directory, runar spawns a scan task that walks the directory with tokio::fs::read_dir, stats each entry, and streams ScanEvent::Entry events back to the UI over an mpsc::unbounded_channel. The UI thread processes these events incrementally and adds each entry to the visible list as it arrives. On a fast local filesystem this looks instantaneous. On a slow NFS mount with 10,000+ files, the first entries appear within milliseconds and the rest trickle in as they're stat'd, and at no point does the UI freeze.

This is the design lesson from every file manager that has ever frozen on a stuck network share: don't do I/O on the UI thread, ever, even for "quick" metadata calls. The temptation is strong because a single stat call feels cheap, and on a local filesystem it is. On NFS it's a round trip. On a dead NFS mount it's a 60-second timeout, and if you're on the UI thread when it fires, your window manager will offer to force-quit you. runar's rule is that the UI thread does no I/O at all — not directory reads, not stats, not MIME lookups, not icon loads. Everything goes through a channel. This is more code than the synchronous version, but it's the difference between a file manager that feels fast and one that feels broken.

/proc/mounts instead of gio::VolumeMonitor

gio::VolumeMonitor is the standard answer for "what's mounted on this system" in the GLib world. It's a D-Bus-speaking wrapper around udev and udisks2, it handles hotplug events, it knows about optical drives and MTP devices and GVFS mounts. If you're already in GLib-land, it's the right answer. We're not in GLib-land. runar polls /proc/mounts directly every 5 seconds, parses it with a 60-line parser, filters out pseudo-filesystems (proc, sysfs, tmpfs, cgroup, devtmpfs, and friends), and tags network mounts (nfs, cifs, sshfs) for separate icon treatment in the sidebar.

The trade-off is that we don't get hotplug events — there's up to a 5-second delay between a USB stick being mounted and it appearing in the sidebar. For a power-user file manager, that's fine. The upside is zero D-Bus dependency, zero udev dependency, zero udisks2 dependency, and a volume monitor that works identically on systemd systems, on OpenRC systems, on a BusyBox initramfs, and on a container where D-Bus isn't even running. /proc/mounts is the kernel's own view of what's mounted where, and the kernel is the one source of truth that's always present. When you strip away the abstractions, this is what's underneath them, and it turns out the underneath is plenty for a file manager.

Inline SVG icons

The standard way to resolve icons on Linux is the freedesktop-icons crate, which walks the system icon theme (hicolor, Papirus, Breeze, etc.) at runtime and returns a path to an SVG or PNG. This works great on a desktop Linux system with a full icon theme installed. It breaks on a from-scratch Alpine install with no theme. It breaks in a container. It breaks on a minimal embedded system. And it imposes a runtime dependency on a package you can't ship — your binary is correct, but it looks wrong because the theme isn't there.

runar ships a tiny inline SVG glyph set baked into the binary: folder, file, appdir, symlink, device, network, bookmark, up, edit, reload. Ten icons, about 3 KB of SVG markup, zero file I/O at runtime. The icons are rendered by iced's vector backend and scaled to whatever size the view requests. They're not pretty — they're functional, monochrome, and deliberately spare. If you want Papirus, fork the project and replace src/icons.rs. The point isn't to win a beauty contest; the point is that runar looks the same on every system it runs on, and "looks the same on every system" is a property that matters more than "looks native on this specific system" for the audience I'm building for.

AppDir detection

ROX-Filer's killer feature, the one thing every ROX user remembers, is the AppDir. A directory containing an executable AppRun script — or a file with the same name as the directory — is treated as an application bundle. You double-click it and it launches. You Shift+Double-Click it and you enter the directory like any other. No .desktop file, no xdg-mime query, no installation step, no package manager, no root. The application is the directory; the directory is the application. This pre-dates Flatpak by a decade and macOS app bundles by several years, and it remains the simplest self-contained app packaging format on Linux.

runar implements AppDir detection in src/vfs/appdir.rs, which is 60 lines of Rust. During the directory scan, each subdirectory is probed for an AppRun executable or a same-named launcher file. On a match, the entry is tagged EntryKind::AppDir { launcher }, the icon flips to the appdir glyph, and double-click dispatches to the launch handler in src/launch.rs, which spawns the launcher directly. Shift+Double-Click falls through to the normal "enter directory" behavior. The whole feature is small enough to hold in your head, which is the point — packaging formats that grow beyond what you can hold in your head become Flatpak, and Flatpak is fine but it's not what runar is for.

Instant MIME action hooks

runar's MIME dispatch is a TOML file at ~/.config/runar/actions.toml. It maps file extensions and MIME globs to shell commands. An entry looks like this:

[[actions]]
pattern = "image/*"
command = "feh {}"

The {} is substituted with the shell-quoted file path. Commands run via sh -c, so pipes work, environment variables work, command substitution works, and you can compose with any tool on your $PATH. The launch dispatcher consults this file first, on every activation, and falls back to xdg-open (via the open crate) only if no pattern matches.

What's missing here is the entire .desktop / xdg-mime / mimeapps.list stack. No .desktop files. No xdg-mime queries. No mimeapps.list precedence rules. No desktop-environment daemon. Just a TOML file and a sh -c call. This is the ROX-Filer philosophy distilled to its essence: the user knows what they want to happen when they double-click a file, and the file manager's job is to do that thing, not to consult a committee of specifications first. The cost is that you have to write the TOML file yourself the first time — there's no "detect installed applications and build the list for you" magic. For runar's audience, that's a feature: you write the three lines you actually want, and the file manager does exactly that, forever, without surprises.

What's next

Phase 3 of the original manifest is only partially done. The launch dispatcher is in and works — AppDirs launch, MIME actions fire, xdg-open is the fallback. What's not in yet is background file operations: copy, move, delete with progress toasts, cancellation, and error surfacing. That's the next big chunk of work, and it's mostly a UI problem rather than a systems problem. The async plumbing is already there — tokio is running, the channels work, the scan loop proves the pattern. What's missing is a progress-bar widget, a cancellation token threaded through the operation, and an error toast that surfaces failures without blocking the rest of the UI. None of that is research; it's just work.

After file operations, the next item is a true multi-column icon grid. The current view is a single-column virtualized list, which scales to 10,000+ entries without breaking a sweat but doesn't feel like a file manager to anyone who grew up with icon views. A virtualized grid is harder than a virtualized list — the layout math is nastier, the selection model has to handle two-dimensional ranges, the keyboard navigation has to decide what "down" means — but iced's grid widget is mature enough now to build on. After that: cross-process DND via a winit hook, since the iced maintainers have signaled they're open to XDS support but it's not on their near-term roadmap. And thumbnail rendering, which is almost free given that rayon and the image crate are already in the dependency tree — wire them up behind a cache, render on a worker pool, ship the bytes to the UI as they're ready.

The long-term vision is the part I'm most serious about and least able to prove. I want runar to be the default file manager for a from-scratch Linux desktop. Not a GNOME replacement, not a KDE replacement, not "yet another lightweight DE" — a foundation for people who want to build their own desktop without inheriting 20 years of XDG specification baggage. The desktop-as-file-manager posture that Puppy and DSL pioneered with ROX-Filer is still viable, and Rust's static-binary story makes it more viable than it was in 2005. If you want a desktop where the file manager is the shell and AppDirs are the application format and /proc/mounts is the volume manager, runar is the file manager half of that. The other half — window management, panel, launcher — is someone else's project, but I'd love to talk to whoever's building it.

Closing

If any of this resonates, the code is at https://dcos.net/runar under GPL-2.0-only, the same license as Thunar, because the license is part of the genealogy and I want the genealogy to be honest. Patches are welcome, and the architecture is small enough to hold in your head — about 2,000 lines of Rust across 13 files. The largest file is src/vfs.rs at maybe 350 lines, and most of the rest are under 200. If you've ever wanted to hack on a file manager but bounced off Nautilus's 80,000-line codebase, or off Dolphin's plugin architecture, or off Thunar's Xfce coupling, this is your entry point. The code is readable, the build is one cargo build --release away, and the static binary that comes out runs on any Linux system with a kernel and a display. That's the whole pitch. Come hack on it.

— Jeremy Anderson, 2026