Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions crates/disktree-app/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf>,
/// 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<disktree_core::space::Volume>,
pub volume_highlight: usize,
/// The side panel's width, in rem; dragged from its left edge.
pub panel_rems: f32,
pub scan_started: Option<Instant>,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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>) {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
80 changes: 79 additions & 1 deletion crates/disktree-app/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -466,6 +467,83 @@ 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 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);
Expand Down
Loading