diff --git a/Cargo.lock b/Cargo.lock index a250ba8..7d90fbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1934,6 +1934,7 @@ dependencies = [ "librarian-core", "librarian-win", "notify-debouncer-mini", + "winresource", ] [[package]] @@ -3374,6 +3375,15 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "shlex" version = "2.0.1" @@ -3767,6 +3777,21 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -3797,6 +3822,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tracing" version = "0.1.44" @@ -4791,6 +4822,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winresource" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +dependencies = [ + "toml", + "version_check", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/crates/librarian-app/Cargo.toml b/crates/librarian-app/Cargo.toml index 58ff9e6..2de0661 100644 --- a/crates/librarian-app/Cargo.toml +++ b/crates/librarian-app/Cargo.toml @@ -15,3 +15,8 @@ librarian-win.workspace = true iced.workspace = true chrono = "0.4" notify-debouncer-mini = "0.7.0" + +# Embeds the application icon as a Win32 resource (so the .exe shows it in +# Explorer / on shortcuts). Windows-only; see build.rs. +[target.'cfg(windows)'.build-dependencies] +winresource = "0.1" diff --git a/crates/librarian-app/build.rs b/crates/librarian-app/build.rs new file mode 100644 index 0000000..287ce7a --- /dev/null +++ b/crates/librarian-app/build.rs @@ -0,0 +1,35 @@ +//! Build script: embed the application icon as a Win32 resource on Windows, so +//! the icon shows on the `.exe` itself in Explorer and on shortcuts. +//! +//! This is independent of the in-app window/taskbar icon, which `main.rs` sets +//! at runtime via winit. A `.exe` resource icon, by contrast, is baked into the +//! PE binary at link time — which is what this does. + +fn main() { + // The resource format is PE-specific, so only do this when *targeting* + // Windows (`CARGO_CFG_TARGET_OS` reflects the target, not the host, so this + // stays correct under cross-compilation). + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { + return; + } + + // The icon lives at the repository root, two levels up from this crate. + // Build an absolute path (joining component-by-component keeps native + // separators) so it resolves regardless of the resource compiler's cwd. + let manifest = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let icon = manifest + .join("..") + .join("..") + .join("icon") + .join("librarian.ico"); + println!("cargo:rerun-if-changed={}", icon.display()); + + let mut res = winresource::WindowsResource::new(); + res.set_icon(icon.to_str().expect("icon path is valid UTF-8")); + if let Err(e) = res.compile() { + // Don't fail the build if the resource compiler (rc.exe / windres) is + // unavailable — the app still runs and keeps its runtime window icon. + // The .exe just won't carry an embedded icon in this build. + println!("cargo:warning=could not embed exe icon resource: {e}"); + } +} diff --git a/crates/librarian-app/src/ellipsis.rs b/crates/librarian-app/src/ellipsis.rs index 5c6bd86..01c5a04 100644 --- a/crates/librarian-app/src/ellipsis.rs +++ b/crates/librarian-app/src/ellipsis.rs @@ -15,8 +15,8 @@ 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::advanced::widget::{Tree, tree}; +use iced::advanced::{Layout, Widget, layout, mouse, renderer, text}; use iced::{Element, Length, Pixels, Rectangle, Size}; /// Create an ellipsizing single-line text cell with the given content. diff --git a/crates/librarian-app/src/icons.rs b/crates/librarian-app/src/icons.rs index 9cc1bc1..c318491 100644 --- a/crates/librarian-app/src/icons.rs +++ b/crates/librarian-app/src/icons.rs @@ -9,13 +9,17 @@ use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use iced::widget::image::Handle; -use librarian_win::{folder_icon, icon_for_extension, icon_for_path, IconImage, ShellWorker}; +use librarian_win::{ + IconImage, ShellWorker, computer_icon, folder_icon, icon_for_extension, icon_for_path, +}; /// What an icon represents. `Path` is used for things with a per-item icon /// (drives, and later custom-icon files); `Folder`/`Ext` are generic and shared. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum IconKey { Folder, + /// The shell's "This PC" / Computer icon (the drives container node). + Computer, Ext(String), Path(PathBuf), } @@ -57,6 +61,7 @@ pub fn extract_icons(worker: &ShellWorker, keys: Vec) -> Vec<(IconKey, .filter_map(|key| { let image = match &key { IconKey::Folder => worker.run(|apt| folder_icon(apt, false)), + IconKey::Computer => worker.run(|apt| computer_icon(apt, false)), IconKey::Ext(ext) => { let ext = ext.clone(); worker.run(move |apt| icon_for_extension(apt, &ext, false)) diff --git a/crates/librarian-app/src/main.rs b/crates/librarian-app/src/main.rs index b5dcfd5..b6945aa 100644 --- a/crates/librarian-app/src/main.rs +++ b/crates/librarian-app/src/main.rs @@ -14,23 +14,23 @@ 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, pane_grid, row, scrollable, stack, - text, text_input, Space, + Space, button, checkbox, column, container, image, mouse_area, pane_grid, row, scrollable, + stack, text, text_input, }; use iced::{Border, Center, Element, Length::Fill, Point, Size, Subscription, Task, Theme}; use librarian_core::{ - is_visible, read_dir_all, read_subdirs, sort_entries, Entry, History, Location, Sort, SortKey, - SortOrder, + Entry, History, Location, Sort, SortKey, SortOrder, is_visible, read_dir_all, read_subdirs, + sort_entries, }; use librarian_win::{ - copy_items, create_folder, delete_to_recycle, known_folders, list_drives, move_items, rename, - Apartment, DriveInfo, IconImage, KnownFolder, ShellWorker, + Apartment, DriveInfo, IconImage, KnownFolder, ShellWorker, copy_items, create_folder, + delete_to_recycle, known_folders, list_drives, move_items, rename, user_home, }; use ellipsis::ellipsized; -use icons::{extract_icons, IconCache, IconKey}; -use rows::{format_time, human_size, Row}; +use icons::{IconCache, IconKey, extract_icons}; +use rows::{Row, format_time, human_size}; use selection::Selection; use tree::{Reveal, Tree, TreeChild, TreeRow}; @@ -78,11 +78,26 @@ fn main() -> iced::Result { .title("Librarian") .theme(Librarian::theme) .subscription(Librarian::subscription) + // Set the window icon before `window_size`/`centered`, which preserve the + // other window fields they don't touch. + .window(iced::window::Settings { + icon: window_icon(), + ..Default::default() + }) .window_size(Size::new(1100.0, 720.0)) .centered() .run() } +/// The window/taskbar icon, embedded at compile time so the executable stays +/// self-contained and relocatable (no external icon file at runtime). The format +/// is guessed from the bytes. A decode failure is non-fatal — the app falls back +/// to the platform default icon rather than failing to launch. +fn window_icon() -> Option { + let data = include_bytes!("../../../icon/librarian.ico"); + iced::window::icon::from_file_data(data, None).ok() +} + /// The cut/copy buffer for in-app clipboard operations. struct Clip { paths: Vec, @@ -125,8 +140,11 @@ enum PaneKind { /// What the file list is currently showing. enum Content { ThisPc { + // Only system drives (and, later, mapped network locations) belong in + // the "This PC" landing list — not the user's known folders. Those + // remain reachable through the navigation tree, which loads them + // separately. drives: Vec, - folders: Vec, }, Folder { entries: Vec, @@ -205,7 +223,7 @@ enum Message { MoveSelection(Nav, bool, bool), SelectAll, Activate, - ThisPcLoaded(Vec, Vec), + ThisPcLoaded(Vec), Loaded(u64, Result, String>), /// The current directory changed on disk; re-enumerate it in place. DirChanged, @@ -250,10 +268,7 @@ impl Librarian { tree: Tree::new(), panes, pending_reveal: None, - content: Content::ThisPc { - drives: Vec::new(), - folders: Vec::new(), - }, + content: Content::ThisPc { drives: Vec::new() }, rows: Vec::new(), sort: settings.sort, show_hidden: settings.show_hidden, @@ -273,10 +288,10 @@ impl Librarian { scroll_y: 0.0, viewport_h: 720.0 - CHROME_HEIGHT, }; - // Load the starting location, populate the tree root (drives + known - // folders), and theme the window — all in parallel. + // Load the starting location, populate the tree's top level (the user's + // folders + a "This PC" node), and theme the window — all in parallel. let load = app.load_current(); - let tree_load = app.load_tree_children(tree::ROOT_ID, Location::ThisPc); + let tree_load = app.load_tree_roots(); (app, Task::batch([apply_chrome(), load, tree_load])) } @@ -365,8 +380,8 @@ impl Librarian { config::save(&self.settings()); } Message::RowClicked(index) => return self.on_click(index), - Message::ThisPcLoaded(drives, folders) => { - self.content = Content::ThisPc { drives, folders }; + Message::ThisPcLoaded(drives) => { + self.content = Content::ThisPc { drives }; self.recompute_rows(); self.status = format!("{} items", self.rows.len()); return self.request_icons(); @@ -450,11 +465,22 @@ impl Librarian { // resolves rather than hanging. let children = result.unwrap_or_default(); self.tree.set_children(id, children); + // When the top level lands, auto-expand "This PC" so the drives + // show without a manual click (it starts collapsed otherwise). + let expand_pc = if id == tree::ROOT_ID { + self.tree + .this_pc_id() + .and_then(|pc| self.tree.toggle(pc)) + .map(|(load_id, location)| self.load_tree_children(load_id, location)) + .unwrap_or_else(Task::none) + } else { + Task::none() + }; // 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]); + return Task::batch([icons, expand_pc, reveal]); } Message::PaneResized(event) => { self.panes.resize(event.split, event.ratio); @@ -635,16 +661,9 @@ impl Librarian { let load = match location { Location::ThisPc => { - self.content = Content::ThisPc { - drives: Vec::new(), - folders: Vec::new(), - }; + self.content = Content::ThisPc { drives: Vec::new() }; self.rows.clear(); - let worker = self.worker.clone(); - Task::perform( - offload(move || (list_drives(), worker.run(|_| known_folders()))), - |(drives, folders)| Message::ThisPcLoaded(drives, folders), - ) + Task::perform(offload(list_drives), Message::ThisPcLoaded) } Location::Path(path) => { self.load_token += 1; @@ -756,12 +775,15 @@ impl Librarian { if paths.is_empty() { return; } - let wanted: std::collections::HashSet<&Path> = - paths.iter().map(PathBuf::as_path).collect(); - let indices = self.rows.iter().enumerate().filter_map(|(i, row)| match &row.target { - Location::Path(path) if wanted.contains(path.as_path()) => Some(i), - _ => None, - }); + let wanted: std::collections::HashSet<&Path> = paths.iter().map(PathBuf::as_path).collect(); + let indices = self + .rows + .iter() + .enumerate() + .filter_map(|(i, row)| match &row.target { + Location::Path(path) if wanted.contains(path.as_path()) => Some(i), + _ => None, + }); self.selection.set_many(indices); } @@ -824,7 +846,10 @@ impl Librarian { self.scroll_y = y; iced::widget::operation::scroll_to( LIST_ID, - scrollable::AbsoluteOffset { x: None, y: Some(y) }, + scrollable::AbsoluteOffset { + x: None, + y: Some(y), + }, ) } @@ -842,13 +867,22 @@ impl Librarian { // --- 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 { + /// Load the tree's top-level nodes (the user's folders + a "This PC" node) + /// onto the hidden root, on a worker thread. + fn load_tree_roots(&self) -> Task { let worker = self.worker.clone(); + Task::perform( + offload(move || Ok(fetch_tree_roots(&worker))), + move |result| Message::TreeChildrenLoaded(tree::ROOT_ID, result), + ) + } + + /// Load the children of a tree node on a worker thread: the drives for the + /// "This PC" node, or the subdirectories of a real folder. + fn load_tree_children(&self, id: tree::NodeId, location: Location) -> Task { let show_hidden = self.show_hidden; Task::perform( - offload(move || fetch_tree_children(&worker, &location, show_hidden)), + offload(move || fetch_tree_children(&location, show_hidden)), move |result| Message::TreeChildrenLoaded(id, result), ) } @@ -912,12 +946,8 @@ impl Librarian { /// Rebuild `rows` from `content`, applying the current filter and sort. fn recompute_rows(&mut self) { self.rows = match &self.content { - Content::ThisPc { drives, folders } => { - let mut rows: Vec = drives - .iter() - .map(rows::row_from_drive) - .chain(folders.iter().map(rows::row_from_known)) - .collect(); + Content::ThisPc { drives } => { + let mut rows: Vec = drives.iter().map(rows::row_from_drive).collect(); if !self.filter.is_empty() { let needle = self.filter.to_lowercase(); rows.retain(|r| r.label.to_lowercase().contains(&needle)); @@ -991,7 +1021,13 @@ impl Librarian { fn view_tree(&self) -> Element<'_, Message> { let rows = self.tree.visible_rows(); let mut list = column![].width(Fill); - for row in &rows { + for (i, row) in rows.iter().enumerate() { + // Separate the user's folders (above) from the "This PC" drives + // section (below) with a divider. Skip it when "This PC" is the very + // first row, so we never lead with a stray rule. + if i > 0 && matches!(row.location, Location::ThisPc) { + list = list.push(tree_section_divider()); + } list = list.push(self.view_tree_row(row)); } let scroll = scrollable(list).height(Fill); @@ -1025,9 +1061,12 @@ impl Librarian { 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), + 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) @@ -1093,7 +1132,10 @@ impl Librarian { }; row![ nav("←", self.history.can_go_back().then_some(Message::GoBack)), - nav("→", self.history.can_go_forward().then_some(Message::GoForward)), + nav( + "→", + self.history.can_go_forward().then_some(Message::GoForward) + ), nav("↑", self.history.current().parent().map(|_| Message::GoUp)), nav("⟳", Some(Message::Refresh)), text_input("Path", &self.address) @@ -1289,7 +1331,12 @@ fn view_header(sort: Sort) -> Element<'static, Message> { // still line up with the rows below. row![ heading("Name", SortKey::Name, Fill, Horizontal::Left), - heading("Date modified", SortKey::Modified, 150.0.into(), 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), @@ -1360,6 +1407,26 @@ fn tree_row_button_style(theme: &Theme, status: button::Status, selected: bool) style } +/// A thin horizontal divider between the folder tree's sections (the user's +/// folders above, the "This PC" drives section below). Inset from the pane edges +/// so it reads as a subtle separator rather than a hard border. +fn tree_section_divider() -> Element<'static, Message> { + let line = + container(Space::new().width(Fill).height(1.0)).style(|theme: &Theme| container::Style { + background: Some(theme.extended_palette().background.strong.color.into()), + ..container::Style::default() + }); + container(line) + .padding(iced::Padding { + top: 4.0, + right: 10.0, + bottom: 4.0, + left: 10.0, + }) + .width(Fill) + .into() +} + /// One clickable row in the context menu. fn menu_item(label: &str, message: Message) -> Element<'static, Message> { button(text(label.to_string()).size(13)) @@ -1405,60 +1472,78 @@ 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> { +/// The top-level nodes of the folder tree: the user's home folder (with the +/// known user folders nested inside, shown expanded), then a "This PC" node that +/// holds the drives. Mirrors Windows 11's nav pane. +fn fetch_tree_roots(worker: &ShellWorker) -> Vec { + let folders = worker.run(|_| known_folders()); + let home = worker.run(|_| user_home()); + let known: Vec = folders.iter().map(tree_child_from_known).collect(); + + let mut roots = Vec::new(); + match home { + Some(home) => roots.push(home_tree_child(home, known)), + // If the home folder can't be resolved, list the known folders at the + // top level rather than dropping them entirely. + None => roots.extend(known), + } + roots.push(this_pc_tree_child()); + roots +} + +/// The user's home-folder node, labelled with the home folder's name (the +/// account folder, e.g. `Alice`) and carrying the known user folders nested +/// inside, shown expanded. Uses the home folder's real shell icon. +fn home_tree_child(home: PathBuf, folders: Vec) -> TreeChild { + let label = home + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "Home".to_string()); + TreeChild::branch( + label, + IconKey::Path(home.clone()), + Location::Path(home), + folders, + ) +} + +/// Load the children for a folder-tree node: the drives under the "This PC" +/// node, or the (visible) subdirectories of a real folder, sorted by name. Runs +/// on a worker thread via [`offload`]. (Neither path needs the COM worker — the +/// top-level known folders that do are loaded by [`fetch_tree_roots`].) +fn fetch_tree_children(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) - } + // "This PC" now contains only the system drives; the user's folders are + // their own top-level nodes (see [`fetch_tree_roots`]). + Location::ThisPc => Ok(list_drives().iter().map(tree_child_from_drive).collect()), 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), - }) + .map(|e| TreeChild::lazy(e.name, IconKey::Folder, Location::Path(e.path))) .collect(); Ok(children) } } } +/// The standalone "This PC" tree node — an expandable container for the drives. +fn this_pc_tree_child() -> TreeChild { + TreeChild::lazy("This PC".to_string(), IconKey::Computer, Location::ThisPc) +} + /// 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, - } + TreeChild::lazy(row.label, row.icon, 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, - } + TreeChild::lazy(row.label, row.icon, row.target) } /// A non-colliding "New folder" name in `dir`, matching Explorer's scheme. @@ -1509,11 +1594,7 @@ fn key_to_message(key: Key, modifiers: keyboard::Modifiers) -> Option { /// `""` for a count of 1, `"s"` otherwise — for pluralizing status text. fn plural(count: usize) -> &'static str { - if count == 1 { - "" - } else { - "s" - } + if count == 1 { "" } else { "s" } } /// A stream that emits [`Message::DirChanged`] whenever `path`'s direct @@ -1529,42 +1610,45 @@ fn plural(count: usize) -> &'static str { #[allow(clippy::ptr_arg)] fn watch_stream(path: &PathBuf) -> impl iced::futures::Stream + use<> { use iced::futures::{SinkExt, StreamExt}; - use notify_debouncer_mini::{new_debouncer, notify::RecursiveMode, DebounceEventResult}; + use notify_debouncer_mini::{DebounceEventResult, new_debouncer, notify::RecursiveMode}; let path = path.clone(); - iced::stream::channel(4, move |mut output: iced::futures::channel::mpsc::Sender| async move { - // The debouncer callback (sync, on notify's thread) pokes this channel; - // the async loop below drains it and forwards to the UI. - let (mut tx, mut rx) = iced::futures::channel::mpsc::channel::<()>(4); + iced::stream::channel( + 4, + move |mut output: iced::futures::channel::mpsc::Sender| async move { + // The debouncer callback (sync, on notify's thread) pokes this channel; + // the async loop below drains it and forwards to the UI. + let (mut tx, mut rx) = iced::futures::channel::mpsc::channel::<()>(4); - let mut debouncer = match new_debouncer( - Duration::from_millis(200), - move |res: DebounceEventResult| { - // Any non-empty batch of events means the directory changed. - if res.map(|events| !events.is_empty()).unwrap_or(false) { - let _ = tx.try_send(()); - } - }, - ) { - Ok(debouncer) => debouncer, - Err(_) => return, - }; + let mut debouncer = match new_debouncer( + Duration::from_millis(200), + move |res: DebounceEventResult| { + // Any non-empty batch of events means the directory changed. + if res.map(|events| !events.is_empty()).unwrap_or(false) { + let _ = tx.try_send(()); + } + }, + ) { + Ok(debouncer) => debouncer, + Err(_) => return, + }; - if debouncer - .watcher() - .watch(&path, RecursiveMode::NonRecursive) - .is_err() - { - return; - } - - while rx.next().await.is_some() { - if output.send(Message::DirChanged).await.is_err() { - break; // the app dropped this subscription + if debouncer + .watcher() + .watch(&path, RecursiveMode::NonRecursive) + .is_err() + { + return; } - } - // `debouncer` drops here, stopping its background watcher thread. - }) + + while rx.next().await.is_some() { + if output.send(Message::DirChanged).await.is_err() { + break; // the app dropped this subscription + } + } + // `debouncer` drops here, stopping its background watcher thread. + }, + ) } /// Apply the dark title bar / Mica backdrop to the main window once it exists. @@ -1614,7 +1698,10 @@ fn visible_window(scroll_y: f32, viewport_h: f32, total: usize) -> (usize, usize let first = (scroll_y.max(0.0) / ROW_HEIGHT).floor() as usize; let onscreen = (viewport_h.max(0.0) / ROW_HEIGHT).ceil() as usize + 1; let start = first.saturating_sub(OVERSCAN); - let end = first.saturating_add(onscreen).saturating_add(OVERSCAN).min(total); + let end = first + .saturating_add(onscreen) + .saturating_add(OVERSCAN) + .min(total); (start, end) } @@ -1655,6 +1742,13 @@ where mod tests { use super::*; + #[test] + fn embedded_window_icon_decodes() { + // The bundled .ico must actually decode (right path + ICO codec enabled), + // otherwise the window would silently fall back to the default icon. + assert!(window_icon().is_some(), "bundled window icon should decode"); + } + fn ctrl() -> keyboard::Modifiers { keyboard::Modifiers::CTRL } @@ -1677,7 +1771,10 @@ mod tests { key_to_message(ch("c"), ctrl()), Some(Message::Copy) )); - assert!(matches!(key_to_message(ch("x"), ctrl()), Some(Message::Cut))); + assert!(matches!( + key_to_message(ch("x"), ctrl()), + Some(Message::Cut) + )); assert!(matches!( key_to_message(ch("v"), ctrl()), Some(Message::Paste) diff --git a/crates/librarian-app/src/rows.rs b/crates/librarian-app/src/rows.rs index ef498c9..8015423 100644 --- a/crates/librarian-app/src/rows.rs +++ b/crates/librarian-app/src/rows.rs @@ -61,7 +61,10 @@ pub fn row_from_drive(drive: &DriveInfo) -> Row { pub fn row_from_known(folder: &KnownFolder) -> Row { Row { label: folder.name.to_string(), - icon: IconKey::Folder, + // Resolve the folder's real shell icon (Desktop/Documents/Downloads/… + // each have a distinct one in Explorer), like drives do, instead of the + // generic folder glyph. + icon: IconKey::Path(folder.path.clone()), is_container: true, target: Location::Path(folder.path.clone()), size: None, @@ -111,7 +114,9 @@ pub fn human_size(bytes: u64) -> String { /// Format a timestamp in local time (e.g. `2026-06-13 09:41`). pub fn format_time(time: SystemTime) -> String { let utc: DateTime = time.into(); - utc.with_timezone(&Local).format("%Y-%m-%d %H:%M").to_string() + utc.with_timezone(&Local) + .format("%Y-%m-%d %H:%M") + .to_string() } #[cfg(test)] diff --git a/crates/librarian-app/src/tree.rs b/crates/librarian-app/src/tree.rs index 18d86e1..10ad26b 100644 --- a/crates/librarian-app/src/tree.rs +++ b/crates/librarian-app/src/tree.rs @@ -55,6 +55,45 @@ pub struct TreeChild { pub label: String, pub icon: IconKey, pub location: Location, + /// Children to attach immediately. `None` means the node is loaded lazily on + /// first expand; `Some` (even empty) means its children are already known + /// and grafted along with it — used to nest a curated subtree (e.g. the + /// user's folders under their home node) rather than a raw directory listing. + pub children: Option>, + /// Whether the node starts expanded. Only meaningful alongside pre-attached + /// [`children`](Self::children); a lazy node can't show children it hasn't + /// loaded yet. + pub expanded: bool, +} + +impl TreeChild { + /// A node whose children are fetched lazily, the first time it's expanded. + pub fn lazy(label: String, icon: IconKey, location: Location) -> Self { + Self { + label, + icon, + location, + children: None, + expanded: false, + } + } + + /// A node grafted with its `children` already attached and shown expanded — + /// for a curated, pre-loaded subtree. + pub fn branch( + label: String, + icon: IconKey, + location: Location, + children: Vec, + ) -> Self { + Self { + label, + icon, + location, + children: Some(children), + expanded: true, + } + } } /// A flattened, render-ready view of one visible node. Borrows from the tree. @@ -71,6 +110,7 @@ pub struct TreeRow<'a> { } /// The outcome of a [`Tree::reveal`] step. +#[derive(Debug)] pub enum Reveal { /// To continue revealing, the children of this node must be loaded first. Load(NodeId, Location), @@ -82,18 +122,26 @@ pub enum Reveal { /// The folder tree and its node-id allocator. pub struct Tree { + /// A hidden synthetic container whose children are the *top-level* nodes + /// (the user's folders, then a "This PC" node holding the drives). It is + /// never rendered itself — [`visible_rows`](Self::visible_rows) starts at + /// its children — which lets the sidebar show a forest of roots rather than + /// a single "This PC" parent over everything. 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. + /// A fresh tree whose (hidden) root is awaiting its top-level children — + /// the user's folders plus a "This PC" node. The app kicks that load off at + /// startup; until it lands, the sidebar is empty. pub fn new() -> Self { let root = TreeNode { id: ROOT_ID, - label: "This PC".to_string(), + label: String::new(), icon: IconKey::Folder, + // Never navigated or matched against a path; only ever descended + // through. `ThisPc` has no path, so it can't collide with a target. location: Location::ThisPc, expanded: true, children: Children::Loading, @@ -104,17 +152,40 @@ impl Tree { } } - /// Flatten the expanded nodes into render rows, depth-first. + /// Flatten the expanded nodes into render rows, depth-first. The hidden root + /// is skipped, so its children are the top level (depth 0). pub fn visible_rows(&self) -> Vec> { let mut rows = Vec::new(); - push_rows(&self.root, 0, &mut rows); + if let Children::Loaded(children) = &self.root.children { + for child in children { + push_rows(child, 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. + /// from the shared icon cache alongside the main list's. The hidden root has + /// no icon of its own, so collection starts at its children. pub fn collect_icon_keys(&self, out: &mut Vec) { - collect_keys(&self.root, out); + if let Children::Loaded(children) = &self.root.children { + for child in children { + collect_keys(child, out); + } + } + } + + /// The id of the top-level "This PC" node, once the root's children have + /// loaded. The app uses it to auto-expand "This PC" at startup so the drives + /// are visible without a manual click. + pub fn this_pc_id(&self) -> Option { + match &self.root.children { + Children::Loaded(children) => children + .iter() + .find(|c| matches!(c.location, Location::ThisPc)) + .map(|c| c.id), + _ => None, + } } /// Expand or collapse the node `id`. Returns the node and location to load @@ -159,13 +230,19 @@ impl Tree { .map(|c| { let id = self.next_id; self.next_id += 1; + // Pre-attached children are grafted (and recursively built) now; + // otherwise the node is left unloaded for a lazy fetch later. + let children = match c.children { + Some(kids) => Children::Loaded(self.build_nodes(kids)), + None => Children::Unloaded, + }; TreeNode { id, label: c.label, icon: c.icon, location: c.location, - expanded: false, - children: Children::Unloaded, + expanded: c.expanded, + children, } }) .collect() @@ -240,10 +317,22 @@ fn reveal_node(node: &mut TreeNode, target: &Path) -> Reveal { 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, …) - }, + Children::Loaded(children) => { + // Prefer the deepest path-prefix child (e.g. a user-folder shortcut). + if let Some(i) = best_child_index(children, target) { + return reveal_node(&mut children[i], target); + } + // No path child matched. At the top level the drives live under the + // "This PC" node (whose own location is pathless), so fall back to + // descending into it — one of its drives will prefix the target. + if let Some(i) = children + .iter() + .position(|c| matches!(c.location, Location::ThisPc)) + { + return reveal_node(&mut children[i], target); + } + Reveal::Stop // target not under any child (hidden, gone, …) + } } } @@ -266,11 +355,16 @@ mod tests { 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)), - } + TreeChild::lazy( + label.to_string(), + IconKey::Folder, + Location::Path(PathBuf::from(path)), + ) + } + + /// A pathless "This PC" child — the drives' container at the top level. + fn this_pc() -> TreeChild { + TreeChild::lazy("This PC".to_string(), IconKey::Folder, Location::ThisPc) } /// Find a visible row by label, for assertions. @@ -283,22 +377,22 @@ mod tests { } #[test] - fn root_is_visible_and_loading() { + fn root_is_hidden_until_children_load() { + // The synthetic root isn't rendered, so the sidebar is empty until its + // top-level children arrive. 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); + assert!(tree.visible_rows().is_empty()); } #[test] - fn set_children_makes_them_visible_under_expanded_root() { + fn top_level_children_render_at_depth_zero() { 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:"]); + let rows = tree.visible_rows(); + // The hidden root contributes no row of its own. + let labels: Vec<&str> = rows.iter().map(|r| r.label).collect(); + assert_eq!(labels, ["C:", "D:"]); + assert!(rows.iter().all(|r| r.depth == 0)); } #[test] @@ -320,15 +414,15 @@ mod tests { 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 + assert_eq!(tree.visible_rows().len(), 2); // C:, Users // Collapsing C: hides Users; it requests no reload. assert!(tree.toggle(c).is_none()); - assert_eq!(tree.visible_rows().len(), 2); + assert_eq!(tree.visible_rows().len(), 1); // Re-expanding shows it again with no reload (children cached). assert!(tree.toggle(c).is_none()); - assert_eq!(tree.visible_rows().len(), 3); + assert_eq!(tree.visible_rows().len(), 2); } #[test] @@ -353,11 +447,7 @@ mod tests { 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(); + let row = tree.visible_rows().into_iter().find(|r| r.id == c).unwrap(); assert!(!row.expandable, "a confirmed-empty node shows no chevron"); } @@ -386,9 +476,9 @@ mod tests { tree.set_children(users, vec![child("me", "C:\\Users\\me")]); assert!(matches!(tree.reveal(&target), Reveal::Stop)); - // The whole chain is now visible. + // The whole chain is now visible (the hidden root adds no row). let labels: Vec<&str> = tree.visible_rows().iter().map(|r| r.label).collect(); - assert_eq!(labels, ["This PC", "C:", "Users", "me", "D:"]); + assert_eq!(labels, ["C:", "Users", "me", "D:"]); } #[test] @@ -424,4 +514,74 @@ mod tests { Reveal::Stop )); } + + #[test] + fn this_pc_id_finds_the_drives_container() { + let mut tree = Tree::new(); + assert_eq!(tree.this_pc_id(), None, "no node before the roots load"); + // Top level: a user folder, then the "This PC" node. + tree.set_children( + ROOT_ID, + vec![child("Desktop", "C:\\Users\\me\\Desktop"), this_pc()], + ); + let pc = tree.this_pc_id().expect("This PC node present"); + assert_eq!(pc, row_id(&tree, "This PC")); + } + + #[test] + fn reveal_descends_through_this_pc_to_a_drive_path() { + // Drives are no longer top-level: they sit under the pathless "This PC" + // node, so revealing a drive path must fall back into it. + let mut tree = Tree::new(); + tree.set_children( + ROOT_ID, + vec![child("Desktop", "C:\\Users\\me\\Desktop"), this_pc()], + ); + let target = PathBuf::from("C:\\Windows"); + + // No top-level path child prefixes C:\Windows, so reveal descends into + // "This PC" and asks to load it (its drives aren't loaded yet). + let pc = tree.this_pc_id().unwrap(); + match tree.reveal(&target) { + Reveal::Load(id, Location::ThisPc) => assert_eq!(id, pc), + other => panic!("expected to load the This PC node, got {other:?}"), + } + + // Its drives arrive; the next step descends into C:\ toward the target. + tree.set_children(pc, vec![child("C:", "C:\\")]); + match tree.reveal(&target) { + Reveal::Load(_, Location::Path(p)) => assert_eq!(p, PathBuf::from("C:\\")), + other => panic!("expected to load C:\\, got {other:?}"), + } + } + + #[test] + fn branch_grafts_children_and_starts_expanded() { + // A `branch` node arrives with its children already attached and shown, + // so they're visible (nested, depth 1) without any lazy load or toggle. + let mut tree = Tree::new(); + let home = TreeChild::branch( + "me".to_string(), + IconKey::Folder, + Location::Path(PathBuf::from("C:\\Users\\me")), + vec![ + child("Desktop", "C:\\Users\\me\\Desktop"), + child("Documents", "C:\\Users\\me\\Documents"), + ], + ); + tree.set_children(ROOT_ID, vec![home]); + + let rows = tree.visible_rows(); + let labels: Vec<&str> = rows.iter().map(|r| r.label).collect(); + assert_eq!(labels, ["me", "Desktop", "Documents"]); + // The home node is depth 0 and expanded; its folders are nested at 1. + assert_eq!(rows[0].depth, 0); + assert!(rows[0].expanded); + assert!(rows[1].depth == 1 && rows[2].depth == 1); + + // Collapsing the pre-loaded node requests no reload (children cached). + let me = row_id(&tree, "me"); + assert!(tree.toggle(me).is_none()); + assert_eq!(tree.visible_rows().len(), 1); + } } diff --git a/crates/librarian-core/src/enumerate.rs b/crates/librarian-core/src/enumerate.rs index 1b2ab10..f8a2a4a 100644 --- a/crates/librarian-core/src/enumerate.rs +++ b/crates/librarian-core/src/enumerate.rs @@ -97,7 +97,9 @@ 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 }; + let Ok(meta) = dirent.metadata() else { + continue; + }; if !meta.is_dir() { continue; } diff --git a/crates/librarian-core/src/lib.rs b/crates/librarian-core/src/lib.rs index bec0719..afcf393 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, read_subdirs, DEFAULT_BATCH}; +pub use enumerate::{DEFAULT_BATCH, read_dir_all, read_dir_batched, read_subdirs}; pub use history::History; pub use model::{Attributes, Entry, EntryKind, Location}; -pub use sort::{is_visible, sort_entries, Sort, SortKey, SortOrder}; +pub use sort::{Sort, SortKey, SortOrder, is_visible, sort_entries}; diff --git a/crates/librarian-win/src/chrome.rs b/crates/librarian-win/src/chrome.rs index 7682b2f..0b7771e 100644 --- a/crates/librarian-win/src/chrome.rs +++ b/crates/librarian-win/src/chrome.rs @@ -10,8 +10,8 @@ use core::ffi::c_void; use windows::Win32::Foundation::HWND; use windows::Win32::Graphics::Dwm::{ - DwmSetWindowAttribute, DWMSBT_MAINWINDOW, DWMWA_SYSTEMBACKDROP_TYPE, - DWMWA_USE_IMMERSIVE_DARK_MODE, + DWMSBT_MAINWINDOW, DWMWA_SYSTEMBACKDROP_TYPE, DWMWA_USE_IMMERSIVE_DARK_MODE, + DwmSetWindowAttribute, }; /// Give the window a dark title bar (to match the dark theme) and request the diff --git a/crates/librarian-win/src/com.rs b/crates/librarian-win/src/com.rs index 6115244..eb7f1a8 100644 --- a/crates/librarian-win/src/com.rs +++ b/crates/librarian-win/src/com.rs @@ -12,9 +12,7 @@ use std::marker::PhantomData; use std::sync::mpsc::{self, Sender}; use std::thread; -use windows::Win32::System::Com::{ - CoInitializeEx, CoUninitialize, COINIT_APARTMENTTHREADED, -}; +use windows::Win32::System::Com::{COINIT_APARTMENTTHREADED, CoInitializeEx, CoUninitialize}; type Job = Box; @@ -101,7 +99,8 @@ impl ShellWorker { let _ = rtx.send(f(&apartment)); }); self.tx.send(job).expect("COM worker thread is gone"); - rrx.recv().expect("COM worker dropped the job without replying") + rrx.recv() + .expect("COM worker dropped the job without replying") } } diff --git a/crates/librarian-win/src/drives.rs b/crates/librarian-win/src/drives.rs index 032d6a0..c10ced0 100644 --- a/crates/librarian-win/src/drives.rs +++ b/crates/librarian-win/src/drives.rs @@ -8,10 +8,10 @@ use std::path::PathBuf; -use windows::core::PCWSTR; use windows::Win32::Storage::FileSystem::{ GetDiskFreeSpaceExW, GetDriveTypeW, GetLogicalDrives, GetVolumeInformationW, }; +use windows::core::PCWSTR; use crate::util::{to_wide, wide_to_string}; diff --git a/crates/librarian-win/src/fileop.rs b/crates/librarian-win/src/fileop.rs index 9130d75..678fb9a 100644 --- a/crates/librarian-win/src/fileop.rs +++ b/crates/librarian-win/src/fileop.rs @@ -13,13 +13,13 @@ use std::path::{Path, PathBuf}; -use windows::core::PCWSTR; use windows::Win32::Storage::FileSystem::FILE_ATTRIBUTE_DIRECTORY; -use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_ALL}; +use windows::Win32::System::Com::{CLSCTX_ALL, CoCreateInstance}; use windows::Win32::UI::Shell::{ - FileOperation, IFileOperation, IShellItem, SHCreateItemFromParsingName, FILEOPERATION_FLAGS, - FOF_ALLOWUNDO, FOF_NOCONFIRMMKDIR, FOFX_RECYCLEONDELETE, + FILEOPERATION_FLAGS, FOF_ALLOWUNDO, FOF_NOCONFIRMMKDIR, FOFX_RECYCLEONDELETE, FileOperation, + IFileOperation, IShellItem, SHCreateItemFromParsingName, }; +use windows::core::PCWSTR; use crate::com::Apartment; use crate::util::to_wide; diff --git a/crates/librarian-win/src/icon.rs b/crates/librarian-win/src/icon.rs index 16a8f2c..b21b21a 100644 --- a/crates/librarian-win/src/icon.rs +++ b/crates/librarian-win/src/icon.rs @@ -20,19 +20,20 @@ use core::ffi::c_void; use std::mem::size_of; use std::path::Path; -use windows::core::PCWSTR; use windows::Win32::Graphics::Gdi::{ - DeleteObject, GetDC, GetDIBits, GetObjectW, ReleaseDC, BITMAP, BITMAPINFO, - BITMAPINFOHEADER, DIB_RGB_COLORS, HBITMAP, HGDIOBJ, + BITMAP, BITMAPINFO, BITMAPINFOHEADER, DIB_RGB_COLORS, DeleteObject, GetDC, GetDIBits, + GetObjectW, HBITMAP, HGDIOBJ, ReleaseDC, }; use windows::Win32::Storage::FileSystem::{ FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_NORMAL, FILE_FLAGS_AND_ATTRIBUTES, }; +use windows::Win32::System::Com::CoTaskMemFree; use windows::Win32::UI::Shell::{ - SHGetFileInfoW, SHFILEINFOW, SHGFI_FLAGS, SHGFI_ICON, SHGFI_LARGEICON, SHGFI_SMALLICON, - SHGFI_USEFILEATTRIBUTES, + FOLDERID_ComputerFolder, SHFILEINFOW, SHGFI_FLAGS, SHGFI_ICON, SHGFI_LARGEICON, SHGFI_PIDL, + SHGFI_SMALLICON, SHGFI_USEFILEATTRIBUTES, SHGetFileInfoW, SHGetKnownFolderIDList, }; use windows::Win32::UI::WindowsAndMessaging::{DestroyIcon, GetIconInfo, HICON, ICONINFO}; +use windows::core::PCWSTR; use crate::com::Apartment; use crate::util::to_wide; @@ -53,7 +54,11 @@ pub fn icon_for_extension(_apt: &Apartment, ext: &str, large: bool) -> Option Option Option { + let size = if large { + SHGFI_LARGEICON + } else { + SHGFI_SMALLICON + }; + unsafe { + // The Computer folder's id list is allocated by the COM task allocator, + // so it must be freed with `CoTaskMemFree` once we're done with it. + let pidl = SHGetKnownFolderIDList(&FOLDERID_ComputerFolder, 0, None).ok()?; + let mut shfi = SHFILEINFOW::default(); + let ok = SHGetFileInfoW( + // With `SHGFI_PIDL` the first argument is a PIDL, not a path string. + PCWSTR(pidl as *const u16), + FILE_FLAGS_AND_ATTRIBUTES(0), + Some(&mut shfi), + size_of::() as u32, + SHGFI_ICON | SHGFI_PIDL | size, + ); + CoTaskMemFree(Some(pidl as *const c_void)); + if ok == 0 || shfi.hIcon.is_invalid() { + return None; + } + let hicon = shfi.hIcon; + let image = hicon_to_rgba(hicon); + _ = DestroyIcon(hicon); + image + } +} + fn icon_flags(large: bool, use_attributes: bool) -> SHGFI_FLAGS { - let size = if large { SHGFI_LARGEICON } else { SHGFI_SMALLICON }; + let size = if large { + SHGFI_LARGEICON + } else { + SHGFI_SMALLICON + }; let mut flags = SHGFI_ICON | size; if use_attributes { flags |= SHGFI_USEFILEATTRIBUTES; @@ -249,4 +292,15 @@ mod tests { .expect("folder icon should resolve"); assert_eq!(icon.rgba.len(), (icon.width * icon.height * 4) as usize); } + + #[test] + fn extracts_the_computer_icon() { + let icon = worker() + .run(|apt| computer_icon(apt, false)) + .expect("This PC icon should resolve"); + assert!(icon.width > 0 && icon.height > 0); + assert_eq!(icon.rgba.len(), (icon.width * icon.height * 4) as usize); + // A real icon has at least one non-transparent pixel. + assert!(icon.rgba.chunks_exact(4).any(|px| px[3] != 0)); + } } diff --git a/crates/librarian-win/src/known.rs b/crates/librarian-win/src/known.rs index ae30bfe..1412fa4 100644 --- a/crates/librarian-win/src/known.rs +++ b/crates/librarian-win/src/known.rs @@ -8,12 +8,12 @@ use core::ffi::c_void; use std::path::PathBuf; -use windows::core::GUID; use windows::Win32::System::Com::CoTaskMemFree; use windows::Win32::UI::Shell::{ - SHGetKnownFolderPath, FOLDERID_Desktop, FOLDERID_Documents, FOLDERID_Downloads, - FOLDERID_Music, FOLDERID_Pictures, FOLDERID_Videos, KNOWN_FOLDER_FLAG, + FOLDERID_Desktop, FOLDERID_Documents, FOLDERID_Downloads, FOLDERID_Music, FOLDERID_Pictures, + FOLDERID_Profile, FOLDERID_Videos, KNOWN_FOLDER_FLAG, SHGetKnownFolderPath, }; +use windows::core::GUID; #[derive(Debug, Clone)] pub struct KnownFolder { @@ -42,6 +42,12 @@ pub fn known_folders() -> Vec { folders } +/// The current user's home (profile) folder, e.g. `C:\Users\Alice`. Backs the +/// folder tree's home node, under which the known user folders are nested. +pub fn user_home() -> Option { + resolve(&FOLDERID_Profile) +} + fn resolve(id: &GUID) -> Option { unsafe { let pwstr = SHGetKnownFolderPath(id, KNOWN_FOLDER_FLAG(0), None).ok()?; diff --git a/crates/librarian-win/src/lib.rs b/crates/librarian-win/src/lib.rs index 888697e..8ed1174 100644 --- a/crates/librarian-win/src/lib.rs +++ b/crates/librarian-win/src/lib.rs @@ -19,8 +19,8 @@ pub mod open; pub use chrome::apply_window_chrome; pub use com::{Apartment, ShellWorker}; -pub use drives::{list_drives, DriveInfo, DriveKind}; +pub use drives::{DriveInfo, DriveKind, list_drives}; pub use fileop::{copy_items, create_folder, delete_to_recycle, move_items, rename}; -pub use icon::{folder_icon, icon_for_extension, icon_for_path, IconImage}; -pub use known::{known_folders, KnownFolder}; +pub use icon::{IconImage, computer_icon, folder_icon, icon_for_extension, icon_for_path}; +pub use known::{KnownFolder, known_folders, user_home}; pub use open::open_path; diff --git a/crates/librarian-win/src/open.rs b/crates/librarian-win/src/open.rs index bd8786a..5a86b59 100644 --- a/crates/librarian-win/src/open.rs +++ b/crates/librarian-win/src/open.rs @@ -2,9 +2,9 @@ use std::path::Path; -use windows::core::{w, PCWSTR}; use windows::Win32::UI::Shell::ShellExecuteW; use windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL; +use windows::core::{PCWSTR, w}; use crate::com::Apartment; use crate::util::to_wide;