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)
|
//! * /var/run/media — udisks2 auto-mount point (systemd systems)
|
||||||
//! * /opt — optional software packages
|
//! * /opt — optional software packages
|
||||||
//! * /usr/src — kernel sources / build trees
|
//! * /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 — user's home directory
|
||||||
//! * $HOME/Downloads — downloads folder (deliberate exception to the
|
//! * $HOME/Downloads — downloads folder (deliberate exception to the
|
||||||
//! "no hardcoded XDG dirs" rule — see README)
|
//! "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
|
/// Same as `defaults()` but takes an explicit home directory. Used by
|
||||||
/// tests so they don't have to mutate the `HOME` env var.
|
/// tests so they don't have to mutate the `HOME` env var.
|
||||||
pub fn defaults_with(home: Option<PathBuf>) -> Vec<DefaultLocation> {
|
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 {
|
out.push(DefaultLocation {
|
||||||
label: "mnt",
|
label: "mnt",
|
||||||
|
|
@ -96,6 +98,18 @@ pub fn defaults_with(home: Option<PathBuf>) -> Vec<DefaultLocation> {
|
||||||
icon: crate::icons::Icon::Folder,
|
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 {
|
if let Some(h) = &home {
|
||||||
out.push(DefaultLocation {
|
out.push(DefaultLocation {
|
||||||
label: "Home",
|
label: "Home",
|
||||||
|
|
@ -127,6 +141,8 @@ mod tests {
|
||||||
assert!(paths.iter().any(|p| p == "/opt"));
|
assert!(paths.iter().any(|p| p == "/opt"));
|
||||||
assert!(paths.iter().any(|p| p == "/usr/src"));
|
assert!(paths.iter().any(|p| p == "/usr/src"));
|
||||||
assert!(paths.iter().any(|p| p == "/var/run/media"));
|
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]
|
#[test]
|
||||||
|
|
@ -149,15 +165,17 @@ mod tests {
|
||||||
assert!(d.iter().any(|x| x.label == "opt"));
|
assert!(d.iter().any(|x| x.label == "opt"));
|
||||||
assert!(d.iter().any(|x| x.label == "src"));
|
assert!(d.iter().any(|x| x.label == "src"));
|
||||||
assert!(d.iter().any(|x| x.label == "removable"));
|
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]
|
#[test]
|
||||||
fn defaults_total_count() {
|
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")));
|
let d = defaults_with(Some(PathBuf::from("/home/testuser")));
|
||||||
assert_eq!(d.len(), 6);
|
assert_eq!(d.len(), 8);
|
||||||
// 4 fixed only when home is None
|
// 6 fixed only when home is None
|
||||||
let d = defaults_with(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)),
|
.color(Color::from_rgb(0.85, 0.85, 0.88)),
|
||||||
Space::new(0, 16),
|
Space::new(0, 16),
|
||||||
text("Author").size(11).color(Color::from_rgb(0.55, 0.55, 0.6)),
|
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)
|
.size(13)
|
||||||
.color(Color::from_rgb(0.92, 0.92, 0.93)),
|
.color(Color::from_rgb(0.92, 0.92, 0.93)),
|
||||||
text("https://dcos.net/runar")
|
text("https://git.dcos.net/dcosnet/runar")
|
||||||
.size(12)
|
.size(12)
|
||||||
.color(Color::from_rgb(0.5, 0.7, 0.95)),
|
.color(Color::from_rgb(0.5, 0.7, 0.95)),
|
||||||
Space::new(0, 16),
|
Space::new(0, 16),
|
||||||
|
|
|
||||||
|
|
@ -73,10 +73,35 @@ pub fn view<'a>(app: &'a super::App) -> Element<'a, Message> {
|
||||||
.width(Length::Fill),
|
.width(Length::Fill),
|
||||||
);
|
);
|
||||||
} else {
|
} 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() {
|
for (i, entry) in entries.iter().enumerate() {
|
||||||
|
if !show_hidden && entry.hidden {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
visible_count += 1;
|
||||||
let is_selected = app.selected() == Some(i);
|
let is_selected = app.selected() == Some(i);
|
||||||
list = list.push(entry_row(i, entry, is_selected));
|
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);
|
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"),
|
Message::NotImplemented("Preferences"),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
MenuKind::View => vec![
|
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(
|
MenuItem::entry_with_shortcut(
|
||||||
"Reload",
|
"Reload",
|
||||||
"Ctrl+R",
|
"Ctrl+R",
|
||||||
Message::Reload,
|
Message::Reload,
|
||||||
),
|
),
|
||||||
MenuItem::separator(),
|
MenuItem::separator(),
|
||||||
MenuItem::entry("Show Hidden Files", Message::NotImplemented("Show Hidden")),
|
MenuItem::entry_with_shortcut(
|
||||||
|
hidden_label,
|
||||||
|
"Ctrl+H",
|
||||||
|
Message::ToggleHiddenFiles,
|
||||||
|
),
|
||||||
MenuItem::entry("Sort by Name", Message::NotImplemented("Sort by Name")),
|
MenuItem::entry("Sort by Name", Message::NotImplemented("Sort by Name")),
|
||||||
MenuItem::entry("Sort by Size", Message::NotImplemented("Sort by Size")),
|
MenuItem::entry("Sort by Size", Message::NotImplemented("Sort by Size")),
|
||||||
MenuItem::entry("Sort by Modified", Message::NotImplemented("Sort by Modified")),
|
MenuItem::entry("Sort by Modified", Message::NotImplemented("Sort by Modified")),
|
||||||
MenuItem::separator(),
|
MenuItem::separator(),
|
||||||
MenuItem::entry("Icon View", Message::NotImplemented("Icon View")),
|
MenuItem::entry("Icon View", Message::NotImplemented("Icon View")),
|
||||||
MenuItem::entry("Detailed List", Message::NotImplemented("Detailed List")),
|
MenuItem::entry("Detailed List", Message::NotImplemented("Detailed List")),
|
||||||
],
|
]
|
||||||
|
}
|
||||||
MenuKind::Go => {
|
MenuKind::Go => {
|
||||||
let mut items = vec![
|
let mut items = vec![
|
||||||
MenuItem::entry_with_shortcut(
|
MenuItem::entry_with_shortcut(
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,9 @@ pub enum Message {
|
||||||
ShowAbout,
|
ShowAbout,
|
||||||
/// Close the About dialog.
|
/// Close the About dialog.
|
||||||
CloseAbout,
|
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
|
/// No-op. Used by disabled menu entries and separators so they don't
|
||||||
/// need an `Option<Message>` field.
|
/// need an `Option<Message>` field.
|
||||||
Noop,
|
Noop,
|
||||||
|
|
@ -222,6 +225,11 @@ pub struct App {
|
||||||
/// True when the About dialog is open.
|
/// True when the About dialog is open.
|
||||||
show_about: bool,
|
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 ---
|
// --- Drag-and-drop state ---
|
||||||
/// The entry path being dragged from the grid, once the drag threshold
|
/// The entry path being dragged from the grid, once the drag threshold
|
||||||
/// (5px movement while button held) is exceeded. None when not dragging.
|
/// (5px movement while button held) is exceeded. None when not dragging.
|
||||||
|
|
@ -261,6 +269,7 @@ impl App {
|
||||||
open_menu: None,
|
open_menu: None,
|
||||||
context_menu_target: None,
|
context_menu_target: None,
|
||||||
show_about: false,
|
show_about: false,
|
||||||
|
show_hidden: false,
|
||||||
drag_source: None,
|
drag_source: None,
|
||||||
drag_candidate_idx: None,
|
drag_candidate_idx: None,
|
||||||
drag_press_pos: None,
|
drag_press_pos: None,
|
||||||
|
|
@ -271,7 +280,22 @@ impl App {
|
||||||
/// Navigate to `dir`. Bumps scan_id so the subscription restarts,
|
/// Navigate to `dir`. Bumps scan_id so the subscription restarts,
|
||||||
/// clears the entry list immediately (so the UI feels responsive
|
/// clears the entry list immediately (so the UI feels responsive
|
||||||
/// rather than showing stale entries from the old dir).
|
/// 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) {
|
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.current_dir = dir;
|
||||||
self.scan_id = self.scan_id.wrapping_add(1);
|
self.scan_id = self.scan_id.wrapping_add(1);
|
||||||
self.entries.clear();
|
self.entries.clear();
|
||||||
|
|
@ -350,6 +374,12 @@ impl App {
|
||||||
self.show_about
|
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).
|
/// True if a drag-from-grid is in progress (threshold exceeded).
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn is_dragging(&self) -> bool {
|
pub fn is_dragging(&self) -> bool {
|
||||||
|
|
@ -380,8 +410,17 @@ pub fn update(app: &mut App, msg: Message) -> Task<Message> {
|
||||||
}
|
}
|
||||||
ScanEvent::Done { total, .. } => {
|
ScanEvent::Done { total, .. } => {
|
||||||
app.scanning = false;
|
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 =
|
app.status =
|
||||||
format!("{} items • {}", total, app.current_dir.display());
|
format!("{} items • {}", visible, app.current_dir.display());
|
||||||
}
|
}
|
||||||
ScanEvent::EntryError { path, message } => {
|
ScanEvent::EntryError { path, message } => {
|
||||||
log::warn!("scan error on {}: {message}", path.display());
|
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;
|
app.show_about = false;
|
||||||
Task::none()
|
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(),
|
Message::Noop => Task::none(),
|
||||||
|
|
||||||
// --- Drag-and-drop ---
|
// --- Drag-and-drop ---
|
||||||
|
|
@ -720,6 +780,11 @@ fn handle_key(
|
||||||
return Task::none();
|
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 →
|
// Escape closes things in priority order: About → context menu →
|
||||||
// menubar dropdown → pathbar edit mode. Each guard returns early.
|
// menubar dropdown → pathbar edit mode. Each guard returns early.
|
||||||
if matches!(key.as_ref(), iced::keyboard::Key::Named(Named::Escape)) {
|
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.
|
/// by `/`. Clicking any segment navigates to that ancestor.
|
||||||
fn breadcrumbs<'a>(app: &'a super::App) -> Element<'a, Message> {
|
fn breadcrumbs<'a>(app: &'a super::App) -> Element<'a, Message> {
|
||||||
let dir = app.current_dir();
|
let dir = app.current_dir();
|
||||||
let mut segments: Vec<(String, PathBuf)> = Vec::new();
|
let segments = build_breadcrumb_segments(dir);
|
||||||
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 total = segments.len();
|
let total = segments.len();
|
||||||
let mut row_state = row![].spacing(2).align_y(Alignment::Center);
|
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()
|
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).
|
//! pseudo-FSes filtered out by src/mounts.rs).
|
||||||
//!
|
//!
|
||||||
//! 2. LOCATIONS — hardcoded default shortcuts: /mnt, /var/run/media,
|
//! 2. LOCATIONS — hardcoded default shortcuts: /mnt, /var/run/media,
|
||||||
//! /opt, /usr/src, $HOME, $HOME/Downloads. Always
|
//! /opt, /usr/src, /srv, /var/www, $HOME, $HOME/Downloads.
|
||||||
//! present; not user-removable. Defined in
|
//! Always present; not user-removable. Defined in
|
||||||
//! src/config/defaults.rs.
|
//! src/config/defaults.rs.
|
||||||
//!
|
//!
|
||||||
//! 3. BOOKMARKS — purely user-added paths. Seeded empty on first run.
|
//! 3. BOOKMARKS — purely user-added paths. Seeded empty on first run.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue