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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,31 @@ All notable changes to this project are documented here. Format: [Keep a Changel

## [Unreleased]

### Added
- Undo: developer build folders and uninstall remnants go to the freedesktop Trash; `marmot undo` (or the Undo button) restores the last batch. Every action is logged to `~/.local/state/marmot/history.jsonl` (`marmot history`).
- Dry-run for every cleaner item (Preview button, `marmot clean --dry-run`).
- Package backends for dnf (dnf4/dnf5), zypper and apk, plus Flatpak apps in the uninstaller.
- Command line: `marmot status|scan|clean|undo|history`, with `--json` output.
- Cleaner: per-folder `~/.cache` selection, orphaned packages, unused Flatpak runtimes, Docker/Podman unused data, old Snap revisions.
- Disk Analyzer: cached directory sizes (instant back/forward), progress, cancel on navigation, percentage bars, stays on one filesystem.

- Redesigned TUI: theme-aware cards, CPU/network sparklines, colour-coded bars, top processes, system card;
number keys 1–5 switch tabs; confirmation dialog before cleaning/uninstalling.
- Disk Analyzer: `d` moves the highlighted item to Trash (home folder only, undoable).
- Optimizer: every task shows whether it applies on this machine and why; new "Trim SSDs"; `marmot optimize`.

### Changed
- Optimizer "Drop page cache" is labelled as benchmarking-only, "Clear failed services" says it does not fix them.
- Uninstaller loads the package list once and filters in memory (no package-manager call per keystroke).
- Scans, cleaning and uninstalling run off the UI thread, so the TUI no longer freezes during a pkexec prompt.

### Fixed
- **Reclaim swap left zram/systemd swap disabled**: it ran `swapoff -a && swapon -a`, and `swapon -a` only
re-enables fstab entries. Now each device is cycled on its own and restored with its priority.
- File and package names containing `[` were swallowed as markup (e.g. the `[DIR]` prefix in the analyzer).
- Operation logs printed raw `[success]` markup instead of colours.
- Journal size was always reported as 0 when `journalctl` printed sizes like `1.2G`.

## [1.1.0]

### Added
Expand Down
29 changes: 24 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,33 @@ Upgrade with `uv tool upgrade marmot-tui`, remove with `uv tool uninstall marmot

**Supported distributions**

| Family | Package backend | Status |
|---|---|---|
| Debian, Ubuntu, Mint, Pop!_OS, Kali | apt / dpkg | full |
| Arch, Manjaro, EndeavourOS, CachyOS | pacman | full |
| Others (Fedora, openSUSE, Alpine...) | none yet | Monitor, Cleaner (no package cache), Analyzer, Optimizer |
| Family | Package backend |
|---|---|
| Debian, Ubuntu, Mint, Pop!_OS, Kali | apt / dpkg |
| Arch, Manjaro, EndeavourOS, CachyOS | pacman |
| Fedora, RHEL, Rocky, Alma | dnf (dnf4 and dnf5) |
| openSUSE Tumbleweed / Leap | zypper |
| Alpine | apk |
| any distro | Flatpak apps (uninstall, unused runtimes) |

Docker/Podman and Snap cleanup appear automatically when those tools are installed.

Journal cleaning and service reset need systemd.

### Command line

Every cleaning action also works without the TUI (scripts, cron, SSH):

```bash
marmot status [--json] # CPU, RAM, disks, battery
marmot scan [--json] # what can be cleaned, with item ids
marmot clean user_cache dev_junk --dry-run
marmot clean user_cache --keep-cache mozilla yay --yes
marmot undo # restore what the last action moved to Trash
marmot history
marmot optimize [ID] [--dry-run] # maintenance tasks and whether they apply
```

### Keyboard Navigation & Controls

- `TAB` / `Shift+TAB` : Switch between active Tabs (`Monitor`, `Cleaner`, `Uninstaller`, `Disk Analyzer`, `Optimizer`)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Homepage = "https://github.com/aquistusfami/Marmot"
Issues = "https://github.com/aquistusfami/Marmot/issues"

[project.scripts]
marmot = "marmot.app:main"
marmot = "marmot.cli:main"

[dependency-groups]
dev = ["pytest>=8", "pytest-cov>=5", "ruff>=0.6"]
Expand Down
6 changes: 4 additions & 2 deletions src/marmot/__main__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from marmot.app import main
import sys

main()
from marmot.cli import main

sys.exit(main())
109 changes: 73 additions & 36 deletions src/marmot/analyzer.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,29 @@
import os
import stat
from collections.abc import Callable
from pathlib import Path


class ScanCancelled(Exception):
pass


class DiskAnalyzer:
"""ncdu-like sizes. Every directory size computed is cached, so going into a child or back
to the parent after a scan is instant. `invalidate()` drops the cache (Rescan, after cleaning)."""

def __init__(self):
self.current_path = Path.home()

self._cache: dict[str, int] = {}

def set_path(self, path_str: str):
path = Path(path_str)
if path.exists() and path.is_dir():
self.current_path = path

def invalidate(self) -> None:
self._cache.clear()

def format_size(self, size_bytes: int) -> str:
if size_bytes == 0:
return "0 B"
Expand All @@ -21,50 +34,74 @@ def format_size(self, size_bytes: int) -> str:
i += 1
return f"{size_bytes:.2f} {size_name[i]}"

def _recursive_size(self, path_str: str) -> int:
import stat
total = 0
def _recursive_size(self, path_str: str, cancelled: Callable[[], bool] | None = None) -> int:
"""Apparent size of a file, or of a directory tree (symlinks count 0, other filesystems skipped)."""
cached = self._cache.get(path_str)
if cached is not None:
return cached
try:
st = os.lstat(path_str)
if stat.S_ISREG(st.st_mode):
return st.st_size
elif stat.S_ISLNK(st.st_mode):
return 0

for root, _dirs, files in os.walk(path_str):
for f in files:
except OSError:
return 0
if stat.S_ISLNK(st.st_mode):
return 0
if not stat.S_ISDIR(st.st_mode):
return st.st_size
return self._dir_size(path_str, st.st_dev, cancelled)

def _dir_size(self, path_str: str, dev: int, cancelled) -> int:
cached = self._cache.get(path_str)
if cached is not None:
return cached
if cancelled and cancelled():
raise ScanCancelled
total = 0
try:
with os.scandir(path_str) as it:
for entry in it:
try:
fp = os.path.join(root, f)
fst = os.lstat(fp)
if not stat.S_ISLNK(fst.st_mode):
total += fst.st_size
if entry.is_symlink():
continue
if entry.is_dir(follow_symlinks=False):
# like `du -x`: don't wander into /proc, network mounts, other disks
if entry.stat(follow_symlinks=False).st_dev == dev:
total += self._dir_size(entry.path, dev, cancelled)
else:
total += entry.stat(follow_symlinks=False).st_size
except OSError:
pass
except Exception:
except OSError:
pass
self._cache[path_str] = total
return total

def scan_current(self) -> list:
def scan_current(self, progress: Callable[[int, int, str], None] | None = None,
cancelled: Callable[[], bool] | None = None) -> list:
"""Sizes of the current directory's entries, biggest first.
`progress(done, total, name)` is called per entry; raises ScanCancelled if `cancelled()` turns true."""
items = []
try:
for entry in os.scandir(self.current_path):
try:
is_directory = entry.is_dir(follow_symlinks=False)
if is_directory:
size = self._recursive_size(entry.path)
else:
size = entry.stat().st_size

items.append({
'name': entry.name,
'path': entry.path,
'size': size,
'size_str': self.format_size(size),
'is_dir': is_directory
})
except OSError:
pass
items.sort(key=lambda x: x['size'], reverse=True)
entries = list(os.scandir(self.current_path))
except Exception:
pass
return items
for n, entry in enumerate(entries):
if progress:
progress(n, len(entries), entry.name)
try:
is_directory = entry.is_dir(follow_symlinks=False)
size = self._recursive_size(entry.path, cancelled) if is_directory else entry.stat().st_size
items.append({
'name': entry.name,
'path': entry.path,
'size': size,
'size_str': self.format_size(size),
'is_dir': is_directory
})
except OSError:
pass
items.sort(key=lambda x: x['size'], reverse=True)
total = sum(i['size'] for i in items) or 1
for i in items:
i['percent'] = i['size'] * 100 / total
self._cache[str(self.current_path)] = sum(i['size'] for i in items)
return items
Loading
Loading