From 87d3c1d2a0759c1622b905e85ae7732f90fdbc04 Mon Sep 17 00:00:00 2001 From: ado11231 Date: Fri, 2 Oct 2026 14:15:30 -0400 Subject: [PATCH 1/2] feat(core): read and remove simulator runtimes and Docker data through their own tools Runtimes come from xcrun simctl runtime list, and only an ID of hex digits and hyphens is ever passed to simctl runtime delete. Docker's usage comes from docker system df, and docker system prune --all --force clears unused images, containers, networks, and build cache, keeping volumes. Neither can go to the Trash, so SAFETY.md now names them as the one thing neet removes for good. --- crates/neet-core/src/lib.rs | 1 + crates/neet-core/src/tools.rs | 293 ++++++++++++++++++++++++++++++++++ docs/SAFETY.md | 25 ++- 3 files changed, 316 insertions(+), 3 deletions(-) create mode 100644 crates/neet-core/src/tools.rs diff --git a/crates/neet-core/src/lib.rs b/crates/neet-core/src/lib.rs index e33a1cc..df7fb4e 100644 --- a/crates/neet-core/src/lib.rs +++ b/crates/neet-core/src/lib.rs @@ -8,5 +8,6 @@ pub mod rules; pub mod safety; pub mod scan; pub mod size; +pub mod tools; pub mod trash; pub mod tree; diff --git a/crates/neet-core/src/tools.rs b/crates/neet-core/src/tools.rs new file mode 100644 index 0000000..df6e6ac --- /dev/null +++ b/crates/neet-core/src/tools.rs @@ -0,0 +1,293 @@ +//! Space only its own tool can free: simulator runtimes, through +//! `xcrun simctl`, and Docker's images and containers, through `docker`. +//! Neither can go to the Trash, so removing them is for good. See Tools +//! neet Runs in `docs/SAFETY.md`. + +use std::collections::HashMap; +use std::io; +use std::path::Path; +use std::process::{Command, Output}; + +use serde::Deserialize; + +/// One simulator runtime Xcode downloaded +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Runtime { + /// The ID `simctl` knows it by + pub identifier: String, + /// The platform and version, such as `iOS 18.6` + pub name: String, + pub build: String, + pub size: u64, + /// When a simulator last used it, as `simctl` gives it + pub last_used: Option, + /// Whether `simctl` lets it be deleted + pub deletable: bool, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawRuntime { + identifier: String, + #[serde(default)] + version: String, + #[serde(default)] + build: String, + #[serde(default)] + size_bytes: u64, + #[serde(default)] + last_used_at: Option, + #[serde(default)] + deletable: bool, + #[serde(default)] + platform_identifier: String, +} + +/// The platform's name from its identifier, such as `iOS` +fn platform(identifier: &str) -> &'static str { + match identifier { + "com.apple.platform.iphonesimulator" => "iOS", + "com.apple.platform.appletvsimulator" => "tvOS", + "com.apple.platform.watchsimulator" => "watchOS", + "com.apple.platform.xrsimulator" => "visionOS", + _ => "Simulator", + } +} + +fn parse_runtimes(json: &[u8]) -> io::Result> { + let raw: HashMap = serde_json::from_slice(json) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + let mut runtimes: Vec = raw + .into_values() + .map(|runtime| Runtime { + name: format!( + "{} {}", + platform(&runtime.platform_identifier), + runtime.version + ), + identifier: runtime.identifier, + build: runtime.build, + size: runtime.size_bytes, + last_used: runtime.last_used_at, + deletable: runtime.deletable, + }) + .collect(); + runtimes.sort_by(|a, b| b.size.cmp(&a.size).then_with(|| a.name.cmp(&b.name))); + Ok(runtimes) +} + +/// An error from a tool's output, with what it printed +fn failed(output: &Output) -> io::Error { + let message = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if message.is_empty() { + io::Error::other(format!("the tool stopped with {}", output.status)) + } else { + io::Error::other(message) + } +} + +/// Every simulator runtime, largest first. Empty when there are no +/// simulators on this Mac, so `xcrun` is never run where it might ask to +/// install developer tools. +/// +/// # Errors +/// +/// Returns an error if `simctl` could not run or gave output neet cannot read. +pub fn runtimes() -> io::Result> { + if !Path::new("/Library/Developer/CoreSimulator").exists() { + return Ok(Vec::new()); + } + let output = Command::new("/usr/bin/xcrun") + .args(["simctl", "runtime", "list", "-j"]) + .output()?; + if !output.status.success() { + return Err(failed(&output)); + } + parse_runtimes(&output.stdout) +} + +/// Whether `identifier` looks like a runtime ID: letters, digits, and +/// hyphens only, so it can never be `all` or an option. +fn is_runtime_id(identifier: &str) -> bool { + identifier.len() >= 8 + && identifier + .chars() + .all(|c| c.is_ascii_hexdigit() || c == '-') +} + +/// Asks `simctl` to delete one runtime, for good. Simulators that are +/// running on it are shut down first. +/// +/// # Errors +/// +/// Returns an error if the ID is not a runtime ID, or `simctl` failed. +pub fn delete_runtime(identifier: &str) -> io::Result<()> { + if !is_runtime_id(identifier) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "not a simulator runtime ID", + )); + } + let output = Command::new("/usr/bin/xcrun") + .args(["simctl", "runtime", "delete", identifier]) + .output()?; + if output.status.success() { + Ok(()) + } else { + Err(failed(&output)) + } +} + +/// One line of `docker system df`, such as images or the build cache. +/// Docker gives the sizes as text, such as `1.808GB`. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct DockerUsage { + #[serde(rename = "Type")] + pub kind: String, + pub total_count: String, + pub active: String, + pub size: String, + pub reclaimable: String, +} + +/// What Docker could say about its space +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Docker { + NotInstalled, + /// Installed, but Docker Desktop is not running + NotRunning, + Usage(Vec), +} + +/// The `docker` program, found where Docker Desktop and Homebrew put it +fn docker_program() -> Option<&'static str> { + [ + "/usr/local/bin/docker", + "/opt/homebrew/bin/docker", + "/Applications/Docker.app/Contents/Resources/bin/docker", + ] + .into_iter() + .find(|path| Path::new(path).exists()) +} + +fn parse_docker_usage(text: &[u8]) -> io::Result> { + String::from_utf8_lossy(text) + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| { + serde_json::from_str(line) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) + }) + .collect() +} + +/// How much space Docker's images, containers, volumes, and build cache +/// take, asked of `docker system df`. Nothing changes. +/// +/// # Errors +/// +/// Returns an error if Docker gave output neet cannot read. +pub fn docker_usage() -> io::Result { + let Some(docker) = docker_program() else { + return Ok(Docker::NotInstalled); + }; + let output = Command::new(docker) + .args(["system", "df", "--format", "json"]) + .output()?; + if !output.status.success() { + let message = String::from_utf8_lossy(&output.stderr); + if message.contains("Cannot connect to the Docker daemon") { + return Ok(Docker::NotRunning); + } + return Err(failed(&output)); + } + parse_docker_usage(&output.stdout).map(Docker::Usage) +} + +/// Opens Docker Desktop, which starts Docker in the background. +/// +/// # Errors +/// +/// Returns an error if macOS could not open it. +pub fn start_docker() -> io::Result<()> { + let output = Command::new("/usr/bin/open") + .args(["-a", "Docker"]) + .output()?; + if output.status.success() { + Ok(()) + } else { + Err(failed(&output)) + } +} + +/// Runs `docker system prune -a -f`: removes stopped containers, networks +/// no container uses, every image no container uses, and the build cache, +/// for good. Volumes are kept. Returns the space Docker says it reclaimed. +/// +/// # Errors +/// +/// Returns an error if Docker is not installed, or the prune failed. +pub fn docker_prune() -> io::Result { + let docker = docker_program() + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Docker is not installed"))?; + let output = Command::new(docker) + .args(["system", "prune", "--all", "--force"]) + .output()?; + if !output.status.success() { + return Err(failed(&output)); + } + let text = String::from_utf8_lossy(&output.stdout); + Ok(text + .lines() + .find_map(|line| line.strip_prefix("Total reclaimed space:")) + .map_or_else(|| "0B".to_string(), |space| space.trim().to_string())) +} + +#[cfg(test)] +mod tests { + use super::{is_runtime_id, parse_docker_usage, parse_runtimes}; + + #[test] + fn reads_runtimes_largest_first() { + let json = br#"{ + "A": {"identifier": "AAAA-1111", "version": "26.5", "build": "23F77", + "sizeBytes": 100, "deletable": true, + "platformIdentifier": "com.apple.platform.iphonesimulator"}, + "B": {"identifier": "BBBB-2222", "version": "18.6", "build": "22G86", + "sizeBytes": 200, "deletable": true, "lastUsedAt": "2026-09-17T22:26:41Z", + "platformIdentifier": "com.apple.platform.iphonesimulator"} + }"#; + + let runtimes = parse_runtimes(json).expect("runtimes should parse"); + + assert_eq!(runtimes[0].name, "iOS 18.6"); + assert_eq!(runtimes[0].size, 200); + assert_eq!( + runtimes[0].last_used.as_deref(), + Some("2026-09-17T22:26:41Z") + ); + assert_eq!(runtimes[1].identifier, "AAAA-1111"); + } + + #[test] + fn only_runtime_ids_are_passed_to_simctl() { + assert!(is_runtime_id("DE9F86B8-4B9B-4325-96E9-277119F7A187")); + assert!(!is_runtime_id("all")); + assert!(!is_runtime_id("--notUsedSinceDays")); + assert!(!is_runtime_id("DE9F86B8 4B9B")); + } + + #[test] + fn reads_docker_usage() { + let text = br#"{"Active":"2","Reclaimable":"1.105GB (61%)","Size":"1.808GB","TotalCount":"9","Type":"Images"} +{"Active":"0","Reclaimable":"0B","Size":"0B","TotalCount":"0","Type":"Build Cache"} +"#; + + let usage = parse_docker_usage(text).expect("usage should parse"); + + assert_eq!(usage.len(), 2); + assert_eq!(usage[0].kind, "Images"); + assert_eq!(usage[0].reclaimable, "1.105GB (61%)"); + } +} diff --git a/docs/SAFETY.md b/docs/SAFETY.md index 04ca259..c4a06da 100644 --- a/docs/SAFETY.md +++ b/docs/SAFETY.md @@ -15,12 +15,13 @@ 7. [Cleanup Rules](#cleanup-rules) 8. [App Removal](#app-removal) 9. [Build Folders And Installers](#build-folders-and-installers) -10. [Planned Features](#planned-features) -11. [Tests](#tests) +10. [Tools neet Runs](#tools-neet-runs) +11. [Planned Features](#planned-features) +12. [Tests](#tests) ## The Promises -1. Nothing is deleted for good, and neet never empties the Trash. +1. Nothing is deleted for good, and neet never empties the Trash. The one exception is [simulator runtimes and Docker data](#tools-neet-runs), which only their own tool can remove, after a separate question that says so. 2. You see every path, and confirm, before anything moves. 3. Cleanup only removes items inside a short, fixed list of folders. App removal, and build folders and installers, each have their own narrow check. 4. Your own files, cloud files, passwords, and keys are never touched. The one exception: project build folders and installers, which can be in Documents, Desktop, or Downloads. @@ -195,6 +196,23 @@ min_age_days = 0 4. Every other protected folder still applies, such as `.git`, iCloud, keychains, and `~/.ssh`. * Build folders come back when you install or build again. An installer only comes back if you download it again. +## Tools neet Runs + +* Simulator runtimes and Docker's images cannot go to the Trash: macOS keeps runtimes in secure storage, and Docker keeps images inside its own disk image. neet asks their own tool to remove them, from Quick Clean. +* This is the only way neet removes anything for good. Both can be downloaded again. + +| Tool | Command | Removes | +| --- | --- | --- | +| Simulator runtimes | `xcrun simctl runtime delete `, once per runtime you select | The runtime. Simulators that use it stop working until Xcode downloads it again. | +| Docker | `docker system prune --all --force` | Stopped containers, networks no container uses, every image no container uses, and the build cache. Volumes are kept. | + +* Before either runs, neet: + 1. Lists exactly what the tool reports: each runtime with its version, build, size, and when it was last used, or Docker's images, containers, volumes, and build cache with what can be reclaimed. + 2. Starts with nothing selected for runtimes. + 3. Asks in a red box that says the removal is for good and does not go to the Trash. Only `y` goes ahead. +* Only a runtime ID made of letters, digits, and hyphens is passed to `simctl`, so it can never be `all` or an option. +* Neither command uses admin rights. neet never starts Docker on its own; `o` opens Docker Desktop only when you press it. + ## Planned Features * Later features change files or settings in place, not through the Trash. Each has a fixed list of what it may change. @@ -237,6 +255,7 @@ min_age_days = 0 10. An old folder holding a recently changed file. 11. App removal refusing Apple's apps, links, apps in subfolders, open apps, files outside the table, and partial names. 12. Build folders and installers: accepted in Documents, Desktop, and Downloads, and refused when nested, in `~/Library` or a hidden folder, inside `.git` or `~/.ssh`, a link, a lone `target`, or a file that is not an installer. + 13. Tool commands: only runtime IDs reach `simctl`, and what `simctl` and `docker` print is read as data. * Before release, a harmless test item is moved to the Trash by hand and restored with Put Back. This passed on macOS 26.5. * Removing an app owned by root was tried the same way, and Put Back restored it. * Planned features add their own tests, for changes outside their lists, links that lead out, and undoing each change. From 29a7ac5da4434fcd74116ec8bd37832891908941 Mon Sep 17 00:00:00 2001 From: ado11231 Date: Fri, 2 Oct 2026 14:18:58 -0400 Subject: [PATCH 2/2] feat(tui): remove simulator runtimes and prune Docker from Quick Clean Enter on Simulator runtimes lists every runtime with its build, size, and last use, none selected. Enter on Docker shows what docker system df reports and what prune removes and keeps, or how to start Docker Desktop. Both end in a red question that says the removal is for good, and only y runs the tool. Quick Clean marks both rows neet, for good. --- README.md | 2 +- crates/neet/src/ui/mod.rs | 1 + crates/neet/src/ui/quick.rs | 38 +- crates/neet/src/ui/tools.rs | 887 ++++++++++++++++++++++++++++++++++++ docs/FEATURES.md | 1 + docs/INTERFACE.md | 24 +- 6 files changed, 936 insertions(+), 17 deletions(-) create mode 100644 crates/neet/src/ui/tools.rs diff --git a/README.md b/README.md index 74b162c..6b816b7 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ neet ## Notes * Works on macOS 13 or later, on Apple silicon and Intel. -* Files go to the Trash, never deleted, so you can put them back. +* Files go to the Trash, never deleted, so you can put them back. Simulator runtimes and Docker images are the exception: neet asks their own tools to remove them, after a red question that says so. * You review and confirm everything before it moves. * Your personal folders, iCloud files, keychains, and SSH keys are never touched. The one exception is project build folders and installers, which you pick in Quick Clean. * For a full scan, give your terminal Full Disk Access: diff --git a/crates/neet/src/ui/mod.rs b/crates/neet/src/ui/mod.rs index b7107c3..17fef26 100644 --- a/crates/neet/src/ui/mod.rs +++ b/crates/neet/src/ui/mod.rs @@ -13,6 +13,7 @@ mod quick; mod review; mod scan; mod skipped; +mod tools; use std::io; use std::path::PathBuf; diff --git a/crates/neet/src/ui/quick.rs b/crates/neet/src/ui/quick.rs index 4fd75df..20160ef 100644 --- a/crates/neet/src/ui/quick.rs +++ b/crates/neet/src/ui/quick.rs @@ -20,6 +20,7 @@ use super::format; use super::loading::scanning; use super::pick::Pick; use super::scan::ScanStatus; +use super::tools::{DockerSpace, Simulators}; /// Below this width the largest items are left out. const MIN_SIDE_WIDTH: u16 = 130; @@ -95,7 +96,10 @@ fn name(item: Item) -> &'static str { /// Who clears an item #[derive(Clone, Copy, PartialEq, Eq)] enum Who { + /// neet moves it to the Trash Neet, + /// neet asks the item's own tool to remove it, which is for good + Tool, You, /// macOS clears it on its own Mac, @@ -105,6 +109,7 @@ fn who(item: Item) -> Who { match item { Item::Rules => Who::Neet, Item::Clutter(kind) if clutter::neet_removes(kind) => Who::Neet, + Item::Clutter(Kind::DockerImage | Kind::SimulatorRuntimes) => Who::Tool, Item::Clutter(Kind::TempFiles) => Who::Mac, Item::Clutter(_) => Who::You, } @@ -113,6 +118,7 @@ fn who(item: Item) -> Who { fn who_span(who: Who) -> Span<'static> { match who { Who::Neet => Span::raw("neet").green(), + Who::Tool => Span::raw("neet, for good").red(), Who::You => Span::raw("you").yellow(), Who::Mac => Span::raw("macOS").blue(), } @@ -166,14 +172,14 @@ fn steps(item: Item) -> &'static [&'static str] { "neet never empties the Trash, so Put Back always works.", ], Item::Clutter(Kind::DockerImage) => &[ - "Run: docker system prune -a", - "Or remove images in Docker Desktop.", - "Then Docker Desktop, Settings, Resources can shrink the disk.", + "Press Enter to see what Docker can free.", + "neet runs docker system prune --all. Volumes are kept.", + "It asks first, in red: this is for good, not the Trash.", ], Item::Clutter(Kind::SimulatorRuntimes) => &[ - "Run: xcrun simctl runtime list", - "Then: xcrun simctl runtime delete ", - "Or Xcode, Settings, Components.", + "Press Enter to list every runtime. None start selected.", + "neet runs xcrun simctl runtime delete for each one you pick.", + "It asks first, in red: this is for good, not the Trash.", ], Item::Clutter(Kind::TempFiles) => &[ "macOS removes old ones on its own.", @@ -326,7 +332,7 @@ impl QuickClean { let (size, bar, found) = match *status { Status::Found { size, count } => { match who(item) { - Who::Neet => neet_total += size, + Who::Neet | Who::Tool => neet_total += size, Who::You => your_total += size, Who::Mac => {} } @@ -417,6 +423,7 @@ impl QuickClean { ]; let color = match who(item) { Who::Neet => Color::Green, + Who::Tool => Color::Red, Who::You => Color::Yellow, Who::Mac => Color::Blue, }; @@ -597,6 +604,12 @@ impl Screen for QuickClean { let ScanStatus::Done { scan, .. } = context.scan else { return Action::None; }; + if kind == Kind::SimulatorRuntimes { + return Action::Open(Box::new(Simulators::new())); + } + if kind == Kind::DockerImage { + return Action::Open(Box::new(DockerSpace::new())); + } if clutter::neet_removes(kind) { let paths: Vec = self .removable(kind) @@ -638,7 +651,7 @@ impl Screen for QuickClean { ("g G", "Jump to the first or last row"), ( "Enter → l", - "Rows neet clears: pick what goes to the Trash. Others: show in Disk", + "Rows neet clears: pick what to remove. Trash: show in Disk", ), ("d", "Show the largest item in Disk"), ("Esc", "Go back to Home"), @@ -731,7 +744,8 @@ mod tests { assert!(screen.contains("300.0 KB") || screen.contains("303.1 KB")); assert!(screen.contains("17.0 GB")); assert!(screen.contains("looking…")); - assert!(screen.contains("you can free 17.0 GB more")); + assert!(screen.contains("you can free 2.0 MB more")); + assert!(screen.contains("neet, for good")); assert!(screen.contains("macOS")); assert!(screen.contains("Press Enter to open Deep Clean")); } @@ -767,7 +781,7 @@ mod tests { } #[test] - fn enter_opens_deep_clean_then_disk_for_the_trash() { + fn enter_opens_deep_clean_disk_and_the_tool_screens() { let (_dir, scan) = done(); let mut screen = quick(); let _ = render(&mut screen, &scan); @@ -783,13 +797,13 @@ mod tests { press(&mut screen, &scan, KeyCode::Enter), Action::Open(_) )); - // Simulator runtimes are outside the scan, so there is nothing to show. + // Simulator runtimes open their own screen, which asks simctl. for _ in 0..2 { press(&mut screen, &scan, KeyCode::Down); } assert!(matches!( press(&mut screen, &scan, KeyCode::Enter), - Action::None + Action::Open(_) )); } } diff --git a/crates/neet/src/ui/tools.rs b/crates/neet/src/ui/tools.rs new file mode 100644 index 0000000..2180b94 --- /dev/null +++ b/crates/neet/src/ui/tools.rs @@ -0,0 +1,887 @@ +use std::sync::mpsc::{self, Receiver}; +use std::thread; +use std::time::Instant; + +use neet_core::tools::{self, Docker, Runtime}; +use ratatui::Frame; +use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::{Constraint, Flex, Layout, Rect}; +use ratatui::style::{Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Cell, Clear, Padding, Paragraph, Row, Table, TableState, Wrap}; + +use super::app::{Action, Context, Screen}; +use super::clean::checkbox; +use super::format; +use super::loading::Loading; + +/// Runs `work` on its own thread, for the answer to arrive later +fn spawn(work: impl FnOnce() -> T + Send + 'static) -> Receiver { + let (sender, answer) = mpsc::channel(); + thread::spawn(move || { + // The receiver is gone only when the screen was closed. + let _ = sender.send(work()); + }); + answer +} + +/// Where a tool screen is: looking, showing what it found, asking, running +/// the tool, or done +enum Stage { + Looking(Instant, Receiver>), + Failed(String), + Ready(T), + Asking(T), + Working(Instant, Receiver), + Done(R), +} + +impl Stage { + fn look(work: impl FnOnce() -> Result + Send + 'static) -> Self + where + T: Send + 'static, + { + Self::Looking(Instant::now(), spawn(work)) + } + + fn poll(&mut self) { + let next = match self { + Self::Looking(_, answer) => match answer.try_recv() { + Ok(Ok(found)) => Self::Ready(found), + Ok(Err(reason)) => Self::Failed(reason), + Err(_) => return, + }, + Self::Working(_, answer) => match answer.try_recv() { + Ok(result) => Self::Done(result), + Err(_) => return, + }, + _ => return, + }; + *self = next; + } + + /// Asking or running the tool, when keys like `q` must not act + fn is_busy(&self) -> bool { + matches!(self, Self::Asking(_) | Self::Working(..)) + } +} + +/// The red question before a tool removes something for good +fn ask(frame: &mut Frame, area: Rect, title: &str, lines: Vec>) { + let mut lines = lines; + lines.push(Line::default()); + lines.push(Line::from(vec![ + Span::raw("y").bold().red(), + Span::raw(" remove for good "), + Span::raw("n").bold(), + Span::raw(" or "), + Span::raw("Esc").bold(), + Span::raw(" go back"), + ])); + let width = 66.min(area.width); + let inner = usize::from(width.saturating_sub(4)).max(1); + let rows: usize = lines + .iter() + .map(|line| line.width().max(1).div_ceil(inner)) + .sum(); + let height = u16::try_from(rows + 3).unwrap_or(u16::MAX).min(area.height); + let [area] = Layout::vertical([Constraint::Length(height)]) + .flex(Flex::Center) + .areas(area); + let [area] = Layout::horizontal([Constraint::Length(width)]) + .flex(Flex::Center) + .areas(area); + frame.render_widget(Clear, area); + frame.render_widget( + Paragraph::new(lines).wrap(Wrap { trim: false }).block( + Block::bordered() + .border_style(Style::new().red()) + .title(Line::from(format!(" {title} ")).red().bold()) + .padding(Padding::horizontal(1)), + ), + area, + ); +} + +fn failed_box(frame: &mut Frame, area: Rect, title: &str, reason: &str) { + frame.render_widget( + Paragraph::new(reason.to_string()) + .red() + .wrap(Wrap { trim: true }) + .block( + Block::bordered() + .title(format!(" {title} ")) + .padding(Padding::horizontal(1)), + ), + area, + ); +} + +/// The date part of a time `simctl` gives, such as `2026-09-17` +fn day(time: &str) -> &str { + time.split('T').next().unwrap_or(time) +} + +/// What deleting each runtime did: its name, and why it failed if it did +type Deleted = Vec<(String, Result<(), String>)>; + +/// Every simulator runtime, to delete with `xcrun simctl` after a red +/// question. Nothing starts selected. +pub struct Simulators { + stage: Stage, Deleted>, + chosen: Vec, + list: TableState, +} + +impl Simulators { + pub fn new() -> Self { + Self::with(Stage::look(|| { + tools::runtimes().map_err(|error| format!("simctl could not list runtimes: {error}")) + })) + } + + fn with(stage: Stage, Deleted>) -> Self { + Self { + stage, + chosen: Vec::new(), + list: TableState::default().with_selected(Some(0)), + } + } + + fn runtimes(&self) -> &[Runtime] { + match &self.stage { + Stage::Ready(runtimes) | Stage::Asking(runtimes) => runtimes, + _ => &[], + } + } + + fn picked(&self) -> Vec<&Runtime> { + self.runtimes() + .iter() + .zip(&self.chosen) + .filter(|(_, chosen)| **chosen) + .map(|(runtime, _)| runtime) + .collect() + } + + fn draw_list(&mut self, frame: &mut Frame, area: Rect) { + let runtimes = self.runtimes().to_vec(); + self.chosen.resize(runtimes.len(), false); + let picked = self.picked(); + let picked_size: u64 = picked.iter().map(|runtime| runtime.size).sum(); + let total: u64 = runtimes.iter().map(|runtime| runtime.size).sum(); + let rows: Vec = runtimes + .iter() + .zip(&self.chosen) + .map(|(runtime, &chosen)| { + let row = Row::new([ + Cell::from(checkbox(chosen, runtime.deletable)), + Cell::from(runtime.name.clone()), + Cell::from(runtime.build.clone()), + Cell::from( + Line::from(format::size_span(runtime.size, format::size(runtime.size))) + .right_aligned(), + ), + Cell::from( + runtime + .last_used + .as_deref() + .map_or("never", day) + .to_string(), + ), + Cell::from(if runtime.deletable { + Span::raw("") + } else { + Span::raw("simctl will not delete it").italic() + }), + ]); + if runtime.deletable { + row + } else { + row.dark_gray() + } + }) + .collect(); + let header = Row::new([ + Cell::from(""), + Cell::from("Runtime"), + Cell::from("Build"), + Cell::from(Line::from("Size").right_aligned()), + Cell::from("Last used"), + Cell::from(""), + ]) + .bold() + .bottom_margin(1); + let selected = Span::raw(format!( + " Selected: {} of {} · {} ", + picked.len(), + runtimes.len(), + format::size(picked_size) + )); + let table = Table::new( + rows, + [ + Constraint::Length(3), + Constraint::Length(16), + Constraint::Length(10), + Constraint::Length(9), + Constraint::Length(12), + Constraint::Fill(1), + ], + ) + .header(header) + .column_spacing(2) + .block( + Block::bordered() + .title(format!(" Simulator runtimes · {} ", format::size(total))) + .title_bottom(if picked.is_empty() { + selected + } else { + selected.red().bold() + }) + .padding(Padding::horizontal(1)), + ) + .highlight_symbol("▸ ") + .row_highlight_style(Style::new().bold().white()); + frame.render_stateful_widget(table, area, &mut self.list); + } +} + +fn simulators_about(frame: &mut Frame, area: Rect) { + let lines = vec![ + Line::from( + "Each runtime lets Xcode run simulators of one iOS, watchOS, tvOS, or visionOS version.", + ), + Line::default(), + Line::from(vec![ + Span::raw("For good: ").red().bold(), + Span::raw( + "runtimes cannot go to the Trash. neet asks xcrun simctl runtime delete to remove them.", + ), + ]), + Line::from( + "Simulators on a deleted runtime stop working until Xcode downloads it again, in Settings, Components.", + ), + Line::default(), + Line::from("Select the ones you do not test on with Space, then press Enter."), + ]; + frame.render_widget( + Paragraph::new(lines) + .wrap(Wrap { trim: true }) + .block(Block::bordered().padding(Padding::horizontal(1))), + area, + ); +} + +impl Screen for Simulators { + fn draw(&mut self, frame: &mut Frame, area: Rect, _context: &Context) { + self.stage.poll(); + let title = "Simulator runtimes"; + match &self.stage { + Stage::Looking(started, _) => { + return Loading { + title, + doing: "Asking xcrun simctl for the runtimes", + progress: format!("{}s", started.elapsed().as_secs()), + note: "Nothing is changed while neet looks.", + } + .draw(frame, area); + } + Stage::Failed(reason) => return failed_box(frame, area, title, reason), + Stage::Working(started, _) => { + return Loading { + title, + doing: "Deleting with xcrun simctl", + progress: format!("{}s", started.elapsed().as_secs()), + note: "Each runtime is unmounted, then deleted. This can take a minute.", + } + .draw(frame, area); + } + Stage::Done(deleted) => { + let mut lines: Vec = deleted + .iter() + .map(|(name, result)| match result { + Ok(()) => Line::from(vec![ + Span::raw("✓ ").green().bold(), + Span::raw(format!("{name} deleted")), + ]), + Err(reason) => Line::from(vec![ + Span::raw("✗ ").red().bold(), + Span::raw(format!("{name} was not deleted: {reason}")), + ]), + }) + .collect(); + lines.push(Line::default()); + lines.push(Line::from("Press Enter to go back to Home.")); + frame.render_widget( + Paragraph::new(lines).wrap(Wrap { trim: true }).block( + Block::bordered() + .title(format!(" {title} ")) + .padding(Padding::horizontal(1)), + ), + area, + ); + return; + } + Stage::Ready(runtimes) | Stage::Asking(runtimes) if runtimes.is_empty() => { + return failed_box( + frame, + area, + title, + "There are no simulator runtimes on this Mac.", + ); + } + Stage::Ready(_) | Stage::Asking(_) => {} + } + let list_height = u16::try_from(self.runtimes().len() + 4).unwrap_or(u16::MAX); + let [list, about] = + Layout::vertical([Constraint::Length(list_height), Constraint::Fill(1)]).areas(area); + self.draw_list(frame, list); + simulators_about(frame, about); + if matches!(self.stage, Stage::Asking(_)) { + let picked = self.picked(); + let size: u64 = picked.iter().map(|runtime| runtime.size).sum(); + let names: Vec = picked.iter().map(|runtime| runtime.name.clone()).collect(); + ask( + frame, + area, + "Delete for good", + vec![ + Line::from(format!( + "Delete {}, {}, for good?", + names.join(", "), + format::size(size) + )) + .bold(), + Line::default(), + Line::from( + "They do not go to the Trash. Simulators that use them stop working until Xcode downloads them again.", + ), + ], + ); + } + } + + fn handle_key(&mut self, key: KeyEvent, _context: &Context) -> Action { + match &self.stage { + Stage::Asking(_) => { + match key.code { + KeyCode::Char('y') => { + let picked: Vec = self.picked().into_iter().cloned().collect(); + self.stage = Stage::Working( + Instant::now(), + spawn(move || { + picked + .into_iter() + .map(|runtime| { + let result = tools::delete_runtime(&runtime.identifier) + .map_err(|error| error.to_string()); + (runtime.name, result) + }) + .collect() + }), + ); + } + KeyCode::Char('n') => self.back_to_list(), + _ => {} + } + return Action::None; + } + Stage::Done(_) => { + return if key.code == KeyCode::Enter { + Action::Home + } else { + Action::None + }; + } + Stage::Ready(_) => {} + _ => return Action::None, + } + let index = self.list.selected().unwrap_or(0); + let last = self.runtimes().len().saturating_sub(1); + match key.code { + KeyCode::Up | KeyCode::Char('k') => self.list.select(Some(index.saturating_sub(1))), + KeyCode::Down | KeyCode::Char('j') => self.list.select(Some((index + 1).min(last))), + KeyCode::Char(' ') => { + if self + .runtimes() + .get(index) + .is_some_and(|runtime| runtime.deletable) + && let Some(chosen) = self.chosen.get_mut(index) + { + *chosen = !*chosen; + } + } + KeyCode::Enter if !self.picked().is_empty() => { + if let Stage::Ready(runtimes) = + std::mem::replace(&mut self.stage, Stage::Failed(String::new())) + { + self.stage = Stage::Asking(runtimes); + } + } + _ => {} + } + Action::None + } + + fn back(&mut self) -> Action { + match self.stage { + Stage::Asking(_) => { + self.back_to_list(); + Action::None + } + Stage::Working(..) => Action::None, + Stage::Done(_) => Action::Home, + _ => Action::Back, + } + } + + fn hints(&self) -> &'static str { + match self.stage { + Stage::Asking(_) => "y delete for good · n or esc back", + Stage::Working(..) => "deleting, please wait", + Stage::Done(_) => "enter or esc home", + _ => "↑↓ move · space select · enter delete · esc back · ? help", + } + } + + fn help(&self) -> &'static [(&'static str, &'static str)] { + &[ + ("↑ ↓ j k", "Move the selection"), + ("Space", "Select or clear a runtime"), + ("Enter", "Ask before deleting the selected runtimes"), + ("y", "In the question: delete them for good"), + ("n Esc", "In the question: go back"), + ] + } + + fn is_dialog(&self) -> bool { + self.stage.is_busy() + } +} + +impl Simulators { + fn back_to_list(&mut self) { + if let Stage::Asking(runtimes) = + std::mem::replace(&mut self.stage, Stage::Failed(String::new())) + { + self.stage = Stage::Ready(runtimes); + } + } +} + +/// Docker's images, containers, volumes, and build cache, to prune with +/// `docker system prune` after a red question +pub struct DockerSpace { + stage: Stage>, + note: Option, +} + +impl DockerSpace { + pub fn new() -> Self { + Self::with(Self::looking()) + } + + fn looking() -> Stage> { + Stage::look(|| { + tools::docker_usage() + .map_err(|error| format!("Docker could not say what it holds: {error}")) + }) + } + + fn with(stage: Stage>) -> Self { + Self { stage, note: None } + } + + fn draw_usage(&self, frame: &mut Frame, area: Rect, usage: &[tools::DockerUsage]) { + let rows: Vec = usage + .iter() + .map(|line| { + let reclaim = if line.reclaimable.starts_with("0B") { + Span::raw(line.reclaimable.clone()) + } else { + Span::raw(line.reclaimable.clone()).yellow().bold() + }; + Row::new([ + Cell::from(line.kind.clone()), + Cell::from(Line::from(line.total_count.clone()).right_aligned()), + Cell::from(Line::from(line.active.clone()).right_aligned()), + Cell::from(Line::from(line.size.clone()).right_aligned()), + Cell::from(reclaim), + ]) + }) + .collect(); + let header = Row::new([ + Cell::from("Kind"), + Cell::from(Line::from("Count").right_aligned()), + Cell::from(Line::from("In use").right_aligned()), + Cell::from(Line::from("Size").right_aligned()), + Cell::from("Can be freed"), + ]) + .bold() + .bottom_margin(1); + let height = u16::try_from(usage.len() + 4).unwrap_or(u16::MAX); + let [table, about] = + Layout::vertical([Constraint::Length(height), Constraint::Fill(1)]).areas(area); + frame.render_widget( + Table::new( + rows, + [ + Constraint::Length(14), + Constraint::Length(6), + Constraint::Length(7), + Constraint::Length(10), + Constraint::Fill(1), + ], + ) + .header(header) + .column_spacing(2) + .block( + Block::bordered() + .title(" Docker ") + .padding(Padding::horizontal(1)), + ), + table, + ); + let mut lines = vec![ + Line::from("Enter runs docker system prune --all. It removes:").bold(), + Line::from(vec![ + Span::raw("1. ").red(), + Span::raw("Stopped containers."), + ]), + Line::from(vec![ + Span::raw("2. ").red(), + Span::raw("Networks no container uses."), + ]), + Line::from(vec![ + Span::raw("3. ").red(), + Span::raw( + "Every image no container uses. Docker downloads them again when needed.", + ), + ]), + Line::from(vec![Span::raw("4. ").red(), Span::raw("The build cache.")]), + Line::default(), + Line::from(vec![ + Span::raw("Kept: ").green().bold(), + Span::raw( + "running containers, the images they use, and every volume, where databases keep their data.", + ), + ]), + Line::from(vec![ + Span::raw("For good: ").red().bold(), + Span::raw("none of it goes to the Trash."), + ]), + ]; + if let Some(note) = &self.note { + lines.insert(0, Line::from(note.clone()).yellow()); + lines.insert(1, Line::default()); + } + frame.render_widget( + Paragraph::new(lines) + .wrap(Wrap { trim: true }) + .block(Block::bordered().padding(Padding::horizontal(1))), + about, + ); + } +} + +impl Screen for DockerSpace { + fn draw(&mut self, frame: &mut Frame, area: Rect, _context: &Context) { + self.stage.poll(); + let title = "Docker"; + match &self.stage { + Stage::Looking(started, _) => Loading { + title, + doing: "Asking Docker what it holds", + progress: format!("{}s", started.elapsed().as_secs()), + note: "Nothing is changed while neet looks.", + } + .draw(frame, area), + Stage::Failed(reason) => failed_box(frame, area, title, reason), + Stage::Ready(Docker::NotInstalled) | Stage::Asking(Docker::NotInstalled) => { + failed_box(frame, area, title, "Docker is not installed."); + } + Stage::Ready(Docker::NotRunning) | Stage::Asking(Docker::NotRunning) => { + let mut lines = vec![ + Line::from("Docker Desktop is not running, so Docker cannot say what it holds or remove anything.").yellow(), + Line::default(), + Line::from("1. Press o to open Docker Desktop."), + Line::from("2. Wait until it says it is running."), + Line::from("3. Press r to look again."), + ]; + if let Some(note) = &self.note { + lines.push(Line::default()); + lines.push(Line::from(note.clone())); + } + frame.render_widget( + Paragraph::new(lines).wrap(Wrap { trim: true }).block( + Block::bordered() + .title(format!(" {title} ")) + .padding(Padding::horizontal(1)), + ), + area, + ); + } + Stage::Ready(Docker::Usage(usage)) => self.draw_usage(frame, area, usage), + Stage::Asking(Docker::Usage(usage)) => { + self.draw_usage(frame, area, usage); + ask( + frame, + area, + "Remove for good", + vec![ + Line::from("Run docker system prune --all?").bold(), + Line::default(), + Line::from( + "Stopped containers, unused networks, every unused image, and the build cache are removed for good. Volumes are kept.", + ), + ], + ); + } + Stage::Working(started, _) => Loading { + title, + doing: "Running docker system prune --all", + progress: format!("{}s", started.elapsed().as_secs()), + note: "This can take a minute.", + } + .draw(frame, area), + Stage::Done(result) => { + let lines = match result { + Ok(space) => vec![ + Line::from(vec![ + Span::raw("✓ ").green().bold(), + Span::raw("Docker freed "), + Span::raw(space.clone()).green().bold(), + Span::raw("."), + ]), + Line::default(), + Line::from( + "Docker's disk image can take a few minutes to give the space back to macOS.", + ), + Line::default(), + Line::from("Press Enter to go back to Home."), + ], + Err(reason) => vec![ + Line::from(format!("✗ The prune failed: {reason}")).red(), + Line::default(), + Line::from("Press Enter to go back to Home."), + ], + }; + frame.render_widget( + Paragraph::new(lines).wrap(Wrap { trim: true }).block( + Block::bordered() + .title(format!(" {title} ")) + .padding(Padding::horizontal(1)), + ), + area, + ); + } + } + } + + fn handle_key(&mut self, key: KeyEvent, _context: &Context) -> Action { + self.note = None; + match &self.stage { + Stage::Asking(_) => { + match key.code { + KeyCode::Char('y') => { + self.stage = Stage::Working( + Instant::now(), + spawn(|| tools::docker_prune().map_err(|error| error.to_string())), + ); + } + KeyCode::Char('n') => self.back_to_usage(), + _ => {} + } + return Action::None; + } + Stage::Done(_) => { + return if key.code == KeyCode::Enter { + Action::Home + } else { + Action::None + }; + } + Stage::Looking(..) | Stage::Working(..) => return Action::None, + Stage::Failed(_) | Stage::Ready(_) => {} + } + match key.code { + KeyCode::Char('r') => self.stage = Self::looking(), + KeyCode::Char('o') => { + self.note = Some(match tools::start_docker() { + Ok(()) => "Opening Docker Desktop. Press r once it is running.".to_string(), + Err(error) => format!("Docker Desktop could not be opened: {error}"), + }); + } + KeyCode::Enter => { + if matches!(self.stage, Stage::Ready(Docker::Usage(_))) + && let Stage::Ready(docker) = + std::mem::replace(&mut self.stage, Stage::Failed(String::new())) + { + self.stage = Stage::Asking(docker); + } + } + _ => {} + } + Action::None + } + + fn back(&mut self) -> Action { + match self.stage { + Stage::Asking(_) => { + self.back_to_usage(); + Action::None + } + Stage::Working(..) => Action::None, + Stage::Done(_) => Action::Home, + _ => Action::Back, + } + } + + fn hints(&self) -> &'static str { + match self.stage { + Stage::Asking(_) => "y remove for good · n or esc back", + Stage::Working(..) => "pruning, please wait", + Stage::Done(_) => "enter or esc home", + _ => "enter prune · o open Docker Desktop · r look again · esc back · ? help", + } + } + + fn help(&self) -> &'static [(&'static str, &'static str)] { + &[ + ("Enter", "Ask before running docker system prune --all"), + ("o", "Open Docker Desktop"), + ("r", "Ask Docker again"), + ("y", "In the question: remove for good"), + ("n Esc", "In the question: go back"), + ] + } + + fn is_dialog(&self) -> bool { + self.stage.is_busy() + } +} + +impl DockerSpace { + fn back_to_usage(&mut self) { + if let Stage::Asking(docker) = + std::mem::replace(&mut self.stage, Stage::Failed(String::new())) + { + self.stage = Stage::Ready(docker); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui::scan::ScanStatus; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::crossterm::event::KeyModifiers; + + fn render(screen: &mut dyn Screen) -> String { + let mut terminal = Terminal::new(TestBackend::new(110, 24)).unwrap(); + let scan = ScanStatus::Failed(String::new()); + let context = Context { + scan: &scan, + disk: None, + cleanable: None, + plan: None, + }; + terminal + .draw(|frame| screen.draw(frame, frame.area(), &context)) + .unwrap(); + terminal + .backend() + .buffer() + .content() + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect() + } + + fn press(screen: &mut dyn Screen, code: KeyCode) -> Action { + let scan = ScanStatus::Failed(String::new()); + screen.handle_key( + KeyEvent::new(code, KeyModifiers::NONE), + &Context { + scan: &scan, + disk: None, + cleanable: None, + plan: None, + }, + ) + } + + fn runtime(name: &str, size: u64) -> Runtime { + Runtime { + identifier: "AAAA-1111".to_string(), + name: name.to_string(), + build: "22G86".to_string(), + size, + last_used: Some("2026-09-17T22:26:41Z".to_string()), + deletable: true, + } + } + + #[test] + fn nothing_is_deleted_without_a_selection_and_a_yes() { + let mut screen = Simulators::with(Stage::Ready(vec![ + runtime("iOS 18.6", 8_800_000_000), + runtime("iOS 26.5", 8_500_000_000), + ])); + + let text = render(&mut screen); + assert!(text.contains("iOS 18.6")); + assert!(text.contains("8.8 GB")); + assert!(text.contains("2026-09-17")); + assert!(text.contains("Selected: 0 of 2")); + + // Nothing selected, so Enter does not ask. + press(&mut screen, KeyCode::Enter); + assert!(matches!(screen.stage, Stage::Ready(_))); + + press(&mut screen, KeyCode::Char(' ')); + press(&mut screen, KeyCode::Enter); + assert!(render(&mut screen).contains("Delete iOS 18.6, 8.8 GB, for good?")); + assert!(screen.is_dialog()); + + // n goes back without deleting. + press(&mut screen, KeyCode::Char('n')); + assert!(matches!(screen.stage, Stage::Ready(_))); + press(&mut screen, KeyCode::Enter); + assert!(matches!(screen.back(), Action::None)); + assert!(matches!(screen.stage, Stage::Ready(_))); + } + + #[test] + fn docker_shows_what_can_be_freed_and_asks_first() { + let usage = tools::DockerUsage { + kind: "Images".to_string(), + total_count: "9".to_string(), + active: "2".to_string(), + size: "1.808GB".to_string(), + reclaimable: "1.105GB (61%)".to_string(), + }; + let mut screen = DockerSpace::with(Stage::Ready(Docker::Usage(vec![usage]))); + + let text = render(&mut screen); + assert!(text.contains("1.105GB (61%)")); + assert!(text.contains("every volume")); + + press(&mut screen, KeyCode::Enter); + assert!(render(&mut screen).contains("Run docker system prune --all?")); + press(&mut screen, KeyCode::Char('n')); + assert!(matches!(screen.stage, Stage::Ready(_))); + } + + #[test] + fn docker_says_to_open_docker_desktop_when_it_is_not_running() { + let mut screen = DockerSpace::with(Stage::Ready(Docker::NotRunning)); + + assert!(render(&mut screen).contains("Press o to open Docker Desktop")); + press(&mut screen, KeyCode::Enter); + assert!(matches!(screen.stage, Stage::Ready(Docker::NotRunning))); + } +} diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 9c663d1..69ecc63 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -59,6 +59,7 @@ | Your own rules | Loads extra rules from `~/.config/neet/rules/`. They can never reach outside the allowed folders, and never start selected. | Done | | Clean one item | Press `d` in Disk or Large Files to clean the selected item, with the same checks. | Done | | Project build folders | Moves build folders in your code projects, `target/` beside a `Cargo.toml` and `node_modules/`, to the Trash from Quick Clean. You pick which, then review. | Done | +| Simulator runtimes and Docker | From Quick Clean, runs `xcrun simctl runtime delete` for the runtimes you pick, or `docker system prune --all`, after a red question. For good, not the Trash. | Done | | Installers in Downloads | Moves `.dmg`, `.pkg`, `.iso`, and `.xip` files in Downloads to the Trash from Quick Clean. You pick which, then review. | Done | ### Risk Levels diff --git a/docs/INTERFACE.md b/docs/INTERFACE.md index c27e7f8..87fea00 100644 --- a/docs/INTERFACE.md +++ b/docs/INTERFACE.md @@ -150,9 +150,10 @@ * **Rows,** always in this order, what neet clears first: 1. Caches and logs: what every Deep Clean rule found. 2. Project build folders and installers in Downloads, from the scan. neet moves these to the Trash after you pick and review them. - 3. The Trash and the Docker disk image, from the scan, which you clear yourself. - 4. Simulator runtimes, asked of `xcrun simctl` when there are simulators on the Mac, and temporary files, measured in `/private/var/folders`. Both are worked out in the background and show `looking…` until done. -* **Columns:** the size and a bar of it against the largest row, both red from 5 GB and yellow from 1 GB, how many items, and who clears it: `neet` in green, `you` in yellow, or `macOS` in blue. Temporary files are left to macOS, since deleting them by hand can break running apps. + 3. The Trash, from the scan, which you empty yourself. + 4. The Docker disk image, from the scan, and simulator runtimes, asked of `xcrun simctl` when there are simulators on the Mac. neet asks their own tools to remove them, which is for good. + 5. Temporary files, measured in `/private/var/folders`, which macOS clears. Simulator runtimes and temporary files are worked out in the background and show `looking…` until done. +* **Columns:** the size and a bar of it against the largest row, both red from 5 GB and yellow from 1 GB, how many items, and who clears it: `neet` in green, `neet, for good` in red, `you` in yellow, or `macOS` in blue. Temporary files are left to macOS, since deleting them by hand can break running apps. * The bottom edge adds up what neet can clear and what you can free yourself. * **Below the table:** what the selected row is, and numbered steps to clear it. * **Right:** the largest items of the selected row, as many as fit: rules for Caches and logs, folders and installers by path, and the largest item for the Trash and Docker. It is hidden when the terminal is narrower than 130 columns. @@ -164,7 +165,7 @@ | --- | --- | | `Up` / `Down` | Move the selection. | | `g` / `G` | Jump to the first or last row. | -| `Enter`, `Right`, or `l` | On Caches and logs, open [Deep Clean](#deep-clean). On build folders or installers, open [Pick](#pick). On the Trash or Docker, show it in [Disk](#disk). | +| `Enter`, `Right`, or `l` | On Caches and logs, open [Deep Clean](#deep-clean). On build folders or installers, open [Pick](#pick). On Docker or simulator runtimes, open [Docker And Simulators](#docker-and-simulators). On the Trash, show it in [Disk](#disk). | | `d` | Show the largest item of the row in [Disk](#disk). | ### Pick @@ -189,6 +190,21 @@ | `a` | Select all, or clear all when all are selected. | | `Enter` | Go to [Review](#review-confirm-and-move). | +### Docker And Simulators + +* These cannot go to the Trash, so neet asks their own tool to remove them. Both screens end in a red question that says it is for good. Only `y` goes ahead. See [SAFETY.md](SAFETY.md#tools-neet-runs). +* **Simulator runtimes:** a table of each runtime with a checkbox, its version, build, size, and the day a simulator last used it. None start selected. `Enter` asks, then runs `xcrun simctl runtime delete` for each one, and lists which were deleted. +* **Docker:** a table of what `docker system df` reports: images, containers, volumes, and build cache, with how many, how many are in use, their size, and how much can be freed, in yellow. The box below lists what `docker system prune --all` removes and what it keeps. `Enter` asks, then runs it, and shows how much Docker freed. +* If Docker Desktop is not running, the screen says so. `o` opens it, and `r` asks Docker again. + +| Key | Action | +| --- | --- | +| `Space` | On simulator runtimes, select or clear a runtime. | +| `Enter` | Ask before removing. | +| `y` | In the question, remove for good. | +| `n` or `Esc` | In the question, go back. | +| `o` / `r` | On Docker, open Docker Desktop, or ask again. | + ## Deep Clean ```text