From 316b91ff7eb87c712e89c43ea2fb0dded3ef0c48 Mon Sep 17 00:00:00 2001 From: Abdulwahb Isam Date: Sun, 27 Sep 2026 00:04:27 +0300 Subject: [PATCH 1/4] windows: as_chunks instead of chunks_exact(2), which clippy 1.98 wants --- crates/disktree-core/src/windows.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/disktree-core/src/windows.rs b/crates/disktree-core/src/windows.rs index 9e9468d..14ba81d 100644 --- a/crates/disktree-core/src/windows.rs +++ b/crates/disktree-core/src/windows.rs @@ -334,8 +334,10 @@ impl Records { }; let name: Vec = name_bytes - .chunks_exact(2) - .map(|pair| u16::from_ne_bytes([pair[0], pair[1]])) + .as_chunks::<2>() + .0 + .iter() + .map(|pair| u16::from_ne_bytes(*pair)) .collect(); if name == [u16::from(b'.')] || name == [u16::from(b'.'); 2] { return Ok((None, next)); From 93cb8c64b722fee35c5b8b5890d888e9af2b090c Mon Sep 17 00:00:00 2001 From: Abdulwahb Isam Date: Sun, 27 Sep 2026 00:04:31 +0300 Subject: [PATCH 2/4] space, app: a volume picker listing every mounted volume and its free space --- crates/disktree-app/src/state.rs | 92 ++++++++++++++ crates/disktree-app/src/tests.rs | 21 ++++ crates/disktree-app/src/views.rs | 115 +++++++++++++++--- crates/disktree-core/src/space.rs | 196 ++++++++++++++++++++++++++++++ 4 files changed, 409 insertions(+), 15 deletions(-) diff --git a/crates/disktree-app/src/state.rs b/crates/disktree-app/src/state.rs index 27e1846..d78f3c0 100644 --- a/crates/disktree-app/src/state.rs +++ b/crates/disktree-app/src/state.rs @@ -419,6 +419,12 @@ pub struct Disktree { /// The top of the disk the scanned root lives on: what "Whole disk" /// scans. Follows the root when a folder is opened. pub disk_root: Option, + /// The volume picker: open, what it listed, and which row the keys are + /// on. `None` when closed; empty when no volume besides the current one + /// could be read. + pub volumes_open: bool, + pub volumes: Vec, + pub volume_highlight: usize, /// The side panel's width, in rem; dragged from its left edge. pub panel_rems: f32, pub scan_started: Option, @@ -506,6 +512,9 @@ impl Disktree { device: None, full_disk_access: None, disk_root: None, + volumes_open: false, + volumes: Vec::new(), + volume_highlight: 0, panel_rems: PANEL_REMS, scan_started: None, scan_root: PathBuf::new(), @@ -599,6 +608,68 @@ impl Disktree { cx.notify(); } + /// `V`: list every volume and let the scan move to one. The in-flight walk + /// is left alone until a volume is picked; picking calls [`set_root`], + /// which cancels it. + pub fn open_volumes(&mut self, cx: &mut Context<'_, Self>) { + let mut volumes = disktree_core::space::volumes(); + // The current disk is always offered, even when the table misses it: + // it is the one row that is never wrong. + if let Some(root) = volume_root_for(&self.root_path) + && !volumes.iter().any(|volume| volume.point == root) + { + volumes.push(disktree_core::space::Volume { + point: root.clone(), + device: device_for(&root), + space: space_info(&root).ok(), + }); + } + volumes.retain(|volume| volume.point != self.root_path); + self.volume_highlight = 0; + self.volumes = volumes; + self.volumes_open = true; + cx.notify(); + } + + /// Scan the highlighted volume from scratch; closes the picker when there + /// is nothing to pick. + pub fn choose_volume(&mut self, cx: &mut Context<'_, Self>) { + let Some(point) = self + .volumes + .get(self.volume_highlight) + .map(|volume| volume.point.clone()) + else { + self.volumes_open = false; + cx.notify(); + return; + }; + self.volumes_open = false; + self.volumes.clear(); + self.set_root(point, cx); + } + + /// Move the highlight in the open picker, wrapping at the ends. + pub fn move_volume_highlight( + &mut self, + step: i32, + cx: &mut Context<'_, Self>, + ) { + if self.volumes.is_empty() { + return; + } + let len = self.volumes.len(); + let at = self.volume_highlight; + // Picker steps are ±1, but wrap either way without casting the length + // down to a narrower type. + let next = match step.signum() { + 1 => (at + 1) % len, + -1 => at.checked_sub(1).unwrap_or(len - 1), + _ => at, + }; + self.volume_highlight = next; + cx.notify(); + } + /// `g`: the whole disk. Widens when the disk is above the scanned root, /// and goes to its top when it already is the root. pub fn go_to_disk(&mut self, cx: &mut Context<'_, Self>) { @@ -2259,6 +2330,26 @@ impl Disktree { return; } + // The volume picker owns its keys while open: arrows move, Enter + // picks, Escape closes, and nothing behind it acts. + if self.volumes_open { + match key { + "escape" => { + self.volumes_open = false; + cx.notify(); + } + "enter" => self.choose_volume(cx), + "up" | "k" if !control => { + self.move_volume_highlight(-1, cx); + } + "down" | "j" if !control => { + self.move_volume_highlight(1, cx); + } + _ => {} + } + return; + } + // ⌘ chords belong to the menu bar (⌘Q, ⌘W, ⌘R) or to the system. // Read as plain letters they would act twice or by surprise: ⌘D // would re-scan with apparent sizes, ⌘H would hide *and* toggle. @@ -2430,6 +2521,7 @@ impl Disktree { } "r" if !control => self.start_scan(cx), "g" if !control => self.go_to_disk(cx), + "v" if !control && !shift => self.open_volumes(cx), "i" if !control => { self.options.include_hidden = !self.options.include_hidden; self.start_scan(cx); diff --git a/crates/disktree-app/src/tests.rs b/crates/disktree-app/src/tests.rs index af151ec..1b7f4db 100644 --- a/crates/disktree-app/src/tests.rs +++ b/crates/disktree-app/src/tests.rs @@ -466,6 +466,27 @@ fn the_help_overlay_opens_and_closes(cx: &mut TestAppContext) { assert!(!read(&view, cx, |app| app.show_help)); } +#[gpui_kit::test] +fn the_volume_picker_opens_moves_and_closes(cx: &mut TestAppContext) { + cx.update(gpui_omarchy::init); + let temp = fixture(); + let (view, cx) = view_over(temp.path(), cx); + draw(cx); + + // The picker lists volumes and draws them without panicking; Escape + // leaves the scan where it was. + press(cx, "v"); + assert!(read(&view, cx, |app| app.volumes_open)); + draw(cx); + assert!(cx.debug_bounds("disktree-root").is_some()); + let before = read(&view, cx, |app| app.root_path.clone()); + press(cx, "down"); + press(cx, "up"); + press(cx, "escape"); + assert!(!read(&view, cx, |app| app.volumes_open)); + assert_eq!(read(&view, cx, |app| app.root_path.clone()), before); +} + #[gpui_kit::test] fn showing_a_tile_that_is_gone_says_so_instead(cx: &mut TestAppContext) { cx.update(gpui_omarchy::init); diff --git a/crates/disktree-app/src/views.rs b/crates/disktree-app/src/views.rs index 8f75253..22febac 100644 --- a/crates/disktree-app/src/views.rs +++ b/crates/disktree-app/src/views.rs @@ -111,12 +111,84 @@ pub fn root( if app.show_help { root = root.child(help_overlay(app, cx)); } + if app.volumes_open { + root = root.child(volumes_dialog(app, cx)); + } if app.confirm_open { root = root.child(delete_dialog(app, cx)); } root } +/// Picking another volume scans it from scratch: the picker lists every +/// volume with its free space, and the choice is the new root. +fn volumes_dialog( + app: &Disktree, + cx: &mut Context<'_, Disktree>, +) -> impl IntoElement { + let theme = cx.omarchy().clone(); + let cancel = cx.entity().downgrade(); + let mut rows = div().flex().flex_col().gap(space::XS); + if app.volumes.is_empty() { + rows = rows + .child(dialog_description("No other volume could be read.", cx)); + } + for (index, volume) in app.volumes.iter().enumerate() { + let highlighted = index == app.volume_highlight; + let free = volume.space.map_or_else( + || "unknown free".to_string(), + |space| format!("{} free", human_bytes(space.available)), + ); + let label = match &volume.device { + Some(device) => format!( + "{} \u{00b7} {device} \u{00b7} {free}", + volume.point.display() + ), + None => format!("{} \u{00b7} {free}", volume.point.display()), + }; + rows = rows.child( + div() + .id(ElementId::Name(format!("volume-{index}").into())) + .px(space::MD) + .py(space::SM) + .text_size(text::BODY) + .text_color(if highlighted { + theme.bright + } else { + theme.foreground + }) + // Only the row the keys are on is tinted: a second highlight + // would read as a second selection. + .when(highlighted, |row| row.bg(theme.accent.opacity(0.18))) + .hover(|row| row.bg(theme.accent.opacity(0.1))) + .child(label) + .on_click(cx.listener(move |this, _, window, cx| { + this.volume_highlight = index; + this.choose_volume(cx); + window.focus(&this.focus, cx); + })), + ); + } + let popup = dialog_popup(cx) + .child(dialog_title("Scan a volume", cx)) + .child(dialog_description( + "Up and down moves, Enter scans it, Escape stays here.", + cx, + )) + .child(rows); + alert_dialog(&app.confirm_focus, cx) + .open(true) + .on_cancel(move |_, window, cx| { + let _ = cancel.update(cx, |this, cx| { + this.volumes_open = false; + cx.notify(); + this.apply_focus(window, cx); + }); + false + }) + .popup(popup) +} + /// The one question disktree asks: a permanent deletion cannot be undone, so /// it is an alert dialog that names what goes and what comes back. The trash is /// reversible and needs no dialog. @@ -1588,19 +1660,30 @@ fn disk_section( cx: &Context<'_, Disktree>, ) -> Div { let device = app.device.clone().unwrap_or_default(); - let mut section = div().flex().flex_col().gap(space::SM).child( - div() - .flex() - .flex_row() - .gap(space::SM) - .child(widgets::eyebrow("Disk", cx)) - .child( - div() - .text_size(text::CAPTION) - .text_color(theme.secondary.opacity(0.6)) - .child(device), - ), - ); + // The disk header doubles as the way to another volume: it opens the + // picker, like `V` does. A plain label would hide that the scan can move. + let header = div() + .flex() + .flex_row() + .items_center() + .gap(space::SM) + .child(widgets::eyebrow("Disk", cx)) + .child( + div() + .text_size(text::CAPTION) + .text_color(theme.secondary.opacity(0.6)) + .child(device), + ) + .child(div().flex_1()) + .child( + button("volumes", "Volumes", ButtonVariant::Secondary, cx) + .tab_stop(false) + .on_click(cx.listener(|this, _, window, cx| { + this.open_volumes(cx); + window.focus(&this.focus, cx); + })), + ); + let mut section = div().flex().flex_col().gap(space::SM).child(header); let Some(space_info) = app.space else { return section.child( div() @@ -1751,7 +1834,7 @@ fn review_button( /// to every other key and the scan's own numbers hold the trailing edge. fn key_bar(app: &Disktree, theme: &Theme, cx: &App) -> Div { // Most useful first, so a narrow window clips the least useful. - let hints: [(&str, &str); 10] = [ + let hints: [(&str, &str); 11] = [ ("space", "mark"), ("enter", "open"), ("\u{232b}", "up"), @@ -1761,6 +1844,7 @@ fn key_bar(app: &Disktree, theme: &Theme, cx: &App) -> Div { ("[ ]", "depth"), ("t", "mode"), ("0", "reset"), + ("v", "volumes"), ("r", "rescan"), ]; let mut lane = div() @@ -2995,7 +3079,7 @@ fn help_overlay(app: &Disktree, cx: &gpui_kit::App) -> Div { let theme = cx.omarchy(); // Sentence case, and the tile a key acts on is always the one under the // pointer if the pointer moved last, else the keyboard selection. - let rows: [(&str, &str); 27] = [ + let rows: [(&str, &str); 28] = [ ("space / x", "Mark or unmark the tile you point at"), (MODIFIER_CLICK, "Mark without moving the selection"), ("enter", "Open that directory, at any depth"), @@ -3021,6 +3105,7 @@ fn help_overlay(app: &Disktree, cx: &gpui_kit::App) -> Div { ("c", "Review the marked list"), ("t", "Size, files or age: what areas and colours say"), ("r", "Scan again from the same root"), + ("v", "Scan another volume"), (MODIFIER_OPEN, "Choose another directory to scan"), ("g", "The whole disk; click any directory above to widen"), ("d", "Disk usage or apparent size"), diff --git a/crates/disktree-core/src/space.rs b/crates/disktree-core/src/space.rs index f7b5bb4..91dade7 100644 --- a/crates/disktree-core/src/space.rs +++ b/crates/disktree-core/src/space.rs @@ -329,6 +329,175 @@ pub const fn foreign_mounts_for(_root: &Path) -> Option> { Some(Vec::new()) } +/// A mounted volume the picker can switch the scan to: where it is mounted +/// and how much room it has. Listed by [`volumes`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Volume { + /// Where the volume is mounted: `C:\` on Windows, `/` or `/home` on + /// Linux, `/` on macOS. + pub point: PathBuf, + /// What is mounted there, when the table names it: a device such as + /// `/dev/nvme0n1p2`, or a name like `tmpfs`. + pub device: Option, + /// Free space on the volume now; `None` when it cannot be read. + pub space: Option, +} + +impl Volume { + /// Free bytes for ranking; an unreadable volume sorts last. + const fn available(&self) -> u64 { + match &self.space { + Some(space) => space.available, + None => 0, + } + } +} + +/// Every volume worth offering as a scan root, fullest first. +/// +/// Pseudo filesystems (`/proc`, `/sys`, tmpfs, …), snapshot subvolumes and +/// automount points are left out: switching the scan to one of those would +/// measure the wrong thing, the same reason [`foreign_mounts`] keeps a scan +/// from entering them. Duplicates from one device mounted twice (a btrfs +/// subvolume at `/` and `/home`) collapse to the shortest mount point, which +/// is the top of that disk. +#[cfg(not(any(target_os = "macos", windows)))] +pub fn volumes() -> Vec { + let Ok(table) = std::fs::read_to_string("/proc/self/mounts") else { + return Vec::new(); + }; + volumes_in(&parse_mounts(&table)) +} + +/// [`volumes`] over a given mount table, for testing. +pub fn volumes_in(mounts: &[Mount]) -> Vec { + let mut seen: Vec<&Mount> = Vec::new(); + for mount in mounts { + if !is_volume_candidate(mount) { + continue; + } + // One device mounted twice (a btrfs disk at `/`, `/home`, + // `/var/log`) is one volume: keep the shortest mount point, which is + // the top of that disk. + if let Some(known) = seen.iter_mut().find(|known| { + known.source == mount.source && known.fstype == mount.fstype + }) { + if mount.point.as_os_str().len() < known.point.as_os_str().len() { + *known = mount; + } + continue; + } + seen.push(mount); + } + let mut volumes: Vec = seen + .iter() + .map(|mount| Volume { + point: mount.point.clone(), + device: Some(mount.source.clone()), + space: space_info(&mount.point).ok(), + }) + .collect(); + // The scarcest room is the most interesting to a cleanup tool, so the + // fullest volume that still reads is first; unreadable ones sort last. + sort_by_free_space(&mut volumes); + volumes +} + +/// Fullest first; an unreadable volume sorts last. +fn sort_by_free_space(volumes: &mut [Volume]) { + volumes.sort_by_key(|volume| std::cmp::Reverse(volume.available())); +} + +/// Whether the mount is a real disk worth scanning: a device-backed +/// filesystem that is not a snapshot, an automount point, or one of the +/// pseudo filesystems the scan itself refuses to enter. +fn is_volume_candidate(mount: &Mount) -> bool { + if is_snapshot(mount) { + return false; + } + if mount + .options + .split(',') + .any(|option| option == "automounted" || option.starts_with("autofs")) + { + return false; + } + !matches!( + mount.fstype.as_str(), + "autofs" + | "cgroup" + | "cgroup2" + | "configfs" + | "debugfs" + | "devpts" + | "devtmpfs" + | "fuse.portal" + | "fusectl" + | "hugetlbfs" + | "mqueue" + | "nsfs" + | "overlay" + | "proc" + | "pstore" + | "securityfs" + | "sysfs" + | "tmpfs" + | "tracefs" + ) +} + +/// Every volume worth offering as a scan root, fullest first. +/// +/// The Data volume's second mount is the same disk under another name, so it +/// is left out; the firmlinks joined into `/` mean `/` already shows it. +#[cfg(target_os = "macos")] +pub fn volumes() -> Vec { + use std::process::Command; + + let output = Command::new("mount") + .output() + .ok() + .and_then(|output| String::from_utf8(output.stdout).ok()); + let Some(output) = output else { + return Vec::new(); + }; + let mut points = parse_macos_mounts(&output); + points.retain(|point| { + point.as_os_str() != MACOS_DATA_VOLUME && !point.as_os_str().is_empty() + }); + let mut volumes: Vec = points + .iter() + .map(|point| Volume { + point: point.clone(), + device: device_for(point), + space: space_info(point).ok(), + }) + .collect(); + sort_by_free_space(&mut volumes); + volumes +} + +/// Every place a volume is mounted that can be scanned. +/// +/// Drive roots such as `D:\` and folders a volume is mounted on. Unready +/// drives (an empty card reader reports a path but no space) are left out, +/// since there is nothing to measure there. +#[cfg(windows)] +pub fn volumes() -> Vec { + let mut volumes: Vec = crate::windows::mount_points() + .into_iter() + .filter_map(|point| { + space_info(&point).ok().map(|space| Volume { + point, + device: None, + space: Some(space), + }) + }) + .collect(); + sort_by_free_space(&mut volumes); + volumes +} + #[cfg(test)] mod tests { use super::*; @@ -366,6 +535,33 @@ portal /run/user/1000/doc fuse.portal rw 0 0 assert_eq!(foreign, expected, "/home and /var/log are the same disk"); } + #[test] + fn volume_candidates_are_real_disks_not_pseudo_filesystems() { + let mounts = parse_mounts(OMARCHY); + let points: Vec = volumes_in(&mounts) + .iter() + .map(|volume| volume.point.clone()) + .collect(); + // One btrfs disk (at `/`, collapsing `/home` and `/var/log`), plus + // the boot disk; tmpfs, autofs, portals and snapshots are not scans. + assert!(points.contains(&PathBuf::from("/")), "{points:?}"); + assert!(points.contains(&PathBuf::from("/boot")), "{points:?}"); + assert_eq!(points.len(), 2, "{points:?}"); + } + + #[test] + fn separate_home_disk_is_its_own_volume() { + let separate = parse_mounts( + "/dev/sda1 / ext4 rw 0 0\n/dev/sdb1 /home ext4 rw 0 0\n", + ); + let points: Vec = volumes_in(&separate) + .iter() + .map(|volume| volume.point.clone()) + .collect(); + assert!(points.contains(&PathBuf::from("/")), "{points:?}"); + assert!(points.contains(&PathBuf::from("/home")), "{points:?}"); + } + #[test] fn the_whole_disk_is_the_top_of_the_home_volume() { let mounts = parse_mounts(OMARCHY); From 45e47e04d92d03146839452b4004133b3580a675 Mon Sep 17 00:00:00 2001 From: Abdulwahb Isam Date: Sun, 27 Sep 2026 00:14:44 +0300 Subject: [PATCH 3/4] app: centre alert dialog popups in the window --- crates/disktree-app/src/tests.rs | 59 ++++++++++++++++++++++++++- crates/disktree-app/src/views.rs | 69 ++++++++++++++++++++++++++++++-- 2 files changed, 123 insertions(+), 5 deletions(-) diff --git a/crates/disktree-app/src/tests.rs b/crates/disktree-app/src/tests.rs index 1b7f4db..38102b4 100644 --- a/crates/disktree-app/src/tests.rs +++ b/crates/disktree-app/src/tests.rs @@ -5,10 +5,11 @@ //! that panics while painting, a binding that never fires, a removal that //! reports success without removing anything. -use std::path::Path; +use std::path::{Path, PathBuf}; use disktree_core::removal::RemovalMode; use disktree_core::scan::{ScanOptions, scan}; +use disktree_core::space::{SpaceInfo, Volume}; use disktree_core::treemap::Tile; use gpui_kit::{ Bounds, Context, Entity, Pixels, Point, TestAppContext, VisualTestContext, @@ -487,6 +488,62 @@ fn the_volume_picker_opens_moves_and_closes(cx: &mut TestAppContext) { assert_eq!(read(&view, cx, |app| app.root_path.clone()), before); } +#[gpui_kit::test] +fn the_volume_picker_is_centred_and_still_dismisses_from_outside( + cx: &mut TestAppContext, +) { + cx.update(gpui_omarchy::init); + let temp = fixture(); + let (view, cx) = view_over(temp.path(), cx); + // Fixed rows, so this is about where the popup lands and not about which + // disks the machine running the test happens to have. + update(&view, cx, |app, cx| { + app.volumes = vec![ + Volume { + point: PathBuf::from("/one"), + device: Some("/dev/one".into()), + space: Some(SpaceInfo { + total: 1_000, + free: 500, + available: 400, + }), + }, + Volume { + point: PathBuf::from("/two"), + device: None, + space: None, + }, + ]; + app.volumes_open = true; + cx.notify(); + }); + draw(cx); + + let viewport = cx.update(|window, _| window.viewport_size()); + let rows = cx.debug_bounds("volume-rows").expect("the rows are drawn"); + let centre = rows.center(); + assert!( + (centre.x - viewport.width / 2.0).abs() < px(4.), + "the popup is not centred across: {centre:?} in {viewport:?}" + ); + assert!( + (centre.y - viewport.height / 2.0).abs() < px(150.), + "the popup is not centred down: {centre:?} in {viewport:?}" + ); + + // The wrapper that centres the popup must not swallow the backdrop's + // clicks: a click in the corner, outside the popup, still closes it. + cx.simulate_click( + gpui_kit::point(px(4.), px(4.)), + gpui_kit::Modifiers::none(), + ); + draw(cx); + assert!( + !read(&view, cx, |app| app.volumes_open), + "clicking outside the picker closes it" + ); +} + #[gpui_kit::test] fn showing_a_tile_that_is_gone_says_so_instead(cx: &mut TestAppContext) { cx.update(gpui_omarchy::init); diff --git a/crates/disktree-app/src/views.rs b/crates/disktree-app/src/views.rs index 22febac..8748b56 100644 --- a/crates/disktree-app/src/views.rs +++ b/crates/disktree-app/src/views.rs @@ -13,7 +13,7 @@ use gpui_kit::base::CheckboxState; use gpui_kit::{ App, AppContext as _, ClickEvent, Context, Div, DragMoveEvent, ElementId, FontWeight, InteractiveElement as _, IntoElement, KeyDownEvent, - ParentElement, Rems, SharedString, Stateful, + MouseDownEvent, ParentElement, Rems, SharedString, Stateful, StatefulInteractiveElement as _, Styled, Window, anchored, deferred, div, pattern_slash, px, relative, }; @@ -128,7 +128,12 @@ fn volumes_dialog( ) -> impl IntoElement { let theme = cx.omarchy().clone(); let cancel = cx.entity().downgrade(); - let mut rows = div().flex().flex_col().gap(space::XS); + let mut rows = div() + .id("volume-rows") + .debug_selector(|| "volume-rows".into()) + .flex() + .flex_col() + .gap(space::XS); if app.volumes.is_empty() { rows = rows .child(dialog_description("No other volume could be read.", cx)); @@ -176,6 +181,16 @@ fn volumes_dialog( cx, )) .child(rows); + let centred = centred_popup(popup, { + let close = cancel.clone(); + move |_, window, cx| { + let _ = close.update(cx, |this, cx| { + this.volumes_open = false; + cx.notify(); + this.apply_focus(window, cx); + }); + } + }); alert_dialog(&app.confirm_focus, cx) .open(true) .on_cancel(move |_, window, cx| { @@ -186,7 +201,42 @@ fn volumes_dialog( }); false }) - .popup(popup) + .popup(centred) +} + +/// Put a dialog's popup in the middle of the window, and close it when a +/// click lands outside it. +/// +/// The base dialog hosts its popup as an ordinary child of a full-window box, +/// so a popup lands in the top-left corner unless something centres it. That +/// something ends up in front of the backdrop, which is what used to receive +/// the click that dismisses a dialog, so the click is taken here instead. The +/// card stops a mouse-down from reaching this wrapper, so a click on the +/// dialog itself stays the dialog's own. +fn centred_popup( + card: impl IntoElement, + on_outside: impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static, +) -> Stateful
{ + div() + .id("dialog-outside") + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + // Any button, as the backdrop took them: a right-click outside closes + // the dialog too. + .on_any_mouse_down(on_outside) + .child( + div() + .id("dialog-card") + .on_any_mouse_down( + |_: &MouseDownEvent, _: &mut Window, cx: &mut App| { + cx.stop_propagation(); + }, + ) + .child(card), + ) } /// The one question disktree asks: a permanent deletion cannot be undone, so @@ -243,6 +293,17 @@ fn delete_dialog( .child(dialog_title(title, cx)) .child(dialog_description(body, cx)) .child(actions); + // Built before the chain below, which moves `cancel` into its own + // handler for Escape. + let centred = centred_popup(popup, { + let close = cancel.clone(); + move |_, window, cx| { + let _ = close.update(cx, |this, cx| { + this.cancel_delete(cx); + this.apply_focus(window, cx); + }); + } + }); alert_dialog(&app.confirm_focus, cx) .open(true) .on_ok(move |_, window, cx| { @@ -259,7 +320,7 @@ fn delete_dialog( }); false }) - .popup(popup) + .popup(centred) } // ── explore ───────────────────────────────────────────────────────────── From e6ae089b6237c4011a0b93c2994aead76cb7968f Mon Sep 17 00:00:00 2001 From: Abdulwahb Isam Date: Sun, 27 Sep 2026 00:21:52 +0300 Subject: [PATCH 4/4] doc: document the volume picker in README --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 7ee7533..88d682b 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,7 @@ and shows how much free space was actually gained. | `d` | disk usage or apparent size | | `i` | include or skip hidden entries | | `r` | scan again | +| `v` | scan another mounted volume | | `ctrl o` (`⌘O` on macOS) | choose another directory to scan | | `g` | the whole disk | | `p` | show or hide the selection line | @@ -245,6 +246,14 @@ scope per root, a completion counter per directory so no directory is built before its last subdirectory lands, and one bottom-up pass that aggregates sizes and removes duplicate hardlinks. +## Switching volumes + +Press `v` (or click **Volumes** beside the disk name in the side panel) to +bring up the volume picker. It lists every candidate volume mounted on the +system together with its free space, ordered fullest first, filtering out +pseudo-filesystems and duplicate btrfs/APFS mount points. Selecting any +entry resets the scan root directly to that volume. + ## The whole disk Click `/` (or any directory above the scanned root) in the trail, press