Skip to content
Open
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
62 changes: 58 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,21 @@ make install # ~/Applications/disktree.app, and ~/.local/bin/disktree
make uninstall
```

To see everything, give disktree **Full Disk Access** in System Settings ›
Privacy & Security (the panel offers a button when it is missing), then
To see privacy-protected files, give disktree **Full Disk Access** in System
Settings › Privacy & Security (the panel offers a button when it is missing), then
reopen it. Without it macOS hides Mail, Messages, Safari, other apps' data
and the Trash, and disktree counts them as unreadable. Started from a
terminal, it is the terminal that needs the access. macOS also asks once
each for Desktop, Documents and Downloads.

Full Disk Access does not override filesystem ownership and permissions.
Administrator-owned service stores can still be unreadable. Their sizes are
**Unavailable**, or **measured · incomplete** when only part was readable;
neither means zero bytes or that the service is absent. Include hidden entries,
scan without a depth limit, and use the directory chooser to scan a listed
store directly, or rescan after read access has been made available outside
disktree. The app does not request elevation or install a privileged helper.

What is different from Linux:

- **Free space** is what `df` reports. Finder's figure is larger: it counts
Expand All @@ -112,6 +120,51 @@ What is different from Linux:
- **Cloud-only folders** (iCloud Drive, Dropbox and the like, evicted to the
server) are not opened, so a scan never downloads them.

#### FSEvents history and Spotlight indexes

Whole-disk scans show **FSEvents history** (`.fseventsd`) and **Spotlight index**
(`.Spotlight-V100`) in the treemap and in **macOS service stores** in the panel,
including an availability label when there is no measurable tile. On a startup
disk these private stores live in `/System/Volumes/Data`; external volumes
have their own stores. A home-directory scan does not measure them.

The figures are allocated file bytes (`st_blocks × 512`), unless apparent size
is selected, and are already part of the scan total. They are not additional
APFS volume, container or snapshot figures. Hardlinks are counted once across
the tree; shared APFS clone blocks and snapshots mean measured bytes are not
a promise of space freed. The rest of the second Data mount stays excluded,
so firmlinked user files are not counted again.

FSEvents stores persistent filesystem change history that backup and sync
apps can use. At an administrator's explicit discretion, Apple's supported,
root-only [`FSEventsPurgeEventsForDeviceUpToEventId` API](https://developer.apple.com/documentation/coreservices/1447985-fseventspurgeeventsfordeviceupto)
can shorten old history. It destroys history for **every consumer on that
volume**, and those consumers may need a full rescan. Apple's
[FSEvents guide](https://developer.apple.com/library/archive/documentation/Darwin/Conceptual/FSEvents_ProgGuide/UsingtheFSEventsFramework/UsingtheFSEventsFramework.html)
advises against routine purging and requires an explicit administrator request
and confirmation. disktree only explains this option; it does not call it or
suggest raw deletion of `.fseventsd`.

An administrator implementing a chosen retention cutoff would resolve the
volume's current `st_dev`, obtain an event ID with
`FSEventsGetLastEventIdForDeviceBeforeTime`, and only then use the purge API.
The installed macOS SDK's `FSEvents.h` documents this function's time argument
as **seconds since January 1, 1970**, despite its `CFAbsoluteTime` type. The
archived guide describes the usual 2001 epoch instead; check the target SDK
contract rather than applying that conversion. No retention cutoff or space
saving is guaranteed by disktree.

Spotlight's index supports file and content search. Keep indexing for that
functionality, exclude selected folders through
[Spotlight Search Privacy](https://support.apple.com/guide/mac-help/prevent-spotlight-searches-in-files-mchlp2811/mac),
or have an administrator choose whether a volume should be indexed. The
installed `mdutil(1)` manual describes `-s` as a read-only status query,
`-i on|off` as the indexing choice, and `-X` as removing the index directory:
**`-X` does not itself disable indexing**. An erased index may be rebuilt when
indexing is enabled, consuming space and work again; disabling indexing reduces
search coverage. A failed status query is unknown, not evidence of disabled
indexing. disktree does not run these commands or alter indexing settings.

To sign and notarize a build for others, with a Developer ID certificate in
the keychain and credentials saved by `xcrun notarytool store-credentials`:

Expand Down Expand Up @@ -451,8 +504,9 @@ Click `/` (or any directory above the scanned root) in the trail, press
`g`, run `disktree --disk`, or use the launcher's *Scan the whole disk*
action. `g` and `--disk` scan the disk your home directory lives on — `/`
on Omarchy and on macOS. On macOS the Data volume's second mount,
`/System/Volumes/Data`, is skipped: it is `/Users`, `/Applications` and the
rest again under other names.
`/System/Volumes/Data`, exposes only its private `.fseventsd` and
`.Spotlight-V100` stores: `/Users`, `/Applications` and the rest would be
the same files again under other names.

Widening is memoized: the tree already measured is handed to the wider walk
and reused where it is reached, so going from `~` to `/` reads only what is
Expand Down
2 changes: 2 additions & 0 deletions crates/disktree-app/src/palette.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ const fn hue(category: Category) -> (f32, f32) {
Category::Git => (0.955, 1.0),
Category::Media => (0.745, 1.0),
Category::Cache => (0.125, 0.95),
Category::FilesystemEvents => (0.31, 0.6),
Category::Spotlight => (0.855, 0.6),
Category::Documents => (0.6, 0.18),
Category::Other => (0.6, 0.08),
}
Expand Down
8 changes: 7 additions & 1 deletion crates/disktree-app/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -948,7 +948,13 @@ impl Disktree {
cx.notify();
return;
}
if !path.is_absolute() {
// SSH roots use the Unix server's namespace, even on Windows.
let absolute = if self.volumes_source.is_some() {
path.to_str().is_some_and(|path| path.starts_with('/'))
} else {
path.is_absolute()
};
if !absolute {
self.picker_error =
Some("Enter an absolute directory path.".into());
cx.notify();
Expand Down
83 changes: 82 additions & 1 deletion crates/disktree-app/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,76 @@ fn draw(cx: &mut Window) {
});
}

#[cfg(target_os = "macos")]
#[gpui_kit::test]
fn private_service_stores_draw_unavailable_and_partial_figures(
cx: &mut TestAppContext,
) {
use disktree_core::macos_services::{Usage, usage};
use disktree_core::tree::{Metric, Node, NodeKind, aggregate};
cx.update(gpui_omarchy::init);
let temp = fixture();
let (view, cx) = view_over(temp.path(), cx);
update(&view, cx, |app, cx| {
// Replace the fixture's tree without starting a real system scan.
let mut history = Node::directory(".fseventsd");
history.read_error = true;
let mut index = Node::directory(".Spotlight-V100");
index
.children
.push(Node::entry("store", NodeKind::File, 8192));
index.read_error = true;
let mut data = Node::directory("Data");
data.children = vec![history, index];
let mut volumes = Node::directory("Volumes");
volumes.children.push(data);
let mut system = Node::directory("System");
system.children.push(volumes);
let mut root = Node::directory("/");
root.children.push(system);
aggregate(&mut root, Metric::Bytes);
disktree_core::classify::classify(&mut root);
app.tree = Some(std::sync::Arc::new(root));
app.root_path = PathBuf::from("/");
app.selected = None;
app.hovered = None;
app.options = ScanOptions::default();
app.progress.errors = 2;
cx.notify();
});
draw(cx);
assert!(cx.debug_bounds("macos-services").is_some());
read(&view, cx, |app| {
let tree = app.tree().expect("fixture tree");
let data = Path::new(disktree_core::space::MACOS_DATA_VOLUME);
assert_eq!(
usage(&app.root_path, tree, &app.options, &data.join(".fseventsd")),
Usage::Unavailable
);
assert_eq!(
usage(
&app.root_path,
tree,
&app.options,
&data.join(".Spotlight-V100")
),
Usage::Incomplete(8192)
);
});
update(&view, cx, |app, cx| {
app.selected = app.tree().and_then(|tree| {
tree.find(".Spotlight-V100").map(|(crumbs, _)| crumbs)
});
cx.notify();
});
draw(cx);
press(cx, "space");
assert!(read(&view, cx, |app| app.plan().targets.is_empty()));
assert!(read(&view, cx, |app| !app.plan().blocked.is_empty()));
press(cx, "c");
assert_eq!(read(&view, cx, |app| app.screen), Screen::Review);
}

fn press(cx: &mut Window, keys: &str) {
cx.simulate_keystrokes(keys);
draw(cx);
Expand Down Expand Up @@ -451,6 +521,14 @@ fn remote_root_acceptance_never_stats_the_path_locally(
});
draw(cx);
update(&view, cx, |app, cx| {
let before = app.root_path.clone();
for path in ["relative", r"C:\local-only", r"\local-only"] {
app.choose_source_root(PathBuf::from(path), cx);
assert!(app.picker_error.is_some());
assert_eq!(app.root_path, before);
assert!(app.remote.is_none());
assert!(!app.marks.is_empty());
}
app.choose_volume(cx);
assert_eq!(app.remote.as_deref(), Some("-test-fixture"));
assert_eq!(app.root_path, PathBuf::from("/only-on-the-remote-host"));
Expand Down Expand Up @@ -1031,7 +1109,10 @@ fn enter_in_the_focused_volume_picker_scans_the_selected_root(
assert!(!read(&view, cx, Disktree::can_start_over));
press(cx, "enter");
assert!(!read(&view, cx, |app| app.volumes_open));
assert_eq!(read(&view, cx, |app| app.root_path.clone()), target);
assert_eq!(
read(&view, cx, |app| app.root_path.clone()),
dunce::canonicalize(&target).unwrap()
);
finish_scan(&view, cx);
assert!(read(&view, cx, |app| app.tree().is_some()));
}
Expand Down
Loading
Loading