runar is a keyboard-first, statically-linkable Linux file manager written from scratch in pure Rust.

This commit is contained in:
Jeremy Anderson 2026-07-28 05:52:53 -04:00
parent a8de34443b
commit 820ba8eb34
27 changed files with 264 additions and 47 deletions

0
BLOG.md Normal file → Executable file
View File

0
Cargo.lock generated Normal file → Executable file
View File

0
Cargo.toml Normal file → Executable file
View File

0
LICENSE Normal file → Executable file
View File

0
QUICKSTART.md Normal file → Executable file
View File

0
README.md Normal file → Executable file
View File

0
runar-ss.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

0
src/config/actions.rs Normal file → Executable file
View File

0
src/config/bookmarks.rs Normal file → Executable file
View File

28
src/config/defaults.rs Normal file → Executable file
View File

@ -8,6 +8,8 @@
//! * /var/run/media — udisks2 auto-mount point (systemd systems)
//! * /opt — optional software packages
//! * /usr/src — kernel sources / build trees
//! * /srv — site-specific data served by the system
//! * /var/www — traditional web server document root
//! * $HOME — user's home directory
//! * $HOME/Downloads — downloads folder (deliberate exception to the
//! "no hardcoded XDG dirs" rule — see README)
@ -70,7 +72,7 @@ pub fn defaults() -> Vec<DefaultLocation> {
/// Same as `defaults()` but takes an explicit home directory. Used by
/// tests so they don't have to mutate the `HOME` env var.
pub fn defaults_with(home: Option<PathBuf>) -> Vec<DefaultLocation> {
let mut out = Vec::with_capacity(6);
let mut out = Vec::with_capacity(8);
out.push(DefaultLocation {
label: "mnt",
@ -96,6 +98,18 @@ pub fn defaults_with(home: Option<PathBuf>) -> Vec<DefaultLocation> {
icon: crate::icons::Icon::Folder,
});
out.push(DefaultLocation {
label: "srv",
path: Some(PathBuf::from("/srv")),
icon: crate::icons::Icon::Folder,
});
out.push(DefaultLocation {
label: "www",
path: Some(PathBuf::from("/var/www")),
icon: crate::icons::Icon::Folder,
});
if let Some(h) = &home {
out.push(DefaultLocation {
label: "Home",
@ -127,6 +141,8 @@ mod tests {
assert!(paths.iter().any(|p| p == "/opt"));
assert!(paths.iter().any(|p| p == "/usr/src"));
assert!(paths.iter().any(|p| p == "/var/run/media"));
assert!(paths.iter().any(|p| p == "/srv"));
assert!(paths.iter().any(|p| p == "/var/www"));
}
#[test]
@ -149,15 +165,17 @@ mod tests {
assert!(d.iter().any(|x| x.label == "opt"));
assert!(d.iter().any(|x| x.label == "src"));
assert!(d.iter().any(|x| x.label == "removable"));
assert!(d.iter().any(|x| x.label == "srv"));
assert!(d.iter().any(|x| x.label == "www"));
}
#[test]
fn defaults_total_count() {
// 4 fixed + 2 home-relative = 6 when home is provided
// 6 fixed + 2 home-relative = 8 when home is provided
let d = defaults_with(Some(PathBuf::from("/home/testuser")));
assert_eq!(d.len(), 6);
// 4 fixed only when home is None
assert_eq!(d.len(), 8);
// 6 fixed only when home is None
let d = defaults_with(None);
assert_eq!(d.len(), 4);
assert_eq!(d.len(), 6);
}
}

0
src/config/mod.rs Normal file → Executable file
View File

0
src/date.rs Normal file → Executable file
View File

0
src/icons.rs Normal file → Executable file
View File

0
src/launch.rs Normal file → Executable file
View File

0
src/main.rs Normal file → Executable file
View File

0
src/mime.rs Normal file → Executable file
View File

0
src/mounts.rs Normal file → Executable file
View File

4
src/ui/about.rs Normal file → Executable file
View File

@ -27,10 +27,10 @@ pub fn view(_app: &super::App) -> Element<'static, Message> {
.color(Color::from_rgb(0.85, 0.85, 0.88)),
Space::new(0, 16),
text("Author").size(11).color(Color::from_rgb(0.55, 0.55, 0.6)),
text("Jeremy Anderson <jeremy@dcos.net>")
text("Jeremy Anderson <info@dcos.net>")
.size(13)
.color(Color::from_rgb(0.92, 0.92, 0.93)),
text("https://dcos.net/runar")
text("https://git.dcos.net/dcosnet/runar")
.size(12)
.color(Color::from_rgb(0.5, 0.7, 0.95)),
Space::new(0, 16),

0
src/ui/context_menu.rs Normal file → Executable file
View File

25
src/ui/grid.rs Normal file → Executable file
View File

@ -73,10 +73,35 @@ pub fn view<'a>(app: &'a super::App) -> Element<'a, Message> {
.width(Length::Fill),
);
} else {
// Filter hidden (dotfile) entries when `show_hidden` is false.
// We iterate the full entries slice with `enumerate()` so the
// row index passed to `EntryClicked(i)` still refers to the
// original `entries` vec — selection / activation indexing stays
// correct regardless of the visibility filter.
let show_hidden = app.show_hidden();
let mut visible_count = 0usize;
for (i, entry) in entries.iter().enumerate() {
if !show_hidden && entry.hidden {
continue;
}
visible_count += 1;
let is_selected = app.selected() == Some(i);
list = list.push(entry_row(i, entry, is_selected));
}
// Edge case: the directory has entries but they're all hidden and
// the user has hidden-files hidden. Show "(empty)" rather than a
// blank grid so the user knows the dir isn't unreadable.
if visible_count == 0 {
list = list.push(
container(
text("(empty)")
.size(12)
.color(Color::from_rgb(0.5, 0.5, 0.55)),
)
.padding([20, 12])
.width(Length::Fill),
);
}
}
let scroll = scrollable(list).width(Length::Fill).height(Length::Fill);

46
src/ui/menubar.rs Normal file → Executable file
View File

@ -293,21 +293,37 @@ fn menu_items_for(kind: MenuKind, app: &super::App) -> Vec<MenuItem> {
Message::NotImplemented("Preferences"),
),
],
MenuKind::View => vec![
MenuItem::entry_with_shortcut(
"Reload",
"Ctrl+R",
Message::Reload,
),
MenuItem::separator(),
MenuItem::entry("Show Hidden Files", Message::NotImplemented("Show Hidden")),
MenuItem::entry("Sort by Name", Message::NotImplemented("Sort by Name")),
MenuItem::entry("Sort by Size", Message::NotImplemented("Sort by Size")),
MenuItem::entry("Sort by Modified", Message::NotImplemented("Sort by Modified")),
MenuItem::separator(),
MenuItem::entry("Icon View", Message::NotImplemented("Icon View")),
MenuItem::entry("Detailed List", Message::NotImplemented("Detailed List")),
],
MenuKind::View => {
// Dynamic label: shows "Hide Hidden Files" when they're
// currently visible (so clicking hides them), and "Show
// Hidden Files" when they're hidden (so clicking shows them).
// This makes the toggle direction obvious from the label
// alone, matching Thunar / PCManFM behavior.
let hidden_label = if app.show_hidden() {
"Hide Hidden Files"
} else {
"Show Hidden Files"
};
vec![
MenuItem::entry_with_shortcut(
"Reload",
"Ctrl+R",
Message::Reload,
),
MenuItem::separator(),
MenuItem::entry_with_shortcut(
hidden_label,
"Ctrl+H",
Message::ToggleHiddenFiles,
),
MenuItem::entry("Sort by Name", Message::NotImplemented("Sort by Name")),
MenuItem::entry("Sort by Size", Message::NotImplemented("Sort by Size")),
MenuItem::entry("Sort by Modified", Message::NotImplemented("Sort by Modified")),
MenuItem::separator(),
MenuItem::entry("Icon View", Message::NotImplemented("Icon View")),
MenuItem::entry("Detailed List", Message::NotImplemented("Detailed List")),
]
}
MenuKind::Go => {
let mut items = vec![
MenuItem::entry_with_shortcut(

67
src/ui/mod.rs Normal file → Executable file
View File

@ -120,6 +120,9 @@ pub enum Message {
ShowAbout,
/// Close the About dialog.
CloseAbout,
/// Toggle whether hidden (dotfile) entries are shown in the grid.
/// Bound to the View → "Show/Hide Hidden Files" menu item and Ctrl+H.
ToggleHiddenFiles,
/// No-op. Used by disabled menu entries and separators so they don't
/// need an `Option<Message>` field.
Noop,
@ -222,6 +225,11 @@ pub struct App {
/// True when the About dialog is open.
show_about: bool,
/// Whether hidden (dotfile) entries are currently shown in the grid.
/// Toggled via View → "Show/Hide Hidden Files" or Ctrl+H. Defaults to
/// false (hidden files hidden), matching Thunar / PCManFM defaults.
show_hidden: bool,
// --- Drag-and-drop state ---
/// The entry path being dragged from the grid, once the drag threshold
/// (5px movement while button held) is exceeded. None when not dragging.
@ -261,6 +269,7 @@ impl App {
open_menu: None,
context_menu_target: None,
show_about: false,
show_hidden: false,
drag_source: None,
drag_candidate_idx: None,
drag_press_pos: None,
@ -271,7 +280,22 @@ impl App {
/// Navigate to `dir`. Bumps scan_id so the subscription restarts,
/// clears the entry list immediately (so the UI feels responsive
/// rather than showing stale entries from the old dir).
///
/// If `dir` is relative, it's resolved against `current_dir` first —
/// this is defensive (the breadcrumb builder now produces absolute
/// paths, but a relative path typed into the pathbar edit field would
/// otherwise be interpreted against the process CWD, which is rarely
/// what the user means). We deliberately don't `canonicalize` because
/// that resolves symlinks, and the user may have intentionally
/// navigated to a symlinked path.
fn navigate(&mut self, dir: PathBuf) {
let dir = if dir.is_absolute() {
dir
} else {
let mut resolved = self.current_dir.clone();
resolved.push(dir);
resolved
};
self.current_dir = dir;
self.scan_id = self.scan_id.wrapping_add(1);
self.entries.clear();
@ -350,6 +374,12 @@ impl App {
self.show_about
}
/// True if hidden (dotfile) entries are currently shown in the grid.
/// When false, the grid filters out entries whose name starts with `.`.
pub fn show_hidden(&self) -> bool {
self.show_hidden
}
/// True if a drag-from-grid is in progress (threshold exceeded).
#[allow(dead_code)]
pub fn is_dragging(&self) -> bool {
@ -380,8 +410,17 @@ pub fn update(app: &mut App, msg: Message) -> Task<Message> {
}
ScanEvent::Done { total, .. } => {
app.scanning = false;
// Reflect the visible count (filtered by show_hidden)
// so the status bar matches what the user sees. When
// hidden files are visible, `total` is correct; when
// hidden, we count only the non-hidden entries.
let visible = if app.show_hidden {
total
} else {
app.entries.iter().filter(|e| !e.hidden).count()
};
app.status =
format!("{} items • {}", total, app.current_dir.display());
format!("{} items • {}", visible, app.current_dir.display());
}
ScanEvent::EntryError { path, message } => {
log::warn!("scan error on {}: {message}", path.display());
@ -603,6 +642,27 @@ pub fn update(app: &mut App, msg: Message) -> Task<Message> {
app.show_about = false;
Task::none()
}
Message::ToggleHiddenFiles => {
// Flip the visibility flag. The grid view reads `show_hidden`
// on every render via `app.show_hidden()`, so iced re-renders
// automatically — no rescan needed (entries stay in memory).
// The status bar count is recomputed here so it matches the
// visible count immediately.
app.show_hidden = !app.show_hidden;
app.open_menu = None; // close the menubar dropdown after click
let visible = if app.show_hidden {
app.entries.len()
} else {
app.entries.iter().filter(|e| !e.hidden).count()
};
if app.scanning {
app.status = format!("scanning {}...", app.current_dir.display());
} else {
app.status =
format!("{} items • {}", visible, app.current_dir.display());
}
Task::none()
}
Message::Noop => Task::none(),
// --- Drag-and-drop ---
@ -720,6 +780,11 @@ fn handle_key(
return Task::none();
}
// Ctrl+H toggles hidden-files visibility (matches Thunar / PCManFM).
if modifiers.control() && matches!(&key, iced::keyboard::Key::Character(c) if c == "h") {
return update(app, Message::ToggleHiddenFiles);
}
// Escape closes things in priority order: About → context menu →
// menubar dropdown → pathbar edit mode. Each guard returns early.
if matches!(key.as_ref(), iced::keyboard::Key::Named(Named::Escape)) {

137
src/ui/pathbar.rs Normal file → Executable file
View File

@ -39,28 +39,7 @@ pub fn view<'a>(app: &'a super::App) -> Element<'a, Message> {
/// by `/`. Clicking any segment navigates to that ancestor.
fn breadcrumbs<'a>(app: &'a super::App) -> Element<'a, Message> {
let dir = app.current_dir();
let mut segments: Vec<(String, PathBuf)> = Vec::new();
let mut acc = PathBuf::new();
// Always start with "/" as the root segment.
segments.push(("/".to_string(), PathBuf::from("/")));
for component in dir.components() {
use std::path::Component;
match component {
Component::RootDir => {}
Component::Normal(name) => {
acc.push(name);
let label = name.to_string_lossy().into_owned();
segments.push((label, acc.clone()));
}
Component::ParentDir => {
acc.push("..");
segments.push(("..".into(), acc.clone()));
}
_ => {}
}
}
let segments = build_breadcrumb_segments(dir);
let total = segments.len();
let mut row_state = row![].spacing(2).align_y(Alignment::Center);
@ -96,3 +75,117 @@ fn breadcrumbs<'a>(app: &'a super::App) -> Element<'a, Message> {
row_state.width(Length::Fill).into()
}
/// Build the (label, target_path) pairs for the breadcrumb trail of `dir`.
///
/// Factored out of `breadcrumbs` so it can be unit-tested without
/// constructing a full `App`. The first segment is always the root `/`
/// with target `/`; subsequent segments are one per `Normal` or
/// `ParentDir` component of `dir`.
///
/// All emitted target paths are ABSOLUTE. This is the fix for the
/// historical bug where clicking a non-root breadcrumb (e.g. "really"
/// in /folder/really/deep) navigated to a relative path that failed
/// to scan. See `build_breadcrumb_segments_produces_absolute_paths`
/// in the tests below.
fn build_breadcrumb_segments(dir: &std::path::Path) -> Vec<(String, PathBuf)> {
let mut segments: Vec<(String, PathBuf)> = Vec::new();
// Always start with "/" as the root segment.
segments.push(("/".to_string(), PathBuf::from("/")));
// Accumulator for ancestor paths. MUST start as an absolute path
// (PathBuf::from("/")) so each `push(name)` produces an absolute
// path. Starting with an empty `PathBuf::new()` was the historical
// bug — it accumulated *relative* paths like "folder/really", which
// then resolved against the process's CWD instead of the filesystem
// root when scanned.
let mut acc = PathBuf::from("/");
for component in dir.components() {
use std::path::Component;
match component {
// RootDir is already represented by the initial "/" segment
// and the leading "/" in `acc`. Skip so we don't double it.
Component::RootDir => {}
Component::Normal(name) => {
acc.push(name);
let label = name.to_string_lossy().into_owned();
segments.push((label, acc.clone()));
}
Component::ParentDir => {
acc.push("..");
segments.push(("..".into(), acc.clone()));
}
_ => {}
}
}
segments
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::{Path, PathBuf};
/// Regression test for the breadcrumb bug: clicking a parent segment
/// (e.g. "really" in /folder/really/deep) must produce an ABSOLUTE
/// path so `navigate()` can scan it. Previously the accumulator
/// started as `PathBuf::new()` and produced relative paths like
/// "folder/really", which then resolved against the process CWD
/// and failed to load.
#[test]
fn build_breadcrumb_segments_produces_absolute_paths() {
let dir = Path::new("/folder/really/deep");
let segs = build_breadcrumb_segments(dir);
// Expected: [("/", "/"), ("folder", "/folder"),
// ("really", "/folder/really"),
// ("deep", "/folder/really/deep")]
assert_eq!(segs.len(), 4);
assert_eq!(segs[0].0, "/");
assert_eq!(segs[0].1, PathBuf::from("/"));
assert_eq!(segs[1].0, "folder");
assert_eq!(segs[1].1, PathBuf::from("/folder"));
assert!(segs[1].1.is_absolute(), "must be absolute: {}", segs[1].1.display());
assert_eq!(segs[2].0, "really");
assert_eq!(segs[2].1, PathBuf::from("/folder/really"));
assert!(segs[2].1.is_absolute(), "must be absolute: {}", segs[2].1.display());
assert_eq!(segs[3].0, "deep");
assert_eq!(segs[3].1, PathBuf::from("/folder/really/deep"));
assert!(segs[3].1.is_absolute(), "must be absolute: {}", segs[3].1.display());
}
/// Root path produces a single-segment breadcrumb.
#[test]
fn build_breadcrumb_segments_root_only() {
let segs = build_breadcrumb_segments(Path::new("/"));
assert_eq!(segs.len(), 1);
assert_eq!(segs[0].0, "/");
assert_eq!(segs[0].1, PathBuf::from("/"));
}
/// Single-level path: two segments (root + one).
#[test]
fn build_breadcrumb_segments_single_level() {
let segs = build_breadcrumb_segments(Path::new("/home"));
assert_eq!(segs.len(), 2);
assert_eq!(segs[0].0, "/");
assert_eq!(segs[1].0, "home");
assert_eq!(segs[1].1, PathBuf::from("/home"));
}
/// The last segment's path must equal the input dir, so clicking the
/// "current" breadcrumb re-scans the same dir (no-op navigate).
#[test]
fn build_breadcrumb_segments_last_matches_input() {
let dir = Path::new("/a/b/c/d");
let segs = build_breadcrumb_segments(dir);
assert_eq!(segs.last().unwrap().1, PathBuf::from("/a/b/c/d"));
}
}

4
src/ui/sidebar.rs Normal file → Executable file
View File

@ -4,8 +4,8 @@
//! pseudo-FSes filtered out by src/mounts.rs).
//!
//! 2. LOCATIONS — hardcoded default shortcuts: /mnt, /var/run/media,
//! /opt, /usr/src, $HOME, $HOME/Downloads. Always
//! present; not user-removable. Defined in
//! /opt, /usr/src, /srv, /var/www, $HOME, $HOME/Downloads.
//! Always present; not user-removable. Defined in
//! src/config/defaults.rs.
//!
//! 3. BOOKMARKS — purely user-added paths. Seeded empty on first run.

0
src/vfs/appdir.rs Normal file → Executable file
View File

0
src/vfs/mod.rs Normal file → Executable file
View File

0
src/vfs/watcher.rs Normal file → Executable file
View File