diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..372a318 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e88d728 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/crates/librarian-app/src/ellipsis.rs b/crates/librarian-app/src/ellipsis.rs new file mode 100644 index 0000000..5c6bd86 --- /dev/null +++ b/crates/librarian-app/src/ellipsis.rs @@ -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) -> 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, + 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) -> Self { + self.width = width.into(); + self + } + + /// Sets the text size; defaults to the renderer's default. + pub fn size(mut self, size: impl Into) -> 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) -> 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(&self) -> text_widget::Format { + 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 = text_widget::State<::Paragraph>; + +impl Widget for Ellipsized +where + Renderer: text::Renderer, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::>() + } + + fn state(&self) -> tree::State { + tree::State::new(State::::default()) + } + + fn size(&self) -> Size { + 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::>(); + let format = self.format::(); + + // 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::(&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::>(); + 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( + content: &str, + avail: f32, + renderer: &Renderer, + format: text_widget::Format, +) -> 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 { + ::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 for Element<'a, Message, Theme, Renderer> +where + Renderer: text::Renderer + 'a, + Theme: 'a, + Message: 'a, +{ + fn from(widget: Ellipsized) -> Self { + Element::new(widget) + } +} diff --git a/crates/librarian-app/src/main.rs b/crates/librarian-app/src/main.rs index 77bde9f..5ceb5d3 100644 --- a/crates/librarian-app/src/main.rs +++ b/crates/librarian-app/src/main.rs @@ -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, + /// A path the tree is mid-way through revealing (expanding ancestors to); + /// re-driven each time an intermediate child load completes. + pending_reveal: Option, content: Content, /// Precomputed, filtered+sorted display rows for the current `content`. rows: Vec, @@ -175,6 +202,15 @@ enum Message { /// Result of an in-place refresh that preserves selection and scroll. Reloaded(u64, Result, 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, 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) { 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 { @@ -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 { + 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 { + 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 { - let keys = self.rows.iter().map(|r| r.icon.clone()); + let mut keys: Vec = 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, 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"; diff --git a/crates/librarian-app/src/tree.rs b/crates/librarian-app/src/tree.rs new file mode 100644 index 0000000..18d86e1 --- /dev/null +++ b/crates/librarian-app/src/tree.rs @@ -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), +} + +/// 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> { + 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) { + 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) { + // 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) -> Vec { + 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>) { + 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) { + 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 { + 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 + )); + } +} diff --git a/crates/librarian-core/src/enumerate.rs b/crates/librarian-core/src/enumerate.rs index fd14a8a..1b2ab10 100644 --- a/crates/librarian-core/src/enumerate.rs +++ b/crates/librarian-core/src/enumerate.rs @@ -86,6 +86,34 @@ pub fn read_dir_all(dir: &Path) -> io::Result> { 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> { + 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())); diff --git a/crates/librarian-core/src/lib.rs b/crates/librarian-core/src/lib.rs index 4ea1090..bec0719 100644 --- a/crates/librarian-core/src/lib.rs +++ b/crates/librarian-core/src/lib.rs @@ -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};