visual fixes, WIP
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main", "master" ]
|
||||
pull_request:
|
||||
branches: [ "main", "master" ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Unit Tests
|
||||
runs-on: windows-2025
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Set up Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Run unit tests
|
||||
run: cargo test --workspace --all-targets
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Release version (e.g. v1.0.0)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Build and Release
|
||||
runs-on: windows-2025
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Set up Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Build for release
|
||||
run: cargo build --release --locked --workspace
|
||||
|
||||
- name: Generate SHA256 checksum
|
||||
shell: pwsh
|
||||
run: |
|
||||
$hash = (Get-FileHash -Path target/release/librarian.exe -Algorithm SHA256).Hash.ToLower()
|
||||
"$hash librarian.exe" | Out-File -FilePath target/release/librarian.exe.sha256 -Encoding ascii
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@c95fe14b5134b883085f5e7c1e00f91a13b86905 # v2.2.0
|
||||
with:
|
||||
tag_name: ${{ github.event.inputs.version }}
|
||||
name: Release ${{ github.event.inputs.version }}
|
||||
files: |
|
||||
target/release/librarian.exe
|
||||
target/release/librarian.exe.sha256
|
||||
draft: false
|
||||
prerelease: false
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! A single-line text widget that truncates with an ellipsis ("…") when it
|
||||
//! doesn't fit its width, instead of wrapping to a second line.
|
||||
//!
|
||||
//! iced 0.14 has no built-in ellipsis: [`Wrapping::None`](iced::advanced::text::Wrapping)
|
||||
//! keeps text on one line but lets it overflow its bounds, and there's no
|
||||
//! truncation strategy. This widget fills that gap. The only place a renderer
|
||||
//! (and thus real font metrics) is available is a widget's `layout`, so that's
|
||||
//! where it measures: it lays the full string out on one line, and if that
|
||||
//! exceeds the available width it binary-searches the longest character prefix
|
||||
//! that fits with an ellipsis appended. Measuring against the actual font keeps
|
||||
//! truncation exact regardless of glyph widths.
|
||||
//!
|
||||
//! Everything else is delegated to iced's own text `layout`/`draw`/`State`, so
|
||||
//! the only new logic is the measure-and-truncate step.
|
||||
|
||||
use iced::advanced::text::paragraph::Paragraph;
|
||||
use iced::advanced::widget::text as text_widget;
|
||||
use iced::advanced::widget::{tree, Tree};
|
||||
use iced::advanced::{layout, mouse, renderer, text, Layout, Widget};
|
||||
use iced::{Element, Length, Pixels, Rectangle, Size};
|
||||
|
||||
/// Create an ellipsizing single-line text cell with the given content.
|
||||
pub fn ellipsized(content: impl Into<String>) -> Ellipsized {
|
||||
Ellipsized {
|
||||
content: content.into(),
|
||||
width: Length::Shrink,
|
||||
size: None,
|
||||
align_x: text::Alignment::Default,
|
||||
}
|
||||
}
|
||||
|
||||
/// A single-line, ellipsis-truncating text widget. See the [module
|
||||
/// docs](self).
|
||||
pub struct Ellipsized {
|
||||
content: String,
|
||||
width: Length,
|
||||
size: Option<Pixels>,
|
||||
align_x: text::Alignment,
|
||||
}
|
||||
|
||||
impl Ellipsized {
|
||||
/// Sets the width the text is fit into (truncation happens at this width).
|
||||
pub fn width(mut self, width: impl Into<Length>) -> Self {
|
||||
self.width = width.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the text size; defaults to the renderer's default.
|
||||
pub fn size(mut self, size: impl Into<Pixels>) -> Self {
|
||||
self.size = Some(size.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the horizontal alignment within the cell — e.g. right-align a
|
||||
/// numeric column. Truncation (prefix + trailing "…") is unchanged; only
|
||||
/// where the resulting line sits within the cell differs.
|
||||
pub fn align_x(mut self, alignment: impl Into<text::Alignment>) -> Self {
|
||||
self.align_x = alignment.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// The shared text [`Format`](text_widget::Format) for both measuring and
|
||||
/// laying out — top aligned, never wrapping.
|
||||
fn format<Font>(&self) -> text_widget::Format<Font> {
|
||||
text_widget::Format {
|
||||
width: self.width,
|
||||
height: Length::Shrink,
|
||||
size: self.size,
|
||||
font: None,
|
||||
line_height: text::LineHeight::default(),
|
||||
align_x: self.align_x,
|
||||
align_y: iced::alignment::Vertical::Top,
|
||||
shaping: text::Shaping::default(),
|
||||
wrapping: text::Wrapping::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The widget's per-instance state is just iced's text paragraph cache.
|
||||
type State<R> = text_widget::State<<R as text::Renderer>::Paragraph>;
|
||||
|
||||
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer> for Ellipsized
|
||||
where
|
||||
Renderer: text::Renderer,
|
||||
{
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<State<Renderer>>()
|
||||
}
|
||||
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(State::<Renderer>::default())
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
Size {
|
||||
width: self.width,
|
||||
height: Length::Shrink,
|
||||
}
|
||||
}
|
||||
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
let state = tree.state.downcast_mut::<State<Renderer>>();
|
||||
let format = self.format::<Renderer::Font>();
|
||||
|
||||
// Lay out the full string first (single line). The resulting node's
|
||||
// width is the cell's *real* width — the fixed column width, or the
|
||||
// fill allocation — which is what we must fit into. (The incoming
|
||||
// `limits.max().width` can be larger than a fixed column, so it can't
|
||||
// be used for the fit test.) If the text fits, we're done — the common
|
||||
// case costs exactly what a plain text widget would.
|
||||
let node = text_widget::layout(state, renderer, limits, &self.content, format);
|
||||
let avail = node.size().width;
|
||||
if state.min_bounds().width <= avail {
|
||||
return node;
|
||||
}
|
||||
// It overflows: truncate to the longest prefix that fits with "…".
|
||||
let display = ellipsize::<Renderer>(&self.content, avail, renderer, format);
|
||||
text_widget::layout(state, renderer, limits, &display, format)
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut Renderer,
|
||||
_theme: &Theme,
|
||||
defaults: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
_cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let state = tree.state.downcast_ref::<State<Renderer>>();
|
||||
text_widget::draw(
|
||||
renderer,
|
||||
defaults,
|
||||
layout.bounds(),
|
||||
state.raw(),
|
||||
// No explicit color: inherit the surrounding text color (so selected
|
||||
// rows still recolor their text via the row container's style).
|
||||
text_widget::Style { color: None },
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The longest character prefix of `content` that fits within `avail` pixels
|
||||
/// once an ellipsis is appended, measured against the actual font. Returns at
|
||||
/// least `"…"`.
|
||||
fn ellipsize<Renderer>(
|
||||
content: &str,
|
||||
avail: f32,
|
||||
renderer: &Renderer,
|
||||
format: text_widget::Format<Renderer::Font>,
|
||||
) -> String
|
||||
where
|
||||
Renderer: text::Renderer,
|
||||
{
|
||||
let size = format.size.unwrap_or_else(|| renderer.default_size());
|
||||
let font = format.font.unwrap_or_else(|| renderer.default_font());
|
||||
|
||||
// Single-line pixel width of a candidate string, via a throwaway paragraph.
|
||||
let measure = |candidate: &str| -> f32 {
|
||||
<Renderer::Paragraph as Paragraph>::with_text(text::Text {
|
||||
content: candidate,
|
||||
bounds: Size::new(f32::INFINITY, f32::INFINITY),
|
||||
size,
|
||||
line_height: format.line_height,
|
||||
font,
|
||||
align_x: format.align_x,
|
||||
align_y: format.align_y,
|
||||
shaping: format.shaping,
|
||||
wrapping: text::Wrapping::None,
|
||||
})
|
||||
.min_width()
|
||||
};
|
||||
|
||||
let chars: Vec<(usize, char)> = content.char_indices().collect();
|
||||
let n = chars.len();
|
||||
let byte_of = |m: usize| if m >= n { content.len() } else { chars[m].0 };
|
||||
|
||||
// Width grows monotonically with the prefix length, so binary-search the
|
||||
// largest m in [0, n] whose prefix + "…" still fits.
|
||||
let mut lo = 0usize;
|
||||
let mut hi = n;
|
||||
let mut best = 0usize;
|
||||
while lo <= hi {
|
||||
let mid = (lo + hi) / 2;
|
||||
let candidate = format!("{}…", &content[..byte_of(mid)]);
|
||||
if measure(&candidate) <= avail {
|
||||
best = mid;
|
||||
if mid == n {
|
||||
break;
|
||||
}
|
||||
lo = mid + 1;
|
||||
} else if mid == 0 {
|
||||
break;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
format!("{}…", &content[..byte_of(best)])
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer> From<Ellipsized> for Element<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Renderer: text::Renderer + 'a,
|
||||
Theme: 'a,
|
||||
Message: 'a,
|
||||
{
|
||||
fn from(widget: Ellipsized) -> Self {
|
||||
Element::new(widget)
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod config;
|
||||
mod ellipsis;
|
||||
mod icons;
|
||||
mod rows;
|
||||
mod selection;
|
||||
mod tree;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -12,22 +14,25 @@ use std::time::{Duration, Instant};
|
||||
use iced::keyboard::key::Named;
|
||||
use iced::keyboard::{self, Key};
|
||||
use iced::widget::{
|
||||
button, checkbox, column, container, image, mouse_area, row, scrollable, stack, text,
|
||||
text_input, Space,
|
||||
button, checkbox, column, container, image, mouse_area, pane_grid, row, scrollable, stack,
|
||||
text, text_input, Space,
|
||||
};
|
||||
use iced::{Border, Center, Element, Length::Fill, Point, Size, Subscription, Task, Theme};
|
||||
|
||||
use librarian_core::{
|
||||
is_visible, read_dir_all, sort_entries, Entry, History, Location, Sort, SortKey, SortOrder,
|
||||
is_visible, read_dir_all, read_subdirs, sort_entries, Entry, History, Location, Sort, SortKey,
|
||||
SortOrder,
|
||||
};
|
||||
use librarian_win::{
|
||||
copy_items, create_folder, delete_to_recycle, known_folders, list_drives, move_items, rename,
|
||||
Apartment, DriveInfo, IconImage, KnownFolder, ShellWorker,
|
||||
};
|
||||
|
||||
use ellipsis::ellipsized;
|
||||
use icons::{extract_icons, IconCache, IconKey};
|
||||
use rows::{format_time, human_size, Row};
|
||||
use selection::Selection;
|
||||
use tree::{Reveal, Tree, TreeChild, TreeRow};
|
||||
|
||||
/// How close two clicks on the same row must be to count as a double-click.
|
||||
const DOUBLE_CLICK: Duration = Duration::from_millis(400);
|
||||
@@ -45,6 +50,13 @@ const OVERSCAN: usize = 6;
|
||||
/// status). Used only to estimate the visible row count before the first real
|
||||
/// scroll viewport arrives.
|
||||
const CHROME_HEIGHT: f32 = 150.0;
|
||||
/// Height of a row in the folder-tree pane. Slightly tighter than the file
|
||||
/// list, matching Explorer's denser sidebar.
|
||||
const TREE_ROW_HEIGHT: f32 = 22.0;
|
||||
/// Horizontal indentation added per tree depth level.
|
||||
const TREE_INDENT: f32 = 14.0;
|
||||
/// Initial fraction of the window width given to the tree pane.
|
||||
const TREE_RATIO: f32 = 0.22;
|
||||
|
||||
fn main() -> iced::Result {
|
||||
let start = startup_location();
|
||||
@@ -92,6 +104,14 @@ enum Nav {
|
||||
PageDown,
|
||||
}
|
||||
|
||||
/// Which region a `pane_grid` pane holds: the folder tree (left) or the file
|
||||
/// list (right).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum PaneKind {
|
||||
Tree,
|
||||
List,
|
||||
}
|
||||
|
||||
/// What the file list is currently showing.
|
||||
enum Content {
|
||||
ThisPc {
|
||||
@@ -108,6 +128,13 @@ enum Content {
|
||||
struct Librarian {
|
||||
worker: ShellWorker,
|
||||
history: History,
|
||||
/// The folder navigation tree shown in the left pane.
|
||||
tree: Tree,
|
||||
/// Layout of the tree / file-list split, owning the draggable divider ratio.
|
||||
panes: pane_grid::State<PaneKind>,
|
||||
/// A path the tree is mid-way through revealing (expanding ancestors to);
|
||||
/// re-driven each time an intermediate child load completes.
|
||||
pending_reveal: Option<PathBuf>,
|
||||
content: Content,
|
||||
/// Precomputed, filtered+sorted display rows for the current `content`.
|
||||
rows: Vec<Row>,
|
||||
@@ -175,6 +202,15 @@ enum Message {
|
||||
/// Result of an in-place refresh that preserves selection and scroll.
|
||||
Reloaded(u64, Result<Vec<Entry>, String>),
|
||||
IconsLoaded(Vec<(IconKey, IconImage)>),
|
||||
// --- navigation tree ---
|
||||
/// Expand/collapse the tree node with this id.
|
||||
TreeToggle(tree::NodeId),
|
||||
/// Navigate the main view to a tree node's location.
|
||||
TreeNavigate(Location),
|
||||
/// Children of tree node `id` finished loading (or failed).
|
||||
TreeChildrenLoaded(tree::NodeId, Result<Vec<TreeChild>, String>),
|
||||
/// The user dragged the divider between the tree and the file list.
|
||||
PaneResized(pane_grid::ResizeEvent),
|
||||
// --- file operations ---
|
||||
OpenSelected,
|
||||
NewFolder,
|
||||
@@ -191,9 +227,19 @@ enum Message {
|
||||
impl Librarian {
|
||||
fn new(start: Location) -> (Self, Task<Message>) {
|
||||
let settings = config::load();
|
||||
// Two side-by-side panes: the folder tree on the left, file list right.
|
||||
let panes = pane_grid::State::with_configuration(pane_grid::Configuration::Split {
|
||||
axis: pane_grid::Axis::Vertical,
|
||||
ratio: TREE_RATIO,
|
||||
a: Box::new(pane_grid::Configuration::Pane(PaneKind::Tree)),
|
||||
b: Box::new(pane_grid::Configuration::Pane(PaneKind::List)),
|
||||
});
|
||||
let mut app = Self {
|
||||
worker: ShellWorker::spawn(),
|
||||
history: History::new(start),
|
||||
tree: Tree::new(),
|
||||
panes,
|
||||
pending_reveal: None,
|
||||
content: Content::ThisPc {
|
||||
drives: Vec::new(),
|
||||
folders: Vec::new(),
|
||||
@@ -217,9 +263,11 @@ impl Librarian {
|
||||
scroll_y: 0.0,
|
||||
viewport_h: 720.0 - CHROME_HEIGHT,
|
||||
};
|
||||
// Load the starting location and theme the window in parallel.
|
||||
// Load the starting location, populate the tree root (drives + known
|
||||
// folders), and theme the window — all in parallel.
|
||||
let load = app.load_current();
|
||||
(app, Task::batch([apply_chrome(), load]))
|
||||
let tree_load = app.load_tree_children(tree::ROOT_ID, Location::ThisPc);
|
||||
(app, Task::batch([apply_chrome(), load, tree_load]))
|
||||
}
|
||||
|
||||
/// The current persisted-preference snapshot, for writing back to disk.
|
||||
@@ -374,6 +422,34 @@ impl Librarian {
|
||||
}
|
||||
}
|
||||
|
||||
// --- navigation tree -------------------------------------------
|
||||
Message::TreeToggle(id) => {
|
||||
if let Some((load_id, location)) = self.tree.toggle(id) {
|
||||
return self.load_tree_children(load_id, location);
|
||||
}
|
||||
}
|
||||
Message::TreeNavigate(location) => {
|
||||
self.menu = None;
|
||||
// Re-navigating to where we already are would just churn history.
|
||||
if self.history.current() != &location {
|
||||
return self.navigate(location);
|
||||
}
|
||||
}
|
||||
Message::TreeChildrenLoaded(id, result) => {
|
||||
// An error becomes an empty (leaf) load so the spinner/chevron
|
||||
// resolves rather than hanging.
|
||||
let children = result.unwrap_or_default();
|
||||
self.tree.set_children(id, children);
|
||||
// A newly-loaded node may let an in-progress reveal continue,
|
||||
// and its rows need icons.
|
||||
let reveal = self.drive_reveal();
|
||||
let icons = self.request_icons();
|
||||
return Task::batch([icons, reveal]);
|
||||
}
|
||||
Message::PaneResized(event) => {
|
||||
self.panes.resize(event.split, event.ratio);
|
||||
}
|
||||
|
||||
// --- selection, cursor & context menu --------------------------
|
||||
Message::CursorMoved(position) => self.cursor = position,
|
||||
Message::ModifiersChanged(modifiers) => self.modifiers = modifiers,
|
||||
@@ -533,6 +609,20 @@ impl Librarian {
|
||||
let location = self.history.current().clone();
|
||||
self.address = address_text(&location);
|
||||
|
||||
// Reveal & highlight the destination in the folder tree, expanding
|
||||
// ancestors as needed (a no-op target for the "This PC" root, which is
|
||||
// already the always-visible tree root).
|
||||
let reveal = match &location {
|
||||
Location::Path(path) => {
|
||||
self.pending_reveal = Some(path.clone());
|
||||
self.drive_reveal()
|
||||
}
|
||||
Location::ThisPc => {
|
||||
self.pending_reveal = None;
|
||||
Task::none()
|
||||
}
|
||||
};
|
||||
|
||||
let load = match location {
|
||||
Location::ThisPc => {
|
||||
self.content = Content::ThisPc {
|
||||
@@ -562,7 +652,7 @@ impl Librarian {
|
||||
}
|
||||
};
|
||||
// A fresh listing always starts at the top.
|
||||
Task::batch([self.scroll_to(0.0), load])
|
||||
Task::batch([self.scroll_to(0.0), reveal, load])
|
||||
}
|
||||
|
||||
fn on_click(&mut self, index: usize) -> Task<Message> {
|
||||
@@ -740,6 +830,36 @@ impl Librarian {
|
||||
Task::perform(offload(move || worker.run(op)), Message::OpFinished)
|
||||
}
|
||||
|
||||
// --- navigation tree ------------------------------------------------------
|
||||
|
||||
/// Load the children of a tree node on a worker thread: drives + known
|
||||
/// folders for the "This PC" root, or the subdirectories of a real folder.
|
||||
fn load_tree_children(&self, id: tree::NodeId, location: Location) -> Task<Message> {
|
||||
let worker = self.worker.clone();
|
||||
let show_hidden = self.show_hidden;
|
||||
Task::perform(
|
||||
offload(move || fetch_tree_children(&worker, &location, show_hidden)),
|
||||
move |result| Message::TreeChildrenLoaded(id, result),
|
||||
)
|
||||
}
|
||||
|
||||
/// Advance an in-progress reveal of [`Self::pending_reveal`] by one step:
|
||||
/// expand the next loaded ancestor, request a load if the next one isn't
|
||||
/// loaded yet, or finish (clearing the target) once revealed or unreachable.
|
||||
fn drive_reveal(&mut self) -> Task<Message> {
|
||||
let Some(target) = self.pending_reveal.clone() else {
|
||||
return Task::none();
|
||||
};
|
||||
match self.tree.reveal(&target) {
|
||||
Reveal::Load(id, location) => self.load_tree_children(id, location),
|
||||
Reveal::Wait => Task::none(),
|
||||
Reveal::Stop => {
|
||||
self.pending_reveal = None;
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// If a "New folder" we just created is awaiting rename, find it in the
|
||||
/// freshly-loaded rows, select it, and drop into the inline rename with its
|
||||
/// name selected — mirroring Explorer. Dropped if we've since navigated out
|
||||
@@ -810,8 +930,11 @@ impl Librarian {
|
||||
}
|
||||
|
||||
/// Kick off extraction of any icons the current rows need but don't have.
|
||||
/// Covers both the file list and the visible folder-tree nodes, which share
|
||||
/// one icon cache.
|
||||
fn request_icons(&mut self) -> Task<Message> {
|
||||
let keys = self.rows.iter().map(|r| r.icon.clone());
|
||||
let mut keys: Vec<IconKey> = self.rows.iter().map(|r| r.icon.clone()).collect();
|
||||
self.tree.collect_icon_keys(&mut keys);
|
||||
let needed = self.icons.take_unrequested(keys);
|
||||
if needed.is_empty() {
|
||||
return Task::none();
|
||||
@@ -826,11 +949,25 @@ impl Librarian {
|
||||
// --- view -----------------------------------------------------------------
|
||||
|
||||
fn view(&self) -> Element<'_, Message> {
|
||||
// The folder tree and the file list share a horizontal split with a
|
||||
// draggable divider. Column headers belong only above the file list, so
|
||||
// they live inside the right pane rather than spanning both.
|
||||
let split = pane_grid(&self.panes, |_pane, kind, _maximized| {
|
||||
let content: Element<'_, Message> = match kind {
|
||||
PaneKind::Tree => self.view_tree(),
|
||||
PaneKind::List => {
|
||||
column![view_header(self.sort), self.view_body()].into()
|
||||
}
|
||||
};
|
||||
pane_grid::Content::new(content)
|
||||
})
|
||||
.spacing(1)
|
||||
.on_resize(8, Message::PaneResized);
|
||||
|
||||
let base = column![
|
||||
self.view_toolbar(),
|
||||
self.view_command_bar(),
|
||||
view_header(self.sort),
|
||||
self.view_body(),
|
||||
split,
|
||||
self.view_status(),
|
||||
];
|
||||
match &self.menu {
|
||||
@@ -839,6 +976,60 @@ impl Librarian {
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder-tree sidebar: a scrollable, lazily-expanding directory tree.
|
||||
fn view_tree(&self) -> Element<'_, Message> {
|
||||
let rows = self.tree.visible_rows();
|
||||
let mut list = column![].width(Fill);
|
||||
for row in &rows {
|
||||
list = list.push(self.view_tree_row(row));
|
||||
}
|
||||
let scroll = scrollable(list).height(Fill);
|
||||
container(scroll)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.padding([4, 0])
|
||||
.style(tree_pane_style)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn view_tree_row<'a>(&'a self, data: &TreeRow<'a>) -> Element<'a, Message> {
|
||||
// Indent by depth, then a chevron (or a blank of the same width for
|
||||
// leaves) so labels stay aligned regardless of expandability.
|
||||
let indent = Space::new().width(8.0 + data.depth as f32 * TREE_INDENT);
|
||||
let chevron: Element<'_, Message> = if data.expandable {
|
||||
let glyph = if data.expanded { "▾" } else { "▸" };
|
||||
button(text(glyph.to_string()).size(10))
|
||||
.on_press(Message::TreeToggle(data.id))
|
||||
.padding([0, 4])
|
||||
.style(chevron_button_style)
|
||||
.into()
|
||||
} else {
|
||||
Space::new().width(16.0).into()
|
||||
};
|
||||
|
||||
let icon: Element<'_, Message> = match self.icons.get(data.icon) {
|
||||
Some(handle) => image(handle.clone()).width(16.0).height(16.0).into(),
|
||||
None => Space::new().width(16.0).height(16.0).into(),
|
||||
};
|
||||
|
||||
let selected = self.history.current() == data.location;
|
||||
let label = button(
|
||||
row![icon, ellipsized(data.label.to_string()).size(13).width(Fill)]
|
||||
.spacing(6)
|
||||
.align_y(Center),
|
||||
)
|
||||
.on_press(Message::TreeNavigate(data.location.clone()))
|
||||
.width(Fill)
|
||||
.padding([0, 4])
|
||||
.style(move |theme: &Theme, status| tree_row_button_style(theme, status, selected));
|
||||
|
||||
container(row![indent, chevron, label].spacing(2).align_y(Center))
|
||||
.height(TREE_ROW_HEIGHT)
|
||||
.width(Fill)
|
||||
.align_y(Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Action buttons for the current selection / directory. Mirrors the
|
||||
/// keyboard shortcuts and the context menu; disabled when not applicable.
|
||||
fn view_command_bar(&self) -> Element<'_, Message> {
|
||||
@@ -959,15 +1150,19 @@ impl Librarian {
|
||||
.padding([0, 2])
|
||||
.width(Fill)
|
||||
.into(),
|
||||
_ => text(data.label.clone()).width(Fill).into(),
|
||||
// Truncate over-long cells with an ellipsis rather than wrapping.
|
||||
_ => ellipsized(data.label.clone()).width(Fill).into(),
|
||||
};
|
||||
|
||||
let line = row![
|
||||
icon,
|
||||
name,
|
||||
text(modified).width(150.0),
|
||||
text(data.type_label.clone()).width(120.0),
|
||||
text(size).width(90.0),
|
||||
ellipsized(modified).width(150.0),
|
||||
ellipsized(data.type_label.clone()).width(120.0),
|
||||
// Size is numeric, so right-align it (Explorer does the same).
|
||||
ellipsized(size)
|
||||
.width(90.0)
|
||||
.align_x(iced::alignment::Horizontal::Right),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Center);
|
||||
@@ -1054,7 +1249,8 @@ impl Librarian {
|
||||
}
|
||||
|
||||
fn view_header(sort: Sort) -> Element<'static, Message> {
|
||||
let heading = |label: &str, key: SortKey, width: iced::Length| {
|
||||
use iced::alignment::Horizontal;
|
||||
let heading = |label: &str, key: SortKey, width: iced::Length, align: Horizontal| {
|
||||
let arrow = if sort.key == key {
|
||||
match sort.order {
|
||||
SortOrder::Ascending => " ▲",
|
||||
@@ -1063,7 +1259,7 @@ fn view_header(sort: Sort) -> Element<'static, Message> {
|
||||
} else {
|
||||
""
|
||||
};
|
||||
button(text(format!("{label}{arrow}")))
|
||||
button(text(format!("{label}{arrow}")).width(Fill).align_x(align))
|
||||
.on_press(Message::SortBy(key))
|
||||
.width(width)
|
||||
.padding([4, 8])
|
||||
@@ -1071,10 +1267,11 @@ fn view_header(sort: Sort) -> Element<'static, Message> {
|
||||
|
||||
row![
|
||||
Space::new().width(16.0),
|
||||
heading("Name", SortKey::Name, Fill),
|
||||
heading("Date modified", SortKey::Modified, 150.0.into()),
|
||||
heading("Type", SortKey::Type, 120.0.into()),
|
||||
heading("Size", SortKey::Size, 90.0.into()),
|
||||
heading("Name", SortKey::Name, Fill, Horizontal::Left),
|
||||
heading("Date modified", SortKey::Modified, 150.0.into(), Horizontal::Left),
|
||||
heading("Type", SortKey::Type, 120.0.into(), Horizontal::Left),
|
||||
// Right-aligned to sit over the right-aligned numeric size values.
|
||||
heading("Size", SortKey::Size, 90.0.into(), Horizontal::Right),
|
||||
]
|
||||
.spacing(8)
|
||||
.padding([0, 8])
|
||||
@@ -1100,6 +1297,48 @@ fn row_style(theme: &Theme, selected: bool, lead: bool) -> container::Style {
|
||||
style
|
||||
}
|
||||
|
||||
/// Background of the folder-tree pane — a touch darker than the file list to
|
||||
/// set the sidebar apart.
|
||||
fn tree_pane_style(theme: &Theme) -> container::Style {
|
||||
let palette = theme.extended_palette();
|
||||
container::Style {
|
||||
background: Some(palette.background.weak.color.into()),
|
||||
..container::Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Flat, borderless expand/collapse chevron that only tints on hover.
|
||||
fn chevron_button_style(theme: &Theme, status: button::Status) -> button::Style {
|
||||
let palette = theme.extended_palette();
|
||||
let mut style = button::Style {
|
||||
background: None,
|
||||
text_color: palette.background.base.text,
|
||||
..button::Style::default()
|
||||
};
|
||||
if matches!(status, button::Status::Hovered | button::Status::Pressed) {
|
||||
style.text_color = palette.primary.strong.color;
|
||||
}
|
||||
style
|
||||
}
|
||||
|
||||
/// A tree-node label button: flat, highlighting on hover and when it points at
|
||||
/// the current location.
|
||||
fn tree_row_button_style(theme: &Theme, status: button::Status, selected: bool) -> button::Style {
|
||||
let palette = theme.extended_palette();
|
||||
let mut style = button::Style {
|
||||
background: None,
|
||||
text_color: palette.background.base.text,
|
||||
..button::Style::default()
|
||||
};
|
||||
if selected {
|
||||
style.background = Some(palette.primary.weak.color.into());
|
||||
style.text_color = palette.primary.weak.text;
|
||||
} else if matches!(status, button::Status::Hovered | button::Status::Pressed) {
|
||||
style.background = Some(palette.background.strong.color.into());
|
||||
}
|
||||
style
|
||||
}
|
||||
|
||||
/// One clickable row in the context menu.
|
||||
fn menu_item(label: &str, message: Message) -> Element<'static, Message> {
|
||||
button(text(label.to_string()).size(13))
|
||||
@@ -1145,6 +1384,62 @@ fn address_text(location: &Location) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the children for a folder-tree node: drives + known folders under the
|
||||
/// "This PC" root, or the (visible) subdirectories of a real folder, sorted by
|
||||
/// name. Runs on a worker thread via [`offload`].
|
||||
fn fetch_tree_children(
|
||||
worker: &ShellWorker,
|
||||
location: &Location,
|
||||
show_hidden: bool,
|
||||
) -> Result<Vec<TreeChild>, String> {
|
||||
match location {
|
||||
Location::ThisPc => {
|
||||
let drives = list_drives();
|
||||
let folders = worker.run(|_| known_folders());
|
||||
let children = drives
|
||||
.iter()
|
||||
.map(tree_child_from_drive)
|
||||
.chain(folders.iter().map(tree_child_from_known))
|
||||
.collect();
|
||||
Ok(children)
|
||||
}
|
||||
Location::Path(dir) => {
|
||||
let mut dirs = read_subdirs(dir).map_err(|e| e.to_string())?;
|
||||
dirs.retain(|e| is_visible(e, show_hidden, ""));
|
||||
dirs.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
let children = dirs
|
||||
.into_iter()
|
||||
.map(|e| TreeChild {
|
||||
label: e.name,
|
||||
icon: IconKey::Folder,
|
||||
location: Location::Path(e.path),
|
||||
})
|
||||
.collect();
|
||||
Ok(children)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A tree child built from a drive, reusing the file-list row mapping so the
|
||||
/// label and icon match what the "This PC" listing shows.
|
||||
fn tree_child_from_drive(drive: &DriveInfo) -> TreeChild {
|
||||
let row = rows::row_from_drive(drive);
|
||||
TreeChild {
|
||||
label: row.label,
|
||||
icon: row.icon,
|
||||
location: row.target,
|
||||
}
|
||||
}
|
||||
|
||||
fn tree_child_from_known(folder: &KnownFolder) -> TreeChild {
|
||||
let row = rows::row_from_known(folder);
|
||||
TreeChild {
|
||||
label: row.label,
|
||||
icon: row.icon,
|
||||
location: row.target,
|
||||
}
|
||||
}
|
||||
|
||||
/// A non-colliding "New folder" name in `dir`, matching Explorer's scheme.
|
||||
fn unique_folder_name(dir: &Path) -> String {
|
||||
const BASE: &str = "New folder";
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
//! The folder navigation tree (left pane): a lazily-expanding directory tree
|
||||
//! rooted at "This PC".
|
||||
//!
|
||||
//! The tree is the source of truth for what the sidebar shows; the app drives
|
||||
//! it with three operations — [`Tree::toggle`] (expand/collapse a node),
|
||||
//! [`Tree::set_children`] (attach the result of an async load), and
|
||||
//! [`Tree::reveal`] (walk toward a path, expanding ancestors so the current
|
||||
//! location becomes visible). Nodes are addressed by a stable [`NodeId`] rather
|
||||
//! than by position or path, so an async child-load always finds its target
|
||||
//! even if the tree changed shape (or the same folder appears in two branches,
|
||||
//! e.g. a known folder and its real parent) while the load was in flight.
|
||||
//!
|
||||
//! All logic here is pure and filesystem-free — the app supplies already-loaded
|
||||
//! [`TreeChild`] lists — which keeps it unit-testable in isolation.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use librarian_core::Location;
|
||||
|
||||
use crate::icons::IconKey;
|
||||
|
||||
/// Stable identity for a tree node, unique within a [`Tree`] for its lifetime.
|
||||
pub type NodeId = u64;
|
||||
|
||||
/// The always-present root ("This PC") node.
|
||||
pub const ROOT_ID: NodeId = 0;
|
||||
|
||||
/// Lazily-loaded children of a node.
|
||||
enum Children {
|
||||
/// Not fetched yet. We show an expand chevron optimistically — most folders
|
||||
/// do contain subfolders, and finding out for sure would cost a scan.
|
||||
Unloaded,
|
||||
/// A fetch is in flight.
|
||||
Loading,
|
||||
/// Fetched. An empty vec is a *confirmed* leaf, so its chevron disappears.
|
||||
Loaded(Vec<TreeNode>),
|
||||
}
|
||||
|
||||
/// One node in the folder tree.
|
||||
struct TreeNode {
|
||||
id: NodeId,
|
||||
label: String,
|
||||
icon: IconKey,
|
||||
/// Where clicking the node navigates the main view.
|
||||
location: Location,
|
||||
expanded: bool,
|
||||
children: Children,
|
||||
}
|
||||
|
||||
/// The data needed to create a child node, produced by an (async) load. Kept
|
||||
/// free of [`NodeId`] so the loader doesn't need the id allocator — ids are
|
||||
/// assigned when the children are grafted into the tree.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TreeChild {
|
||||
pub label: String,
|
||||
pub icon: IconKey,
|
||||
pub location: Location,
|
||||
}
|
||||
|
||||
/// A flattened, render-ready view of one visible node. Borrows from the tree.
|
||||
pub struct TreeRow<'a> {
|
||||
pub id: NodeId,
|
||||
pub label: &'a str,
|
||||
pub icon: &'a IconKey,
|
||||
pub location: &'a Location,
|
||||
/// Indentation level; the root is 0.
|
||||
pub depth: usize,
|
||||
pub expanded: bool,
|
||||
/// Whether to draw an expand/collapse chevron (false for confirmed leaves).
|
||||
pub expandable: bool,
|
||||
}
|
||||
|
||||
/// The outcome of a [`Tree::reveal`] step.
|
||||
pub enum Reveal {
|
||||
/// To continue revealing, the children of this node must be loaded first.
|
||||
Load(NodeId, Location),
|
||||
/// A load is already in flight along the path; wait for it to complete.
|
||||
Wait,
|
||||
/// Revealing is finished — the target was reached, or can't be reached.
|
||||
Stop,
|
||||
}
|
||||
|
||||
/// The folder tree and its node-id allocator.
|
||||
pub struct Tree {
|
||||
root: TreeNode,
|
||||
next_id: NodeId,
|
||||
}
|
||||
|
||||
impl Tree {
|
||||
/// A fresh tree whose root is "This PC", expanded and awaiting its child
|
||||
/// load (drives + known folders). The app kicks that load off at startup.
|
||||
pub fn new() -> Self {
|
||||
let root = TreeNode {
|
||||
id: ROOT_ID,
|
||||
label: "This PC".to_string(),
|
||||
icon: IconKey::Folder,
|
||||
location: Location::ThisPc,
|
||||
expanded: true,
|
||||
children: Children::Loading,
|
||||
};
|
||||
Self {
|
||||
root,
|
||||
next_id: ROOT_ID + 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Flatten the expanded nodes into render rows, depth-first.
|
||||
pub fn visible_rows(&self) -> Vec<TreeRow<'_>> {
|
||||
let mut rows = Vec::new();
|
||||
push_rows(&self.root, 0, &mut rows);
|
||||
rows
|
||||
}
|
||||
|
||||
/// Append the icon keys of every visible node, so the app can request them
|
||||
/// from the shared icon cache alongside the main list's.
|
||||
pub fn collect_icon_keys(&self, out: &mut Vec<IconKey>) {
|
||||
collect_keys(&self.root, out);
|
||||
}
|
||||
|
||||
/// Expand or collapse the node `id`. Returns the node and location to load
|
||||
/// when expanding a not-yet-loaded node, otherwise `None` (collapse, or
|
||||
/// children already present).
|
||||
pub fn toggle(&mut self, id: NodeId) -> Option<(NodeId, Location)> {
|
||||
let node = find_mut(&mut self.root, id)?;
|
||||
if node.expanded {
|
||||
node.expanded = false;
|
||||
None
|
||||
} else {
|
||||
node.expanded = true;
|
||||
if matches!(node.children, Children::Unloaded) {
|
||||
node.children = Children::Loading;
|
||||
Some((node.id, node.location.clone()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Graft freshly-loaded `children` onto node `id`, assigning each a new id.
|
||||
/// A no-op if the node has since vanished. An empty list marks a leaf.
|
||||
pub fn set_children(&mut self, id: NodeId, children: Vec<TreeChild>) {
|
||||
// Allocate ids first (borrows `self.next_id`), then locate the node.
|
||||
let nodes = self.build_nodes(children);
|
||||
if let Some(node) = find_mut(&mut self.root, id) {
|
||||
node.children = Children::Loaded(nodes);
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk from the root toward `target`, expanding each ancestor so the target
|
||||
/// becomes visible. One step: if an ancestor's children aren't loaded yet it
|
||||
/// returns [`Reveal::Load`]; call again once that load lands to continue.
|
||||
pub fn reveal(&mut self, target: &Path) -> Reveal {
|
||||
reveal_node(&mut self.root, target)
|
||||
}
|
||||
|
||||
fn build_nodes(&mut self, children: Vec<TreeChild>) -> Vec<TreeNode> {
|
||||
children
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
TreeNode {
|
||||
id,
|
||||
label: c.label,
|
||||
icon: c.icon,
|
||||
location: c.location,
|
||||
expanded: false,
|
||||
children: Children::Unloaded,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Tree {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn push_rows<'a>(node: &'a TreeNode, depth: usize, out: &mut Vec<TreeRow<'a>>) {
|
||||
let expandable = match &node.children {
|
||||
Children::Unloaded | Children::Loading => true,
|
||||
Children::Loaded(children) => !children.is_empty(),
|
||||
};
|
||||
out.push(TreeRow {
|
||||
id: node.id,
|
||||
label: &node.label,
|
||||
icon: &node.icon,
|
||||
location: &node.location,
|
||||
depth,
|
||||
expanded: node.expanded,
|
||||
expandable,
|
||||
});
|
||||
if node.expanded
|
||||
&& let Children::Loaded(children) = &node.children
|
||||
{
|
||||
for child in children {
|
||||
push_rows(child, depth + 1, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_keys(node: &TreeNode, out: &mut Vec<IconKey>) {
|
||||
out.push(node.icon.clone());
|
||||
if node.expanded
|
||||
&& let Children::Loaded(children) = &node.children
|
||||
{
|
||||
for child in children {
|
||||
collect_keys(child, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_mut(node: &mut TreeNode, id: NodeId) -> Option<&mut TreeNode> {
|
||||
if node.id == id {
|
||||
return Some(node);
|
||||
}
|
||||
if let Children::Loaded(children) = &mut node.children {
|
||||
for child in children {
|
||||
if let Some(found) = find_mut(child, id) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn reveal_node(node: &mut TreeNode, target: &Path) -> Reveal {
|
||||
// Reached the target: it's already a node, leave it for the caller to
|
||||
// select. Don't force-expand it — we only reveal, not open.
|
||||
if node.location.as_path() == Some(target) {
|
||||
return Reveal::Stop;
|
||||
}
|
||||
// This node is an ancestor of the target; expand it and descend.
|
||||
node.expanded = true;
|
||||
match &mut node.children {
|
||||
Children::Unloaded => {
|
||||
node.children = Children::Loading;
|
||||
Reveal::Load(node.id, node.location.clone())
|
||||
}
|
||||
Children::Loading => Reveal::Wait,
|
||||
Children::Loaded(children) => match best_child_index(children, target) {
|
||||
Some(i) => reveal_node(&mut children[i], target),
|
||||
None => Reveal::Stop, // target not under any child (hidden, gone, …)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Among `children`, the index of the one whose path is the longest prefix of
|
||||
/// `target` (the deepest ancestor to descend into). `starts_with` is
|
||||
/// component-wise, so `C:\Users` matches `C:\Users\me` but not `C:\UsersX`.
|
||||
fn best_child_index(children: &[TreeNode], target: &Path) -> Option<usize> {
|
||||
children
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, c)| c.location.as_path().map(|p| (i, p)))
|
||||
.filter(|(_, p)| target.starts_with(p))
|
||||
.max_by_key(|(_, p)| p.components().count())
|
||||
.map(|(i, _)| i)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn child(label: &str, path: &str) -> TreeChild {
|
||||
TreeChild {
|
||||
label: label.to_string(),
|
||||
icon: IconKey::Folder,
|
||||
location: Location::Path(PathBuf::from(path)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a visible row by label, for assertions.
|
||||
fn row_id(tree: &Tree, label: &str) -> NodeId {
|
||||
tree.visible_rows()
|
||||
.iter()
|
||||
.find(|r| r.label == label)
|
||||
.unwrap_or_else(|| panic!("no visible row labelled {label}"))
|
||||
.id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_is_visible_and_loading() {
|
||||
let tree = Tree::new();
|
||||
let rows = tree.visible_rows();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].label, "This PC");
|
||||
assert!(rows[0].expanded);
|
||||
// Loading counts as expandable so the chevron shows immediately.
|
||||
assert!(rows[0].expandable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_children_makes_them_visible_under_expanded_root() {
|
||||
let mut tree = Tree::new();
|
||||
tree.set_children(ROOT_ID, vec![child("C:", "C:\\"), child("D:", "D:\\")]);
|
||||
let labels: Vec<&str> = tree.visible_rows().iter().map(|r| r.label).collect();
|
||||
assert_eq!(labels, ["This PC", "C:", "D:"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_are_unique_per_node() {
|
||||
let mut tree = Tree::new();
|
||||
tree.set_children(ROOT_ID, vec![child("C:", "C:\\")]);
|
||||
let c = row_id(&tree, "C:");
|
||||
tree.toggle(c); // expand so its children become visible
|
||||
tree.set_children(c, vec![child("Users", "C:\\Users")]);
|
||||
let users = row_id(&tree, "Users");
|
||||
assert_ne!(c, users);
|
||||
assert_ne!(ROOT_ID, c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_collapses_and_re_expands_without_reloading() {
|
||||
let mut tree = Tree::new();
|
||||
tree.set_children(ROOT_ID, vec![child("C:", "C:\\")]);
|
||||
let c = row_id(&tree, "C:");
|
||||
tree.toggle(c); // expand C: (its first expansion requests a load)
|
||||
tree.set_children(c, vec![child("Users", "C:\\Users")]);
|
||||
assert_eq!(tree.visible_rows().len(), 3); // This PC, C:, Users
|
||||
|
||||
// Collapsing C: hides Users; it requests no reload.
|
||||
assert!(tree.toggle(c).is_none());
|
||||
assert_eq!(tree.visible_rows().len(), 2);
|
||||
|
||||
// Re-expanding shows it again with no reload (children cached).
|
||||
assert!(tree.toggle(c).is_none());
|
||||
assert_eq!(tree.visible_rows().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_unloaded_node_requests_its_location() {
|
||||
let mut tree = Tree::new();
|
||||
tree.set_children(ROOT_ID, vec![child("C:", "C:\\")]);
|
||||
let c = row_id(&tree, "C:");
|
||||
// C: was just added Unloaded and collapsed; expanding asks to load it.
|
||||
match tree.toggle(c) {
|
||||
Some((id, Location::Path(p))) => {
|
||||
assert_eq!(id, c);
|
||||
assert_eq!(p, PathBuf::from("C:\\"));
|
||||
}
|
||||
other => panic!("expected a load request for C:\\, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_children_make_a_leaf() {
|
||||
let mut tree = Tree::new();
|
||||
tree.set_children(ROOT_ID, vec![child("C:", "C:\\")]);
|
||||
let c = row_id(&tree, "C:");
|
||||
tree.toggle(c); // expand
|
||||
tree.set_children(c, Vec::new()); // …discovers it has no subfolders
|
||||
let row = tree
|
||||
.visible_rows()
|
||||
.into_iter()
|
||||
.find(|r| r.id == c)
|
||||
.unwrap();
|
||||
assert!(!row.expandable, "a confirmed-empty node shows no chevron");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reveal_loads_ancestors_then_stops_at_target() {
|
||||
let mut tree = Tree::new();
|
||||
tree.set_children(ROOT_ID, vec![child("C:", "C:\\"), child("D:", "D:\\")]);
|
||||
let target = PathBuf::from("C:\\Users\\me");
|
||||
|
||||
// First step: C: isn't loaded, so reveal asks to load it (not D:).
|
||||
match tree.reveal(&target) {
|
||||
Reveal::Load(_, Location::Path(p)) => assert_eq!(p, PathBuf::from("C:\\")),
|
||||
_ => panic!("expected to load C:\\"),
|
||||
}
|
||||
|
||||
// C:'s children arrive; next step descends and asks to load C:\Users.
|
||||
let c = row_id(&tree, "C:");
|
||||
tree.set_children(c, vec![child("Users", "C:\\Users")]);
|
||||
match tree.reveal(&target) {
|
||||
Reveal::Load(_, Location::Path(p)) => assert_eq!(p, PathBuf::from("C:\\Users")),
|
||||
_ => panic!("expected to load C:\\Users"),
|
||||
}
|
||||
|
||||
// C:\Users's children arrive (including the target); reveal completes.
|
||||
let users = row_id(&tree, "Users");
|
||||
tree.set_children(users, vec![child("me", "C:\\Users\\me")]);
|
||||
assert!(matches!(tree.reveal(&target), Reveal::Stop));
|
||||
|
||||
// The whole chain is now visible.
|
||||
let labels: Vec<&str> = tree.visible_rows().iter().map(|r| r.label).collect();
|
||||
assert_eq!(labels, ["This PC", "C:", "Users", "me", "D:"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reveal_picks_the_deepest_matching_branch() {
|
||||
// The same folder reachable two ways: a known-folder shortcut and the
|
||||
// real parent chain. The longest prefix (the shortcut) is chosen.
|
||||
let mut tree = Tree::new();
|
||||
tree.set_children(
|
||||
ROOT_ID,
|
||||
vec![
|
||||
child("C:", "C:\\"),
|
||||
child("Documents", "C:\\Users\\me\\Documents"),
|
||||
],
|
||||
);
|
||||
let target = PathBuf::from("C:\\Users\\me\\Documents\\Work");
|
||||
match tree.reveal(&target) {
|
||||
Reveal::Load(_, Location::Path(p)) => {
|
||||
assert_eq!(p, PathBuf::from("C:\\Users\\me\\Documents"));
|
||||
}
|
||||
_ => panic!("expected to descend via the Documents shortcut"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reveal_gives_up_when_target_is_absent() {
|
||||
let mut tree = Tree::new();
|
||||
tree.set_children(ROOT_ID, vec![child("C:", "C:\\")]);
|
||||
let c = row_id(&tree, "C:");
|
||||
tree.set_children(c, Vec::new()); // C: has no listed subfolders
|
||||
// Target claims to be under C: but no child matches → give up cleanly.
|
||||
assert!(matches!(
|
||||
tree.reveal(&PathBuf::from("C:\\Hidden\\x")),
|
||||
Reveal::Stop
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,34 @@ pub fn read_dir_all(dir: &Path) -> io::Result<Vec<Entry>> {
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
/// Enumerate only the *subdirectories* of `dir`, skipping files.
|
||||
///
|
||||
/// This backs the navigation tree, which only ever shows folders. Skipping
|
||||
/// files during enumeration keeps it light on directories with many files —
|
||||
/// the whole point of the tree being cheap to expand. Like [`read_dir_all`],
|
||||
/// metadata is read from the cached enumeration data, so there's no extra
|
||||
/// `stat` per entry.
|
||||
pub fn read_subdirs(dir: &Path) -> io::Result<Vec<Entry>> {
|
||||
let mut dirs = Vec::new();
|
||||
for dirent in fs::read_dir(dir)? {
|
||||
let Ok(dirent) = dirent else { continue };
|
||||
let Ok(meta) = dirent.metadata() else { continue };
|
||||
if !meta.is_dir() {
|
||||
continue;
|
||||
}
|
||||
dirs.push(Entry {
|
||||
name: dirent.file_name().to_string_lossy().into_owned(),
|
||||
path: dirent.path(),
|
||||
kind: EntryKind::Directory,
|
||||
size: 0,
|
||||
modified: meta.modified().ok(),
|
||||
created: meta.created().ok(),
|
||||
attrs: Attributes::from_raw(meta.file_attributes()),
|
||||
});
|
||||
}
|
||||
Ok(dirs)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -111,6 +139,25 @@ mod tests {
|
||||
fs::remove_dir_all(&tmp).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_subdirs_returns_only_directories() {
|
||||
let tmp = std::env::temp_dir().join(format!("librarian_subdirs_{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
fs::create_dir_all(tmp.join("alpha")).unwrap();
|
||||
fs::create_dir_all(tmp.join("beta")).unwrap();
|
||||
File::create(tmp.join("file.txt")).unwrap();
|
||||
|
||||
let mut dirs = read_subdirs(&tmp).unwrap();
|
||||
dirs.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
|
||||
assert_eq!(dirs.len(), 2, "files must be excluded");
|
||||
assert_eq!(dirs[0].name, "alpha");
|
||||
assert_eq!(dirs[1].name, "beta");
|
||||
assert!(dirs.iter().all(|d| d.is_dir()));
|
||||
|
||||
fs::remove_dir_all(&tmp).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batching_breaks_early_on_request() {
|
||||
let tmp = std::env::temp_dir().join(format!("librarian_break_{}", std::process::id()));
|
||||
|
||||
@@ -11,7 +11,7 @@ pub mod history;
|
||||
pub mod model;
|
||||
pub mod sort;
|
||||
|
||||
pub use enumerate::{read_dir_all, read_dir_batched, DEFAULT_BATCH};
|
||||
pub use enumerate::{read_dir_all, read_dir_batched, read_subdirs, DEFAULT_BATCH};
|
||||
pub use history::History;
|
||||
pub use model::{Attributes, Entry, EntryKind, Location};
|
||||
pub use sort::{is_visible, sort_entries, Sort, SortKey, SortOrder};
|
||||
|
||||
Reference in New Issue
Block a user