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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,28 @@ Built with [GPUI](https://gpui-kit.com/) through
[gpui-omarchy](https://github.com/huacnlee/gpui-omarchy), so it follows your
Omarchy theme and behaves like the rest of the desktop.

## Resize a partition (Linux)

The Disk panel's **Resize partition…** action opens the local volume's
physical disk in [GParted](https://gparted.org/). Select the partition in
GParted, choose **Resize/Move** to grow or shrink it, then review and apply
the queued operations. Opening the editor does not change the disk.
Install `gparted` separately; it is an optional dependency and handles its
own administrator authentication. Disktree stays unprivileged and refreshes
the scan after the editor closes.

Growing needs adjacent unallocated space. Shrinking depends on filesystem
support; free space inside another partition is not unallocated space.
Back up important files before resizing. Use a live USB when the system
partition is busy or an operation requires unmounting. LUKS volumes must be
unlocked in GParted; support depends on the complete storage layout. This
does not change a physical disk's capacity. See the
[GParted manual](https://gparted.org/display-doc.php?name=help-manual).

This action is for local Linux disks and simple partition/LUKS layouts.
SSH sessions remain read-only. RAID, LVM, loop devices and ambiguous device
ancestry are refused rather than selecting an arbitrary disk.

## Install

Download `disktree-*-x86_64-linux.tar.gz` (`aarch64-linux` on ARM) from the
Expand Down
2 changes: 2 additions & 0 deletions crates/disktree-app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ mod git;
mod marks;
mod palette;
mod power;
#[cfg(target_os = "linux")]
mod resize;
mod state;
#[cfg(test)]
mod tests;
Expand Down
178 changes: 178 additions & 0 deletions crates/disktree-app/src/resize.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
//! Linux partition resizing is handed to `GParted`, never to shell snippets.

use disktree_core::partition_editor::{self, Target};
use gpui_kit::{
Context, InteractiveElement as _, IntoElement, ParentElement, Styled, div,
};
use gpui_omarchy::{
ButtonVariant, Status, alert_dialog, dialog_button, dialog_description,
dialog_popup, dialog_title,
};

use crate::state::{Disktree, FocusTarget};
use crate::ui::{size, space};
use crate::views::centred_popup;

#[derive(Debug)]
pub enum Dialog {
Loading,
Ready(Target),
Editing,
Failed(String),
}

impl Disktree {
pub fn open_resize(&mut self, cx: &mut Context<'_, Self>) {
if !self.supports_resize() || !self.can_start_over() {
return;
}
self.resize_epoch += 1;
let epoch = self.resize_epoch;
self.resize_dialog = Some(Dialog::Loading);
self.focus_request = Some(FocusTarget::Dialog);
let root = self.root_path.clone();
let task = cx.background_executor().spawn(async move {
partition_editor::prepare(&root).map_err(|error| error.to_string())
});
cx.spawn(async move |this, cx| {
let result = task.await;
let _ = this.update(cx, |this, cx| {
if this.resize_epoch == epoch && this.resize_open() {
this.resize_dialog = Some(match result {
Ok(target) => Dialog::Ready(target),
Err(error) => Dialog::Failed(error),
});
cx.notify();
}
});
})
.detach();
cx.notify();
}

pub fn close_resize(&mut self, cx: &mut Context<'_, Self>) {
if matches!(self.resize_dialog, Some(Dialog::Editing)) {
return;
}
self.resize_epoch += 1;
self.resize_dialog = None;
self.focus_request = Some(FocusTarget::Root);
cx.notify();
}

pub fn launch_partition_editor(&mut self, cx: &mut Context<'_, Self>) {
// Enter while discovery is pending or after launch cannot start an
// editor. A local Ready target is the only actionable state.
if self.remote.is_some() {
return;
}
let Some(Dialog::Ready(target)) = &self.resize_dialog else {
return;
};
let target = target.clone();
self.resize_dialog = Some(Dialog::Editing);
let task = cx.background_executor().spawn(async move {
partition_editor::open(&target).map_err(|error| error.to_string())
});
cx.spawn(async move |this, cx| {
let result = task.await;
let _ = this.update(cx, |this, cx| {
match result {
Ok(()) => {
this.resize_dialog = None;
this.focus_request = Some(FocusTarget::Root);
this.notice = Some((
"Partition editor closed; refreshing disk usage."
.into(),
Status::Neutral,
));
this.start_scan(cx);
}
Err(error) => {
this.resize_dialog = Some(Dialog::Failed(error));
}
}
cx.notify();
});
})
.detach();
cx.notify();
}
}

pub fn dialog(
app: &Disktree,
cx: &mut Context<'_, Disktree>,
) -> impl IntoElement {
let ready = matches!(app.resize_dialog, Some(Dialog::Ready(_)));
let editing = matches!(app.resize_dialog, Some(Dialog::Editing));
let message = match &app.resize_dialog {
Some(Dialog::Ready(target)) => format!(
"Open {} in GParted to grow or shrink {}. \
Select it there, choose Resize/Move, then review \
and apply the queued changes.",
target.disk.display(),
target.partition.as_ref().map_or_else(
|| "a partition".into(),
|partition| partition.display().to_string()
)
),
Some(Dialog::Loading) => {
"Finding the disk and partition editor…".into()
}
Some(Dialog::Editing) => "GParted is open. Close it to return here; \
disk usage will refresh afterward."
.into(),
Some(Dialog::Failed(error)) => error.clone(),
None => String::new(),
};
let popup = dialog_popup(cx)
.w(size::SOURCE_PICKER)
.child(dialog_title("Resize partition", cx))
.child(dialog_description(message, cx))
.child(dialog_description(
"Growing needs adjacent unallocated space. Shrinking depends on \
the filesystem. Resizing does not change a physical disk's capacity.", cx))
.child(dialog_description(
"Back up important files first. Use a live USB if the system \
partition is busy or the operation requires unmounting. \
Encrypted volumes must be unlocked in GParted; support depends \
on the storage layout. No changes are applied by opening the editor.", cx))
.child(div().flex().flex_row().justify_end().gap(space::SM)
.child(dialog_button("resize-cancel", "Close", ButtonVariant::Secondary, cx)
.disabled(editing)
.debug_selector(|| "resize-cancel".into())
.on_click(cx.listener(|this, _, window, cx| {
this.close_resize(cx);
this.apply_focus(window, cx);
})))
.child(dialog_button("resize-open", "Open GParted", ButtonVariant::Primary, cx)
.disabled(!ready)
.debug_selector(|| "resize-open".into())
.on_click(cx.listener(|this, _, _, cx| this.launch_partition_editor(cx)))));
let cancel = cx.entity().downgrade();
let outside = cancel.clone();
let accept = cancel.clone();
let popup = div().debug_selector(|| "resize-dialog".into()).child(popup);
alert_dialog(&app.confirm_focus, cx)
.open(true)
.on_ok(move |_, _, cx| {
let _ = accept.update(cx, |this, cx| {
this.launch_partition_editor(cx);
});
false
})
.on_cancel(move |_, window, cx| {
let _ = cancel.update(cx, |this, cx| {
this.close_resize(cx);
this.apply_focus(window, cx);
});
false
})
.popup(centred_popup(popup, move |_, window, cx| {
let _ = outside.update(cx, |this, cx| {
this.close_resize(cx);
this.apply_focus(window, cx);
});
}))
}
32 changes: 30 additions & 2 deletions crates/disktree-app/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,10 @@ pub struct Disktree {
/// The permanent-deletion alert dialog is open. Trash needs no dialog: it
/// is reversible, so it commits directly.
pub confirm_open: bool,
#[cfg(target_os = "linux")]
pub resize_dialog: Option<crate::resize::Dialog>,
#[cfg(target_os = "linux")]
pub resize_epoch: u64,
/// Focus owner for the alert dialog while it is open.
pub confirm_focus: FocusHandle,
/// Focus to move on the next occasion a window is in hand. Key handling
Expand Down Expand Up @@ -572,6 +576,10 @@ impl Disktree {
},
trash_backend,
confirm_open: false,
#[cfg(target_os = "linux")]
resize_dialog: None,
#[cfg(target_os = "linux")]
resize_epoch: 0,
confirm_focus: cx.focus_handle(),
focus_request: None,
window_title: String::new(),
Expand Down Expand Up @@ -737,7 +745,10 @@ impl Disktree {
/// 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>) {
if self.run.is_some() || self.screen != Screen::Explore {
if self.run.is_some()
|| self.screen != Screen::Explore
|| self.resize_open()
{
return;
}
self.volumes_open = true;
Expand Down Expand Up @@ -2843,6 +2854,23 @@ impl Disktree {
self.screen == Screen::Explore
&& !self.confirm_open
&& !self.volumes_open
&& !self.resize_open()
}

pub const fn resize_open(&self) -> bool {
#[cfg(target_os = "linux")]
return self.resize_dialog.is_some();
#[cfg(not(target_os = "linux"))]
false
}

#[cfg(target_os = "linux")]
pub fn supports_resize(&self) -> bool {
self.remote.is_none()
&& self
.device
.as_deref()
.is_some_and(|d| d.starts_with("/dev/"))
}

/// Ask for a directory and scan it: an app opened from the Dock has no
Expand Down Expand Up @@ -3049,7 +3077,7 @@ impl Disktree {

// The alert dialog owns Enter and Escape while it is open; a key that
// bubbles up to here must not also act on the screen behind it.
if self.confirm_open {
if self.confirm_open || self.resize_open() {
return;
}

Expand Down
Loading