Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
627d4c1
feat(cli): add TerminalUI target and framed windows for interactive s…
ingvarch Oct 19, 2026
1a71869
feat(cli): color sizes by magnitude in review and storage screens
ingvarch Oct 19, 2026
1ac5d2e
feat(cli): keep the cleanup flow inside the UI with confirmation and …
ingvarch Oct 19, 2026
5e018d6
feat(cli): confirm removal directly from the selection screen with a …
ingvarch Oct 19, 2026
281bf69
feat(cli): add background work with a spinner to TerminalUI
ingvarch Oct 19, 2026
4250d6e
feat(cli): show a spinner and the current path while removing
ingvarch Oct 19, 2026
2a191f7
feat(cli): balance dialog spacing and keep the removal spinner in place
ingvarch Oct 19, 2026
1e8dc7f
feat(cli): show measuring progress in the storage explorer
ingvarch Oct 19, 2026
1150f7d
feat(cli): ask before measuring the home folder from the menu
ingvarch Oct 19, 2026
c8f99da
feat(cli): show memory pressure and storage gauges in system status
ingvarch Oct 19, 2026
4d1d3d0
feat(cli): open preferences and protected paths inside the UI from th…
ingvarch Oct 19, 2026
8b592b4
feat(cli): color sizes in plain reports
ingvarch Oct 19, 2026
75b6bd7
feat(cli): add configurable project folders and create settings files…
ingvarch Oct 19, 2026
0198e51
feat(cli): edit project folders and protected paths inside the UI
ingvarch Oct 19, 2026
2f09430
feat(cli): make preferences editable inside the UI
ingvarch Oct 19, 2026
34bf39a
feat(cli): show system status as framed panels with facts about this Mac
ingvarch Oct 19, 2026
d526438
fix(cli): show memory in binary units like About This Mac
ingvarch Oct 19, 2026
90197ca
feat(cli): ask before project cleanup and show scan progress
ingvarch Oct 19, 2026
db874bd
fix(cli): keep config.json readable by older releases by storing proj…
ingvarch Oct 19, 2026
4e86f07
fix(cli): apply final review fixes for cancel, input, help text and d…
ingvarch Oct 19, 2026
83a51b9
docs(cli): retake README screenshots with freeze
ingvarch Oct 19, 2026
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
12 changes: 11 additions & 1 deletion cli/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,26 @@ let package = Package(
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"),
],
targets: [
.target(
name: "TerminalUI",
path: "Sources/TerminalUI"
),
.executableTarget(
name: "puremac",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
"TerminalUI",
],
path: "Sources/puremac"
),
.testTarget(
name: "TerminalUITests",
dependencies: ["TerminalUI"],
path: "Tests/TerminalUITests"
),
.testTarget(
name: "puremacTests",
dependencies: ["puremac"],
dependencies: ["puremac", "TerminalUI"],
path: "Tests/puremacTests"
),
]
Expand Down
12 changes: 7 additions & 5 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,20 +36,22 @@ puremac purge ~/Projects
puremac analyze ~/Projects
```

Cleanup opens a selection screen. Nothing is selected when the interactive review begins. Move through the list, choose individual paths, and check the running size total. Press Return to review the selection, then confirm permanent deletion separately.
`puremac purge` with no path scans the project folders from Preferences, by default ~/Projects, ~/Code, ~/dev, ~/GitHub and ~/Workspace. The first interactive start creates `~/.config/puremac/config.json` (the age setting only), an empty `~/.config/puremac/ignore` and `~/.config/puremac/projects` (one project folder per line, filled with those defaults); existing files are never overwritten. Project folders live in their own file so older puremac releases, which reject unknown keys in `config.json`, keep working.

Cleanup opens a selection screen. Nothing is selected when the interactive review begins. Move through the list, choose individual paths, and check the running size total. Press Return to remove the selection; a confirmation dialog opens first.

| Key | Action |
|---|---|
| Up / Down | Move through the results |
| Space | Toggle the focused item |
| A / N | Select matching results / clear the selection |
| / | Search the results |
| Return | Review the selection |
| Return | Remove the selection after confirmation |
| Esc / Q | Cancel |

Use Left and Right to scroll a long focused path. The final confirmation prints every selected path in full. Selection is by path, including recent project artifacts that earlier versions only displayed.
Use Left and Right to scroll a long focused path. The confirmation dialog opens with Cancel selected and shows the total size, plus how many selected items the search hides. Choose Remove or press Y to delete, Esc to go back. Selection is by path, including recent project artifacts that earlier versions only displayed.

Prefer an ordinary scrolling terminal? Use `--plain` for numbered selection and a text review:
Prefer an ordinary scrolling terminal? Use `--plain` for numbered selection and a text review that prints every selected path in full:

```bash
puremac purge ~/Projects --plain
Expand Down Expand Up @@ -115,7 +117,7 @@ The CLI has its own scan engine and safety checks. Its coverage is separate from

