Skip to content

Commit 32daffe

Browse files
zoza1982claude
andauthored
feat(tui): show the volume's free disk space in each pane frame (#171)
* feat(tui): show the volume's free disk space in each pane frame Add a per-pane free-disk-space indicator in the frame's bottom-left (e.g. `137.0 GiB free`), refreshed on every navigate. Shows the space available to the current user (respects root-reserved blocks / quotas). Yields to the filter label when a filter is active; degrades gracefully (truncates) on a very narrow pane. - cairn-types: `SpaceInfo { total, available }` and a `Caps::SPACE` flag. - cairn-vfs: `Vfs::space(path) -> Result<Option<SpaceInfo>>`, default `Ok(None)`. Path-scoped (a backend can straddle mounts). - cairn-backend-local: implement via the cross-platform `fs4` crate (statvfs on Unix, GetDiskFreeSpaceEx on Windows) in spawn_blocking — no unsafe/ADR needed. Advertises `Caps::SPACE`. - Other backends inherit the `None` default (object stores/containers have no meaningful free space). SSH/SFTP via `statvfs@openssh.com` is a tracked follow-up (#170) — russh-sftp only exposes it on the raw session. - cairn-core: `PaneState.space`, cleared on navigate; `AppEvent::SpaceFetched` applied with the same conn/dir staleness guard as `Listed`. - cairn: the runtime fetches space as a non-blocking sibling of each `List` and emits `SpaceFetched` — so the reducer still emits a single effect (no test churn). - cairn-plugin: `SPACE` is host-only (no WIT counterpart yet), like `LOCAL_PATH`. Tests: local `space()` (plausible totals, advertises the cap); reducer (stores on match, ignores stale, cleared on navigate); a pane-free-space scenario + snapshots. README + CHANGELOG. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(tui): hide the free-space label when it would clip; gate the fetch on Caps::SPACE Review fixes: - Narrow-pane clipping (bug-bot): on a half-width pane the free-space label was clipped mid-word to a misleading unitless number (e.g. "137.0"). Only show it when it fits alongside the sort label (measured against the pane width); otherwise omit it entirely. Wide panes render the full "137.0 GiB free". - Gate the runtime's space-fetch on `Caps::SPACE` (code-review): skip the task, the space() call, and the no-op SpaceFetched event for backends that can't report space (object stores, containers) — no pointless work. - Log a debug line on a space() error instead of silently swallowing it. Tests: free-space label shows on a wide pane and is omitted (not clipped) on a narrow one. Snapshots regenerated (40x12 now omits the label). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6abbf7f commit 32daffe

18 files changed

Lines changed: 331 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **Each pane's frame now shows the volume's free disk space** in the bottom-left corner (e.g.
13+
`137.0 GiB free`), refreshed on every navigate. Available to the current user (respects
14+
root-reserved blocks and quotas). Supported on the local backend on all platforms (via a
15+
cross-platform statvfs); other backends that can't report it (object stores, containers) simply
16+
omit it, and it yields to the filter label when a filter is active. (SSH/SFTP free space via
17+
`statvfs@openssh.com` is a tracked follow-up.)
18+
1219
- **`Ctrl-S` calculates a folder's size** (recursively) and shows it in a stats popup: total size
1320
(human-readable + exact bytes) plus file and subfolder counts. The walk runs in the background and
1421
the popup updates live, so a large or remote directory shows progress instead of blocking; `Esc`

Cargo.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,9 @@ right-aligned MC-style; on a narrow pane the columns drop out responsively — p
8989
keeping the date as long as it fits — so the name keeps priority, and are blank for backends that
9090
don't expose that metadata. Dates are shown in UTC (`YYYY-MM-DD`). By default `s` cycles the active pane's sort order (name → size → modified →
9191
type) and `.` toggles whether hidden entries (dotfiles) are listed; the current sort mode and hidden
92-
state show in each pane's bottom-right corner. `Ctrl-R` reloads the active pane. `F7` creates a new
92+
state show in each pane's bottom-right corner, and the volume's **free disk space** (e.g.
93+
`137.0 GiB free`) shows in the bottom-left for backends that report it (local now; SSH is a
94+
follow-up). `Ctrl-R` reloads the active pane. `F7` creates a new
9395
directory and `r` renames the entry under the cursor (`F2` is an alias; both
9496
open a text prompt; `Enter` confirms, `Esc` cancels). `Ctrl-S` recursively calculates the size of
9597
the folder under the cursor and shows it in a stats popup (total size, file and subfolder counts);

crates/cairn-backend-local/Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,11 @@ cairn-types = { workspace = true }
1717
cairn-vfs = { workspace = true }
1818
async-trait = "0.1"
1919
bytes = "1"
20+
# Cross-platform free/total disk space (statvfs on Unix, GetDiskFreeSpaceEx on Windows) behind a
21+
# safe API — avoids hand-rolled Windows FFI (which would need `unsafe` + an ADR per the repo rules).
22+
fs4 = { version = "1", features = ["sync"] }
2023
futures = "0.3"
21-
tokio = { version = "1", features = ["fs", "io-util"] }
24+
tokio = { version = "1", features = ["fs", "io-util", "rt"] }
2225

2326
[dev-dependencies]
2427
tempfile = "3"

crates/cairn-backend-local/src/lib.rs

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
88
use async_trait::async_trait;
99
use bytes::Bytes;
10-
use cairn_types::{Caps, ConnectionId, Entry, EntryKind, Scheme, UnixPerms, VfsPath};
10+
use cairn_types::{Caps, ConnectionId, Entry, EntryKind, Scheme, SpaceInfo, UnixPerms, VfsPath};
1111
use cairn_vfs::{
1212
ByteRange, CapabilityProvider, ListOpts, ListPage, ReadHandle, Recurse, Vfs, VfsError,
1313
WriteHandle, WriteOpts, WriteSink,
@@ -27,7 +27,8 @@ const fn platform_caps() -> Caps {
2727
.union(Caps::RENAME_ATOMIC)
2828
.union(Caps::RANDOM_READ)
2929
.union(Caps::APPEND)
30-
.union(Caps::LOCAL_PATH);
30+
.union(Caps::LOCAL_PATH)
31+
.union(Caps::SPACE);
3132
#[cfg(unix)]
3233
{
3334
base.union(Caps::CHMOD).union(Caps::SYMLINK)
@@ -251,6 +252,26 @@ impl Vfs for LocalVfs {
251252
async fn set_perms(&self, path: &VfsPath, perms: UnixPerms) -> Result<(), VfsError> {
252253
set_perms_impl(&self.resolve(path), path, perms).await
253254
}
255+
256+
async fn space(&self, path: &VfsPath) -> Result<Option<SpaceInfo>, VfsError> {
257+
// `fs4::{available_space,total_space}` are blocking syscalls (statvfs / GetDiskFreeSpaceEx),
258+
// so run them off the async reactor. `resolve` (not the symlink-confined `local_path`) is
259+
// fine here — this is read-only telemetry, not a shell-out containment boundary.
260+
let full = self.resolve(path);
261+
let res = tokio::task::spawn_blocking(move || -> std::io::Result<SpaceInfo> {
262+
Ok(SpaceInfo {
263+
total: fs4::total_space(&full)?,
264+
available: fs4::available_space(&full)?,
265+
})
266+
})
267+
.await;
268+
match res {
269+
Ok(Ok(info)) => Ok(Some(info)),
270+
// A statvfs failure (path vanished, unusual FS) or a join failure degrades to "unknown"
271+
// rather than surfacing an error for a decorative indicator.
272+
_ => Ok(None),
273+
}
274+
}
254275
}
255276

256277
#[cfg(unix)]
@@ -431,6 +452,29 @@ mod tests {
431452
assert!(matches!(res, Err(VfsError::AlreadyExists(_))));
432453
}
433454

455+
#[tokio::test]
456+
async fn space_reports_a_plausible_volume() {
457+
let (_dir, vfs) = backend();
458+
let info = vfs
459+
.space(&p("/"))
460+
.await
461+
.expect("space call succeeds")
462+
.expect("local backend reports space");
463+
assert!(info.total > 0, "a real volume has non-zero capacity");
464+
assert!(
465+
info.available <= info.total,
466+
"available ({}) must not exceed total ({})",
467+
info.available,
468+
info.total
469+
);
470+
}
471+
472+
#[test]
473+
fn local_backend_advertises_the_space_cap() {
474+
let (_dir, vfs) = backend();
475+
assert!(vfs.caps().contains(Caps::SPACE));
476+
}
477+
434478
#[test]
435479
fn local_path_resolves_an_in_root_file() {
436480
let (dir, vfs) = backend();

crates/cairn-core/src/msg.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,19 @@ pub enum AppEvent {
198198
/// The page result.
199199
result: Result<ListPage, VfsError>,
200200
},
201+
/// Free/total disk space for a pane's directory. The runtime fetches it when it handles the
202+
/// pane's [`AppEffect::List`] (a sibling of the listing), and delivers it here. Applied only if
203+
/// the pane is still on the same `conn`/`dir`. `space` is `None` when unavailable.
204+
SpaceFetched {
205+
/// Which pane requested it.
206+
pane: Side,
207+
/// The connection it was measured on.
208+
conn: ConnectionId,
209+
/// The directory whose volume was measured.
210+
dir: VfsPath,
211+
/// The space totals, or `None` if the backend couldn't report them.
212+
space: Option<cairn_types::SpaceInfo>,
213+
},
201214
/// Pre-flight sizing progress for an in-flight transfer: the destination conflict-check and the
202215
/// recursive size scan walking the source tree, before any bytes move. Carries the running count
203216
/// of visited entries, the bytes discovered so far, and the path currently being visited so the

crates/cairn-core/src/state.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,10 @@ pub struct PaneState {
257257
/// *inside* another mounted archive nests correctly. Empty for a pane that has never mounted
258258
/// an archive.
259259
pub mount_stack: Vec<MountFrame>,
260+
/// Free/total space for the volume backing this pane's directory, shown in the pane frame.
261+
/// `None` while loading or when the backend can't report it (object stores, containers, or an
262+
/// SFTP server without the extension). Fetched alongside each listing; cleared on navigate.
263+
pub space: Option<cairn_types::SpaceInfo>,
260264
/// When the next listing for this pane arrives, place the cursor on the entry with this name
261265
/// (instead of the default top row). Set by `leave_dir` to the name of the directory being
262266
/// exited, so going up (`..`) returns the cursor to the child you came from — MC behaviour —
@@ -290,6 +294,7 @@ impl PaneState {
290294
filter: None,
291295
filter_editing: false,
292296
mount_stack: Vec::new(),
297+
space: None,
293298
select_after_load: None,
294299
}
295300
}

crates/cairn-core/src/update.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3740,6 +3740,9 @@ fn navigate(state: &mut AppState, side: Side, dir: cairn_types::VfsPath) -> Vec<
37403740
p.filter_editing = false;
37413741
// Default to the top row; `leave_dir` overrides this after the call to land on the exited child.
37423742
p.select_after_load = None;
3743+
// Clear the stale free-space figure; the runtime refills it (via a `SpaceFetched` event) when it
3744+
// handles the `List` effect below — so the reducer still emits a single effect here.
3745+
p.space = None;
37433746
vec![AppEffect::List {
37443747
pane: side,
37453748
conn: p.conn,
@@ -3753,6 +3756,7 @@ fn reload(state: &mut AppState, side: Side) -> Vec<AppEffect> {
37533756
let dir = p.cwd.clone();
37543757
p.listing = Listing::Loading;
37553758
p.marked.clear();
3759+
p.space = None;
37563760
vec![AppEffect::List {
37573761
pane: side,
37583762
conn: p.conn,
@@ -3816,6 +3820,20 @@ fn apply_event(state: &mut AppState, event: AppEvent) -> Vec<AppEffect> {
38163820
}
38173821
Vec::new()
38183822
}
3823+
AppEvent::SpaceFetched {
3824+
pane,
3825+
conn,
3826+
dir,
3827+
space,
3828+
} => {
3829+
// Ignore a stale result for a directory/connection the pane has moved on from (same guard
3830+
// as `Listed`).
3831+
let p = state.pane_mut(pane);
3832+
if p.cwd == dir && p.conn == conn {
3833+
p.space = space;
3834+
}
3835+
Vec::new()
3836+
}
38193837
AppEvent::AiPlanProposed(Ok(plan)) => {
38203838
state.ai_pending = false;
38213839
if plan.steps.is_empty() {
@@ -7115,6 +7133,61 @@ mod tests {
71157133
assert!(!s.active().marked.contains(&0));
71167134
}
71177135

7136+
#[test]
7137+
fn space_fetched_stores_on_the_matching_pane_and_ignores_stale() {
7138+
let mut s = state();
7139+
let conn = s.pane(Side::Left).conn;
7140+
let dir = s.pane(Side::Left).cwd.clone();
7141+
let info = cairn_types::SpaceInfo {
7142+
total: 1000,
7143+
available: 250,
7144+
};
7145+
// A result for the pane's current conn/dir is stored.
7146+
let _ = update(
7147+
&mut s,
7148+
Msg::Event(AppEvent::SpaceFetched {
7149+
pane: Side::Left,
7150+
conn,
7151+
dir: dir.clone(),
7152+
space: Some(info),
7153+
}),
7154+
);
7155+
assert_eq!(s.pane(Side::Left).space, Some(info));
7156+
7157+
// A stale result (different dir) is ignored — the pane keeps its value.
7158+
let _ = update(
7159+
&mut s,
7160+
Msg::Event(AppEvent::SpaceFetched {
7161+
pane: Side::Left,
7162+
conn,
7163+
dir: VfsPath::parse("/elsewhere").unwrap(),
7164+
space: None,
7165+
}),
7166+
);
7167+
assert_eq!(
7168+
s.pane(Side::Left).space,
7169+
Some(info),
7170+
"a stale space result must not clobber the current one"
7171+
);
7172+
}
7173+
7174+
#[test]
7175+
fn navigate_clears_the_stale_free_space() {
7176+
let mut s = state();
7177+
s.pane_mut(Side::Left).space = Some(cairn_types::SpaceInfo {
7178+
total: 1000,
7179+
available: 250,
7180+
});
7181+
deliver(&mut s, Side::Left, vec![Entry::new("d", EntryKind::Dir)]);
7182+
s.pane_mut(Side::Left).cursor = 0;
7183+
let _ = update(&mut s, Msg::Action(Action::Enter));
7184+
assert_eq!(
7185+
s.pane(Side::Left).space,
7186+
None,
7187+
"navigating clears the previous volume's free-space figure"
7188+
);
7189+
}
7190+
71187191
#[test]
71197192
fn stale_listing_is_ignored() {
71207193
let mut s = state();

crates/cairn-plugin/src/component.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -749,10 +749,11 @@ mod tests {
749749

750750
#[test]
751751
fn map_caps_covers_every_wit_flag() {
752-
// Every WIT `caps` flag must be translated; the host-only `LOCAL_PATH` has no WIT counterpart.
753-
// This breaks if either side grows a flag without updating `map_caps`.
752+
// Every WIT `caps` flag must be translated. Host-only caps have no WIT counterpart: a plugin
753+
// backend can't currently advertise `LOCAL_PATH` (real OS path) or `SPACE` (statvfs) — both
754+
// are host-implemented. This breaks if either side grows a flag without updating `map_caps`.
754755
let mapped = map_caps(Caps0::all());
755-
assert_eq!(mapped, Caps::all() & !Caps::LOCAL_PATH);
756+
assert_eq!(mapped, Caps::all() & !Caps::LOCAL_PATH & !Caps::SPACE);
756757
}
757758

758759
#[test]

crates/cairn-tui/src/render.rs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1605,16 +1605,33 @@ fn render_pane(frame: &mut Frame, area: Rect, state: &AppState, side: Side, them
16051605
// Bottom-right status: current sort mode, plus a `+hidden` flag when dotfiles are shown.
16061606
let hidden = if pane.show_hidden { " +hidden" } else { "" };
16071607
let status = format!(" sort: {}{hidden} ", pane.sort.label());
1608-
// No `overlay_base` here: a pane isn't an overlay — it already sits on the theme background painted
1609-
// over the whole frame at the top of `render()`, so it needs no fill of its own.
1608+
let status_w = status.chars().count(); // captured before `status` is moved into the title below
1609+
// No `overlay_base` here: a pane isn't an overlay — it already sits on the theme background painted
1610+
// over the whole frame at the top of `render()`, so it needs no fill of its own.
16101611
let mut block = Block::bordered()
16111612
.title(title)
16121613
.title_bottom(Line::from(status).right_aligned())
16131614
.border_style(Style::default().fg(border));
1614-
// Bottom-left: the active filter (a trailing `_` marks live editing).
1615+
// Bottom-left: the active filter (a trailing `_` marks live editing), or — when not filtering —
1616+
// the free disk space of the volume backing this pane (backends that report it: local, and SSH
1617+
// once wired). The filter takes precedence since it's transient and interactive.
16151618
if let Some(f) = &pane.filter {
16161619
let cursor = if pane.filter_editing { "_" } else { "" };
16171620
block = block.title_bottom(Line::from(format!(" filter: {f}{cursor} ")).left_aligned());
1621+
} else if let Some(space) = pane.space {
1622+
let text = format!(" {} free ", human_bytes(space.available));
1623+
// Only show it if it fits alongside the right-aligned sort label without the two colliding
1624+
// (ratatui would otherwise clip the left title mid-word, e.g. a misleading unitless `137.0`).
1625+
// Interior width is the pane minus its two border columns; leave a 1-col gap between them.
1626+
let interior = usize::from(area.width).saturating_sub(2);
1627+
// `<` (not `<=`) leaves at least a one-column gap between the two titles.
1628+
if text.chars().count() + status_w < interior {
1629+
block = block.title_bottom(
1630+
Line::from(text)
1631+
.left_aligned()
1632+
.style(Style::default().fg(theme.status)),
1633+
);
1634+
}
16181635
}
16191636

16201637
match &pane.listing {

0 commit comments

Comments
 (0)