icon files and separator added back - look and feel updates
This commit is contained in:
Generated
+41
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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<IconKey>) -> 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))
|
||||
|
||||
+220
-123
@@ -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<iced::window::Icon> {
|
||||
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<PathBuf>,
|
||||
@@ -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<DriveInfo>,
|
||||
folders: Vec<KnownFolder>,
|
||||
},
|
||||
Folder {
|
||||
entries: Vec<Entry>,
|
||||
@@ -205,7 +223,7 @@ enum Message {
|
||||
MoveSelection(Nav, bool, bool),
|
||||
SelectAll,
|
||||
Activate,
|
||||
ThisPcLoaded(Vec<DriveInfo>, Vec<KnownFolder>),
|
||||
ThisPcLoaded(Vec<DriveInfo>),
|
||||
Loaded(u64, Result<Vec<Entry>, 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<Message> {
|
||||
/// 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<Message> {
|
||||
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<Message> {
|
||||
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<Row> = drives
|
||||
.iter()
|
||||
.map(rows::row_from_drive)
|
||||
.chain(folders.iter().map(rows::row_from_known))
|
||||
.collect();
|
||||
Content::ThisPc { drives } => {
|
||||
let mut rows: Vec<Row> = 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<Vec<TreeChild>, 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<TreeChild> {
|
||||
let folders = worker.run(|_| known_folders());
|
||||
let home = worker.run(|_| user_home());
|
||||
let known: Vec<TreeChild> = 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>) -> 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<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)
|
||||
}
|
||||
// "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<Message> {
|
||||
|
||||
/// `""` 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<Item = Message> + 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<Message>| 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<Message>| 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)
|
||||
|
||||
@@ -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<Utc> = 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)]
|
||||
|
||||
@@ -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<Vec<TreeChild>>,
|
||||
/// 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<TreeChild>,
|
||||
) -> 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<TreeRow<'_>> {
|
||||
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<IconKey>) {
|
||||
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<NodeId> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,9 @@ 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 };
|
||||
let Ok(meta) = dirent.metadata() else {
|
||||
continue;
|
||||
};
|
||||
if !meta.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<dyn FnOnce() + Send + 'static>;
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Ic
|
||||
} else {
|
||||
format!("file.{ext}")
|
||||
};
|
||||
extract(&to_wide(&name), FILE_ATTRIBUTE_NORMAL, icon_flags(large, true))
|
||||
extract(
|
||||
&to_wide(&name),
|
||||
FILE_ATTRIBUTE_NORMAL,
|
||||
icon_flags(large, true),
|
||||
)
|
||||
}
|
||||
|
||||
/// Generic folder icon.
|
||||
@@ -75,8 +80,46 @@ pub fn icon_for_path(_apt: &Apartment, path: &Path, large: bool) -> Option<IconI
|
||||
)
|
||||
}
|
||||
|
||||
/// The shell's "This PC" (Computer) icon, matching what Explorer shows for the
|
||||
/// machine root. "This PC" is a virtual shell item with no file path, so its
|
||||
/// icon is resolved from the Computer folder's id list (PIDL) rather than a path
|
||||
/// string. Must run on the COM STA thread.
|
||||
pub fn computer_icon(_apt: &Apartment, large: bool) -> Option<IconImage> {
|
||||
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::<SHFILEINFOW>() 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<KnownFolder> {
|
||||
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<PathBuf> {
|
||||
resolve(&FOLDERID_Profile)
|
||||
}
|
||||
|
||||
fn resolve(id: &GUID) -> Option<PathBuf> {
|
||||
unsafe {
|
||||
let pwstr = SHGetKnownFolderPath(id, KNOWN_FOLDER_FLAG(0), None).ok()?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user