## Cleanup boundaries

- Cleanup is permanent. The selection review lists the exact paths before confirmation.
- Cleanup is permanent. The selection screen marks every selected path, and `--plain` prints them all in full before confirmation.
- Critical filesystem roots, credential/configuration roots, and recognized cloud-provider state are protected.
- Symlink checks and ignore-list checks run again before deletion.
- A path containing an ignored descendant is also protected from removal.
Expand Down
107 changes: 107 additions & 0 deletions cli/Sources/TerminalUI/BackgroundWork.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import Foundation

public final class WorkProgress<Value>: @unchecked Sendable {
private let lock = NSLock()
private var current: Value

public init(_ initial: Value) {
current = initial
}

public var value: Value {
lock.lock()
defer { lock.unlock() }
return current
}

public func set(_ newValue: Value) {
lock.lock()
current = newValue
lock.unlock()
}

public func update(_ change: (inout Value) -> Void) {
lock.lock()
change(&current)
lock.unlock()
}
}

public final class CancelFlag: @unchecked Sendable {
private let lock = NSLock()
private var cancelled = false

public init() {}

public var isCancelled: Bool {
lock.lock()
defer { lock.unlock() }
return cancelled
}

public func cancel() {
lock.lock()
cancelled = true
lock.unlock()
}
}

enum BackgroundWork {
static let redrawIntervalMilliseconds = 80

static func run<State, Output>(
progress initial: State,
draw: (_ tick: Int, _ progress: State) -> Void,
readKey: (_ timeoutMilliseconds: Int) -> TerminalKey?,
onKey: (TerminalKey, CancelFlag) -> Void,
work: @escaping (WorkProgress<State>, CancelFlag) -> Output
) -> Output {
let progress = WorkProgress(initial)
let cancel = CancelFlag()
let outcome = WorkProgress<Output?>(nil)

DispatchQueue.global().async {
outcome.set(work(progress, cancel))
}

var tick = 0
while outcome.value == nil {
draw(tick, progress.value)
let deadline = DispatchTime.now().uptimeNanoseconds + UInt64(redrawIntervalMilliseconds) * 1_000_000
while outcome.value == nil {
let remaining = remainingMilliseconds(until: deadline)
guard remaining > 0 else { break }
if let key = readKey(remaining) {
onKey(key, cancel)
}
}
tick += 1
}
return outcome.value!
}
}

extension BackgroundWork {
private static func remainingMilliseconds(until deadline: UInt64) -> Int {
let now = DispatchTime.now().uptimeNanoseconds
guard deadline > now else { return 0 }
return Int((deadline - now + 999_999) / 1_000_000)
}
}

extension TerminalSession {
public func runWithSpinner<State, Output>(
progress initial: State,
render: (_ tick: Int, _ progress: State) -> Buffer,
onKey: (TerminalKey, CancelFlag) -> Void = { _, _ in },
work: @escaping (WorkProgress<State>, CancelFlag) -> Output
) -> Output {
BackgroundWork.run(
progress: initial,
draw: { tick, progress in draw(render(tick, progress)) },
readKey: { readKey(timeoutMilliseconds: $0) },
onKey: onKey,
work: work
)
}
}
155 changes: 155 additions & 0 deletions cli/Sources/TerminalUI/Buffer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
public enum RowConstraint: Equatable {
case length(Int)
case fill
}

