visual fixes, WIP

This commit is contained in:
2026-06-14 20:41:28 -05:00
parent b7fee9c772
commit fdea7fb5b2
7 changed files with 1078 additions and 20 deletions
+26
View File
@@ -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
+46
View File
@@ -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 }}
+217
View File
@@ -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)
}
}
+314 -19
View File
@@ -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";
+427
View File
@@ -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
));
}
}
+47
View File
@@ -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()));
+1 -1
View File
@@ -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};