When disk space becomes scarce, identifying which directories or files consume the most storage becomes essential. On UNIX-like systems, ncdu serves as a powerful terminal-based tool that provides an interactive viusalization of disk usage. The name ncdu combines "ncurses" (the library used for its interface) with "du" (the traditional disk usage command). ncdu presents directory contents in a navigable tree structure, allowing users to explore hierarchically rather than displaying everything at once like the standard du command.
ncdu offers intuitive navigation controls: arrow keys for traversal, Enter to descend into directories, Backspace to ascend, and 'd' to delete selected items. However, ncdu is limited to UNIX-like platforms, leaving Windows users without this efficient solution.
This gap inspired the creation of a cross-platform alternative called pyspace (a Python-based space analyzer). Built with Python's native tkinter library, pyspace delivers similar functionality while remaining platform-independent. The application launches as a standalone GUI window when invoked from the command line.
Basic usage:
pyspace [directory_path]
The optional directory_path parameter specifies the target directory. If omitted, pyspace analyzes the current working directory.
Example:
pyspace /home/user/documents
Key features:
Keyboard controls:
- ↑/↓ arrows - Navigate through items
- Right arrow or Enter - Enter selected directory
- Left arrow or Escape - Return to parent directory
- 'd' - Delete selected file or directory (with confirmation)
- 'o' - Open selected file with default application (platform-specific)
Administrative privileges are required when scanning system directories due to permission restrictions. Exercise caution when deleting files from system locations.
Implementation details:
The core consists of a tree structure representing the filesystem, with eacch node calculating its total size recursively. The GUI displays sorted entries with visual size indicators using ASCII progress bars. File sizes are formatted for human readability (KB, MB, GB).
Here's the complete implementation:
import os
import sys
import shutil
import tkinter as tk
from tkinter import ttk
from tkinter.messagebox import askyesno
import subprocess
import platform
def launch_file(filepath):
"""Open file with system's default application"""
system = platform.system()
if system == 'Darwin': # macOS
subprocess.run(['open', filepath])
elif system == 'Windows':
os.startfile(filepath)
else: # Linux and others
subprocess.run(['xdg-open', filepath])
class FileSystemNode:
"""Represents a file or directory node in the filesystem tree"""
def __init__(self, full_path, ancestor=None):
self.full_path = os.path.abspath(full_path)
self.display_name = os.path.basename(self.full_path)
self.total_size = 0
self.ancestor = ancestor
try:
if os.path.isdir(self.full_path):
self.subdirectories = []
self.regular_files = []
self._scan_directory()
else:
self.total_size = os.path.getsize(self.full_path)
except PermissionError:
self.total_size = 0
except OSError as error:
print(f"Skipping {self.full_path}: {error}")
self.total_size = 0
def _scan_directory(self):
"""Recursively scan directory contents and calculate sizes"""
for entry in os.listdir(self.full_path):
entry_path = os.path.join(self.full_path, entry)
child_node = FileSystemNode(entry_path, self)
self._insert_sorted(child_node)
self.total_size += child_node.total_size
def _insert_sorted(self, node):
"""Insert node in sorted order (descending by size)"""
collection = self.subdirectories if node.is_directory() else self.regular_files
inserted = False
for index, existing in enumerate(collection):
if existing.total_size < node.total_size:
collection.insert(index, node)
inserted = True
break
if not inserted:
collection.append(node)
def is_directory(self):
return hasattr(self, 'subdirectories')
def get_path(self):
return self.full_path
def get_name(self):
return self.display_name
def get_size(self):
return self.total_size
def get_children(self):
if self.is_directory():
return self.subdirectories + self.regular_files
return None
def remove_child(self, child):
"""Remove child node and update ancestor sizes"""
collection = self.subdirectories if child.is_directory() else self.regular_files
self._propagate_size_change(-child.total_size)
collection.remove(child)
def _propagate_size_change(self, delta):
"""Propagate size change up the tree"""
self.total_size += delta
if self.ancestor:
self.ancestor._propagate_size_change(delta)
def format_bytes(size_bytes):
"""Convert bytes to human-readable format"""
units = ['B', 'KB', 'MB', 'GB', 'TB']
size = float(size_bytes)
unit_index = 0
while size >= 1024 and unit_index < len(units) - 1:
size /= 1024
unit_index += 1
if unit_index == 0:
return f"{int(size)} {units[unit_index]}"
return f"{size:.2f} {units[unit_index]}"
def create_entry_text(node, largest_size):
"""Generate display text for listbox entry"""
size_str = f"{format_bytes(node.get_size()):>10}"
# Calculate progress bar width
bar_width = 20
if largest_size > 0:
filled_chars = int(bar_width * node.get_size() / largest_size)
else:
filled_chars = 0
progress_bar = f"[{'=' * filled_chars}{' ' * (bar_width - filled_chars)}]"
icon = '/' if node.is_directory() else ' '
return f"{size_str} {progress_bar} {icon} {node.get_name()}"
class DiskAnalyzerGUI(tk.Tk):
"""Main application window for the disk analyzer"""
def __init__(self, root_node):
super().__init__()
self.root_node = root_node
self.current_node = root_node
self._setup_ui()
self._bind_events()
self._refresh_view()
def _setup_ui(self):
"""Initialize the user interface components"""
self.title("PySpace - Disk Usage Analyzer")
self.geometry("800x600")
# Path display
self.path_label = ttk.Label(self, text=self.current_node.get_path(), relief="sunken")
self.path_label.pack(fill="x", padx=5, pady=5)
# Main frame for listbox and scrollbar
main_frame = ttk.Frame(self)
main_frame.pack(fill="both", expand=True, padx=5, pady=5)
# File/directory list
self.item_listbox = tk.Listbox(main_frame, font=("Courier", 10))
self.item_listbox.pack(side="left", fill="both", expand=True)
# Scrollbar
scrollbar = ttk.Scrollbar(main_frame, orient="vertical")
scrollbar.pack(side="right", fill="y")
# Link listbox and scrollbar
self.item_listbox.config(yscrollcommand=scrollbar.set)
scrollbar.config(command=self.item_listbox.yview)
def _bind_events(self):
"""Configure keyboard and mouse event handlers"""
self.bind('<Escape>', lambda e: self.quit())
self.bind('<Control-q>', lambda e: self.quit())
self.item_listbox.bind('<Return>', self._navigate_into)
self.item_listbox.bind('<Right>', self._navigate_into)
self.item_listbox.bind('<BackSpace>', self._navigate_up)
self.item_listbox.bind('<Left>', self._navigate_up)
self.item_listbox.bind('d', self._delete_item)
self.item_listbox.bind('o', self._open_item)
def _refresh_view(self):
"""Update the listbox with current directory contents"""
self.item_listbox.delete(0, tk.END)
# Add parent directory option
if self.current_node.ancestor:
self.item_listbox.insert(tk.END, "(..) Go to parent directory")
# Add directory contents
children = self.current_node.get_children()
if children:
max_size = max(child.get_size() for child in children)
for child in children:
entry_text = create_entry_text(child, max_size)
self.item_listbox.insert(tk.END, entry_text)
# Update path display and set focus
self.path_label.config(text=self.current_node.get_path())
self.item_listbox.focus_set()
if self.item_listbox.size() > 0:
self.item_listbox.selection_set(0)
def _get_selected_item(self):
"""Get the currently selected FileSystemNode"""
selection = self.item_listbox.curselection()
if not selection:
return None
index = selection[0]
# Handle parent directory navigation
if self.current_node.ancestor and index == 0:
return self.current_node.ancestor
# Adjust index for parent directory entry
actual_index = index - (1 if self.current_node.ancestor else 0)
children = self.current_node.get_children()
if 0 <= actual_index < len(children):
return children[actual_index]
return None
def _navigate_into(self, event=None):
"""Enter the selected directory"""
selected = self._get_selected_item()
if selected and selected.is_directory():
self.current_node = selected
self._refresh_view()
def _navigate_up(self, event=None):
"""Navigate to parent directory"""
if self.current_node.ancestor:
self.current_node = self.current_node.ancestor
self._refresh_view()
def _open_item(self, event=None):
"""Open selected file with default application"""
selected = self._get_selected_item()
if selected and not selected.is_directory():
launch_file(selected.get_path())
def _delete_item(self, event=None):
"""Delete selected file or directory after confirmation"""
selected = self._get_selected_item()
if not selected:
return
confirm = askyesno(
"Confirm Deletion",
f"Delete {selected.get_name()}?\n\nPath: {selected.get_path()}\nSize: {format_bytes(selected.get_size())}"
)
if confirm:
try:
if selected.is_directory():
shutil.rmtree(selected.get_path())
else:
os.remove(selected.get_path())
self.current_node.remove_child(selected)
self._refresh_view()
except OSError as error:
print(f"Deletion failed: {error}")
def main():
"""Application entry point"""
target_path = sys.argv[1] if len(sys.argv) > 1 else os.getcwd()
if not os.path.exists(target_path):
print(f"Error: Path '{target_path}' does not exist")
sys.exit(1)
root_node = FileSystemNode(target_path)
app = DiskAnalyzerGUI(root_node)
app.mainloop()
if __name__ == "__main__":
main()