public struct Rect: Equatable {
public var x, y, width, height: Int

public init(x: Int, y: Int, width: Int, height: Int) {
self.x = x
self.y = y
self.width = width
self.height = height
}

public func splitRows(_ constraints: [RowConstraint]) -> [Rect] {
var top = y
return Rect.sizes(constraints, total: height).map { rowHeight in
defer { top += rowHeight }
return Rect(x: x, y: top, width: width, height: rowHeight)
}
}

public func splitColumns(_ constraints: [RowConstraint]) -> [Rect] {
var left = x
return Rect.sizes(constraints, total: width).map { columnWidth in
defer { left += columnWidth }
return Rect(x: left, y: y, width: columnWidth, height: height)
}
}

private static func sizes(_ constraints: [RowConstraint], total: Int) -> [Int] {
var remaining = max(0, total)
let fixed: [Int?] = constraints.map { constraint in
guard case .length(let length) = constraint else { return nil }
let taken = min(max(0, length), remaining)
remaining -= taken
return taken
}
let fills = fixed.filter { $0 == nil }.count
var fillIndex = 0
return fixed.map { size in
if let size { return size }
defer { fillIndex += 1 }
return remaining / fills + (fillIndex < remaining % fills ? 1 : 0)
}
}
}

public struct Cell: Equatable {
public var symbol: String
public var style: TextStyle

static let blank = Cell(symbol: " ", style: .plain)
}

public struct Buffer: Equatable {
public let width: Int
public let height: Int
private var cells: [Cell]

public var area: Rect { Rect(x: 0, y: 0, width: width, height: height) }

public init(width: Int, height: Int) {
self.width = max(0, width)
self.height = max(0, height)
cells = Array(repeating: .blank, count: self.width * self.height)
}

public subscript(x: Int, y: Int) -> Cell {
cells[index(x: x, y: y)]
}

@discardableResult
public mutating func write(_ text: String, x: Int, y: Int, style: TextStyle = .plain, maxWidth: Int = .max) -> Int {
write([Span(text, style)], x: x, y: y, maxWidth: maxWidth)
}

@discardableResult
public mutating func write(_ spans: [Span], x: Int, y: Int, maxWidth: Int = .max) -> Int {
guard (0..<height).contains(y), (0..<width).contains(x), maxWidth > 0 else { return 0 }
let limit = x + min(maxWidth, width - x)
var column = x
for span in spans {
for character in Term.sanitize(span.text) {
let characterWidth = Term.displayWidth(String(character))
guard column + characterWidth <= limit else { return column - x }
for offset in 0..<characterWidth {
clearWideNeighbor(of: column + offset, y: y)
cells[index(x: column + offset, y: y)] = Cell(
symbol: offset == 0 ? String(character) : "",
style: span.style
)
}
column += characterWidth
}
}
return column - x
}

public mutating func clear(_ rect: Rect) {
let left = max(0, rect.x)
let right = min(width, rect.x + rect.width)
let top = max(0, rect.y)
let bottom = min(height, rect.y + rect.height)
guard right > left, bottom > top else { return }
let blanks = String(repeating: " ", count: right - left)
for row in top..<bottom {
write(blanks, x: left, y: row)
}
}

public mutating func fade() {
let dim = TextStyle(dim: true)
cells = cells.map { Cell(symbol: $0.symbol, style: dim) }
}

public func lines(colors: Bool = Term.colorEnabled) -> [String] {
(0..<height).map { row in
var line = ""
var current = TextStyle.plain
for column in 0..<width {
let cell = cells[index(x: column, y: row)]
if colors {
line += cell.style.transition(from: current)
current = cell.style
}
line += cell.symbol
}
if colors, current != .plain {
line += "\u{1B}[0m"
}
return line
}
}

public func plainLines() -> [String] {
lines(colors: false)
}

private func index(x: Int, y: Int) -> Int {
precondition((0..<width).contains(x) && (0..<height).contains(y), "cell out of buffer")
return y * width + x
}

private mutating func clearWideNeighbor(of column: Int, y: Int) {
let index = index(x: column, y: y)
if cells[index].symbol == "", column > 0 {
cells[index - 1] = .blank
}
if column + 1 < width, cells[index + 1].symbol == "" {
cells[index + 1] = .blank
}
}
}
Loading