runar is a keyboard-first, statically-linkable Linux file manager written from scratch in pure Rust.
This commit is contained in:
parent
a8de34443b
commit
820ba8eb34
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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,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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue