diff --git a/BLOG.md b/BLOG.md old mode 100644 new mode 100755 diff --git a/Cargo.lock b/Cargo.lock old mode 100644 new mode 100755 diff --git a/Cargo.toml b/Cargo.toml old mode 100644 new mode 100755 diff --git a/LICENSE b/LICENSE old mode 100644 new mode 100755 diff --git a/QUICKSTART.md b/QUICKSTART.md old mode 100644 new mode 100755 diff --git a/README.md b/README.md old mode 100644 new mode 100755 diff --git a/runar-ss.png b/runar-ss.png old mode 100644 new mode 100755 diff --git a/src/config/actions.rs b/src/config/actions.rs old mode 100644 new mode 100755 diff --git a/src/config/bookmarks.rs b/src/config/bookmarks.rs old mode 100644 new mode 100755 diff --git a/src/config/defaults.rs b/src/config/defaults.rs old mode 100644 new mode 100755 index e09dee4..eda65c2 --- a/src/config/defaults.rs +++ b/src/config/defaults.rs @@ -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 { /// 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) -> Vec { - 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) -> Vec { 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); } } diff --git a/src/config/mod.rs b/src/config/mod.rs old mode 100644 new mode 100755 diff --git a/src/date.rs b/src/date.rs old mode 100644 new mode 100755 diff --git a/src/icons.rs b/src/icons.rs old mode 100644 new mode 100755 diff --git a/src/launch.rs b/src/launch.rs old mode 100644 new mode 100755 diff --git a/src/main.rs b/src/main.rs old mode 100644 new mode 100755 diff --git a/src/mime.rs b/src/mime.rs old mode 100644 new mode 100755 diff --git a/src/mounts.rs b/src/mounts.rs old mode 100644 new mode 100755 diff --git a/src/ui/about.rs b/src/ui/about.rs old mode 100644 new mode 100755 index abf33e0..f03f201 --- a/src/ui/about.rs +++ b/src/ui/about.rs @@ -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 ") + text("Jeremy Anderson ") .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), diff --git a/src/ui/context_menu.rs b/src/ui/context_menu.rs old mode 100644 new mode 100755 diff --git a/src/ui/grid.rs b/src/ui/grid.rs old mode 100644 new mode 100755 index f8a5643..b490ecb --- a/src/ui/grid.rs +++ b/src/ui/grid.rs @@ -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); diff --git a/src/ui/menubar.rs b/src/ui/menubar.rs old mode 100644 new mode 100755 index e6a4237..3ebb68f --- a/src/ui/menubar.rs +++ b/src/ui/menubar.rs @@ -293,21 +293,37 @@ fn menu_items_for(kind: MenuKind, app: &super::App) -> Vec { 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( diff --git a/src/ui/mod.rs b/src/ui/mod.rs old mode 100644 new mode 100755 index 29a471d..b940e16 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -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` 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 { } 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 { 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)) { diff --git a/src/ui/pathbar.rs b/src/ui/pathbar.rs old mode 100644 new mode 100755 index c9bad60..e3e3f11 --- a/src/ui/pathbar.rs +++ b/src/ui/pathbar.rs @@ -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")); + } +} diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs old mode 100644 new mode 100755 index 8979c3e..c9e123d --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -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. diff --git a/src/vfs/appdir.rs b/src/vfs/appdir.rs old mode 100644 new mode 100755 diff --git a/src/vfs/mod.rs b/src/vfs/mod.rs old mode 100644 new mode 100755 diff --git a/src/vfs/watcher.rs b/src/vfs/watcher.rs old mode 100644 new mode 100755