diff --git a/cli/Package.swift b/cli/Package.swift index 3f1743c..e1dbe17 100644 --- a/cli/Package.swift +++ b/cli/Package.swift @@ -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" ), ] diff --git a/cli/README.md b/cli/README.md index dc514a0..3176485 100644 --- a/cli/README.md +++ b/cli/README.md @@ -36,7 +36,9 @@ 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 | |---|---| @@ -44,12 +46,12 @@ Cleanup opens a selection screen. Nothing is selected when the interactive revie | 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 @@ -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. diff --git a/cli/Sources/TerminalUI/BackgroundWork.swift b/cli/Sources/TerminalUI/BackgroundWork.swift new file mode 100644 index 0000000..8347562 --- /dev/null +++ b/cli/Sources/TerminalUI/BackgroundWork.swift @@ -0,0 +1,107 @@ +import Foundation + +public final class WorkProgress: @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(¤t) + 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( + progress initial: State, + draw: (_ tick: Int, _ progress: State) -> Void, + readKey: (_ timeoutMilliseconds: Int) -> TerminalKey?, + onKey: (TerminalKey, CancelFlag) -> Void, + work: @escaping (WorkProgress, CancelFlag) -> Output + ) -> Output { + let progress = WorkProgress(initial) + let cancel = CancelFlag() + let outcome = WorkProgress(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( + progress initial: State, + render: (_ tick: Int, _ progress: State) -> Buffer, + onKey: (TerminalKey, CancelFlag) -> Void = { _, _ in }, + work: @escaping (WorkProgress, CancelFlag) -> Output + ) -> Output { + BackgroundWork.run( + progress: initial, + draw: { tick, progress in draw(render(tick, progress)) }, + readKey: { readKey(timeoutMilliseconds: $0) }, + onKey: onKey, + work: work + ) + } +} diff --git a/cli/Sources/TerminalUI/Buffer.swift b/cli/Sources/TerminalUI/Buffer.swift new file mode 100644 index 0000000..95668a6 --- /dev/null +++ b/cli/Sources/TerminalUI/Buffer.swift @@ -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.. 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.. left, bottom > top else { return } + let blanks = String(repeating: " ", count: right - left) + for row in top.. [String] { + (0.. [String] { + lines(colors: false) + } + + private func index(x: Int, y: Int) -> Int { + precondition((0.. 0 { + cells[index - 1] = .blank + } + if column + 1 < width, cells[index + 1].symbol == "" { + cells[index + 1] = .blank + } + } +} diff --git a/cli/Sources/TerminalUI/Dialog.swift b/cli/Sources/TerminalUI/Dialog.swift new file mode 100644 index 0000000..87f7b65 --- /dev/null +++ b/cli/Sources/TerminalUI/Dialog.swift @@ -0,0 +1,171 @@ +public struct DialogButton: Equatable { + public enum Role: Equatable { case normal, destructive } + + public var title: String + public var role: Role + + public init(_ title: String, role: Role = .normal) { + self.title = title + self.role = role + } +} + +public struct Dialog { + public enum MessageAlignment: Equatable { case center, leading } + + public var title: String + public var titleStyle: TextStyle + public var borderStyle: TextStyle + public var message: [Span] + public var buttons: [DialogButton] + public var focusedButton: Int? + public var hints: KeyHints? + public var messageAlignment: MessageAlignment + + public init( + title: String, + titleStyle: TextStyle = TextStyle(bold: true), + borderStyle: TextStyle = .plain, + message: [Span] = [], + buttons: [DialogButton] = [], + focusedButton: Int? = nil, + hints: KeyHints? = nil, + messageAlignment: MessageAlignment = .center + ) { + self.title = title + self.titleStyle = titleStyle + self.borderStyle = borderStyle + self.message = message + self.buttons = buttons + self.focusedButton = focusedButton + self.hints = hints + self.messageAlignment = messageAlignment + } + + private static let sidePadding = 1 + private static let titleMargin = 2 + + public static func maxContentWidth(in area: Rect) -> Int { + max(1, area.width - 8 - 2 * Dialog.sidePadding) + } + + public static func maxTitleWidth(in area: Rect) -> Int { + max(1, maxContentWidth(in: area) - titleMargin) + } + + public func frame(in area: Rect) -> Rect { + placement(in: area).frame + } + + public func render(in area: Rect, buffer: inout Buffer) { + let (frame, layout) = placement(in: area) + buffer.clear(frame) + let inner = Window(title: title, borderStyle: borderStyle, titleStyle: titleStyle) + .render(in: frame, buffer: &buffer) + let content = Rect(x: inner.x + Dialog.sidePadding, y: inner.y, width: max(0, inner.width - 2 * Dialog.sidePadding), height: inner.height) + var row = content.y + layout.blankAboveFirst + for message in layout.messageRows { + renderMessage(message, in: content, row: row, buffer: &buffer) + row += 1 + } + row += layout.blankBelowMessage + for spans in buttonRows(width: content.width) { + renderCentered(spans, in: content, row: row, buffer: &buffer) + row += 1 + } + row += layout.blankBelowButtons + for spans in hints?.rows(width: content.width) ?? [] { + renderCentered(spans, in: content, row: row, buffer: &buffer) + row += 1 + } + } + + private struct Layout { + var messageRows: [Span] + var blankAboveFirst = 0 + var blankBelowMessage = 0 + var blankBelowButtons = 0 + var blankBelowLast = 0 + var hintRows: Int + var buttonRows: Int + var height: Int { + messageRows.count + blankAboveFirst + blankBelowMessage + blankBelowButtons + blankBelowLast + buttonRows + hintRows + } + } + + private func placement(in area: Rect) -> (frame: Rect, layout: Layout) { + let contentWidth = self.contentWidth(maxWidth: Dialog.maxContentWidth(in: area)) + let layout = layout(contentWidth: contentWidth, maxRows: area.height - 4) + let width = contentWidth + 4 + 2 * Dialog.sidePadding + let height = layout.height + 2 + let frame = Rect( + x: area.x + (area.width - width) / 2, + y: area.y + (area.height - height) / 2, + width: width, + height: height + ) + return (frame, layout) + } + + private func buttonSpan(_ index: Int) -> Span { + let button = buttons[index] + let title = Term.sanitize(button.title) + let style = TextStyle(color: button.role == .destructive ? .red : nil, inverse: index == focusedButton) + return Span(index == focusedButton ? "› \(title) ‹" : "[ \(title) ]", style) + } + + private func buttonRows(width: Int) -> [[Span]] { + guard !buttons.isEmpty else { return [] } + let spans = buttons.indices.map(buttonSpan) + let sideBySide = spans.enumerated().flatMap { $0.offset == 0 ? [$0.element] : [Span(" "), $0.element] } + return Term.displayWidth(sideBySide) <= width ? [sideBySide] : spans.map { [$0] } + } + + private func contentWidth(maxWidth: Int) -> Int { + let titleWidth = Term.displayWidth(Term.sanitize(title)) + Dialog.titleMargin + let messageWidth = message.map { Term.displayWidth(Term.sanitize($0.text)) }.max() ?? 0 + let buttonsWidth = buttonRows(width: maxWidth).map(Term.displayWidth).max() ?? 0 + let hintWidth = hints?.rows(width: .max).first.map(Term.displayWidth) ?? 0 + return max(1, min(max(titleWidth, messageWidth, buttonsWidth, hintWidth), maxWidth)) + } + + private func layout(contentWidth: Int, maxRows: Int) -> Layout { + let hintRows = hints?.rows(width: contentWidth).count ?? 0 + let buttonRows = self.buttonRows(width: contentWidth).count + let tailRows = buttonRows + hintRows + let wrapped = message.flatMap { paragraph in + wrap(paragraph.text, width: contentWidth).map { Span($0, paragraph.style) } + } + var layout = Layout(messageRows: wrapped, hintRows: hintRows, buttonRows: buttonRows) + let hasGroups = !wrapped.isEmpty || tailRows > 0 + layout.blankAboveFirst = hasGroups ? 1 : 0 + layout.blankBelowMessage = !wrapped.isEmpty && tailRows > 0 ? 1 : 0 + layout.blankBelowButtons = buttonRows > 0 && hintRows > 0 ? 1 : 0 + layout.blankBelowLast = hasGroups ? 1 : 0 + if layout.height > maxRows { layout.blankBelowLast = 0 } + if layout.height > maxRows { layout.blankBelowButtons = 0 } + if layout.height > maxRows { layout.blankBelowMessage = 0 } + if layout.height > maxRows { layout.blankAboveFirst = 0 } + layout.messageRows = Array(wrapped.prefix(max(0, maxRows - layout.height + wrapped.count))) + return layout + } + + private func wrap(_ text: String, width: Int) -> [String] { + messageAlignment == .center ? Term.balancedWrap(text, width: width) : Term.wordWrap(text, width: width) + } + + private func renderCentered(_ spans: [Span], in content: Rect, row: Int, buffer: inout Buffer) { + let width = Term.displayWidth(spans) + let column = content.x + max(0, content.width - width) / 2 + buffer.write(spans, x: column, y: row, maxWidth: content.x + content.width - column) + } + + private func renderMessage(_ span: Span, in content: Rect, row: Int, buffer: inout Buffer) { + switch messageAlignment { + case .center: + renderCentered([span], in: content, row: row, buffer: &buffer) + case .leading: + buffer.write([span], x: content.x, y: row, maxWidth: content.width) + } + } +} diff --git a/cli/Sources/TerminalUI/Gauge.swift b/cli/Sources/TerminalUI/Gauge.swift new file mode 100644 index 0000000..9100a4d --- /dev/null +++ b/cli/Sources/TerminalUI/Gauge.swift @@ -0,0 +1,15 @@ +public struct Gauge { + public var fraction: Double + + public init(fraction: Double) { + self.fraction = fraction + } + + public func spans(width: Int, filled filledStyle: TextStyle = .plain) -> [Span] { + let filledCount = Term.filledCount(fraction: fraction, width: width) + return [ + Span(String(repeating: "█", count: filledCount), filledStyle), + Span(String(repeating: "░", count: max(0, width) - filledCount), TextStyle(dim: true)) + ].filter { !$0.text.isEmpty } + } +} diff --git a/cli/Sources/TerminalUI/KeyHints.swift b/cli/Sources/TerminalUI/KeyHints.swift new file mode 100644 index 0000000..c45151f --- /dev/null +++ b/cli/Sources/TerminalUI/KeyHints.swift @@ -0,0 +1,47 @@ +public struct KeyHint: Equatable { + public var key: String, action: String + + public init(_ key: String, _ action: String) { + self.key = key + self.action = action + } +} + +public struct KeyHints { + public var hints: [KeyHint] + + public init(_ hints: [KeyHint]) { + self.hints = hints + } + + public func rows(width: Int) -> [[Span]] { + let separator = " " + var rows: [[Span]] = [] + var current: [Span] = [] + var used = 0 + for hint in hints { + let spans = [Span(hint.key), Span(" " + hint.action, TextStyle(dim: true))] + let hintWidth = Term.displayWidth(spans) + let needed = current.isEmpty ? hintWidth : used + separator.count + hintWidth + if !current.isEmpty, needed > width { + rows.append(current) + current = [] + } + if current.isEmpty { + used = hintWidth + } else { + current.append(Span(separator)) + used = needed + } + current += spans + } + if !current.isEmpty { rows.append(current) } + return rows + } + + public func render(in rect: Rect, buffer: inout Buffer) { + for (row, spans) in rows(width: rect.width).prefix(rect.height).enumerated() { + buffer.write(spans, x: rect.x, y: rect.y + row, maxWidth: rect.width) + } + } +} diff --git a/cli/Sources/TerminalUI/KeyInput.swift b/cli/Sources/TerminalUI/KeyInput.swift new file mode 100644 index 0000000..96784f4 --- /dev/null +++ b/cli/Sources/TerminalUI/KeyInput.swift @@ -0,0 +1,34 @@ +import Darwin + +final class KeyInput { + private let descriptor: Int32 + private(set) var isClosed = false + + init(descriptor: Int32 = STDIN_FILENO) { + self.descriptor = descriptor + } + + func readByte() -> UInt8? { + var byte: UInt8 = 0 + while true { + let result = Darwin.read(descriptor, &byte, 1) + if result == 1 { return byte } + if result == -1 && errno == EINTR { continue } + isClosed = true + return nil + } + } + + func wait(milliseconds: Int32) -> Bool { + guard !isClosed else { + _ = poll(nil, 0, milliseconds) + return true + } + return available(milliseconds: milliseconds) + } + + func available(milliseconds: Int32) -> Bool { + var pollDescriptor = pollfd(fd: descriptor, events: Int16(POLLIN), revents: 0) + return poll(&pollDescriptor, 1, milliseconds) > 0 + } +} diff --git a/cli/Sources/TerminalUI/Scrollbar.swift b/cli/Sources/TerminalUI/Scrollbar.swift new file mode 100644 index 0000000..538ba62 --- /dev/null +++ b/cli/Sources/TerminalUI/Scrollbar.swift @@ -0,0 +1,29 @@ +public struct Scrollbar { + public var total: Int, visible: Int, offset: Int + + public init(total: Int, visible: Int, offset: Int) { + self.total = total + self.visible = visible + self.offset = offset + } + + public static func centeredOffset(total: Int, visible: Int, focus: Int) -> Int { + max(0, min(max(0, total - visible), focus - visible / 2)) + } + + public func thumb(trackHeight: Int) -> Range? { + guard total > visible, trackHeight > 0 else { return nil } + let length = max(1, Int((Double(trackHeight * visible) / Double(total)).rounded())) + let travel = total - visible + let clamped = max(0, min(travel, offset)) + let start = Int((Double((trackHeight - length) * clamped) / Double(travel)).rounded()) + return start..<(start + length) + } + + public func render(column x: Int, rows: Range, buffer: inout Buffer, style: TextStyle = .plain) { + guard let thumb = thumb(trackHeight: rows.count) else { return } + for row in thumb { + buffer.write("┃", x: x, y: rows.lowerBound + row, style: style) + } + } +} diff --git a/cli/Sources/TerminalUI/Spinner.swift b/cli/Sources/TerminalUI/Spinner.swift new file mode 100644 index 0000000..525da53 --- /dev/null +++ b/cli/Sources/TerminalUI/Spinner.swift @@ -0,0 +1,7 @@ +public enum Spinner { + private static let frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏".map(String.init) + + public static func frame(tick: Int) -> String { + frames[tick % frames.count] + } +} diff --git a/cli/Sources/TerminalUI/Style.swift b/cli/Sources/TerminalUI/Style.swift new file mode 100644 index 0000000..bf08663 --- /dev/null +++ b/cli/Sources/TerminalUI/Style.swift @@ -0,0 +1,52 @@ +public enum TerminalColor: Equatable { + case red, green, yellow, cyan + + var sgrCode: Int { + switch self { + case .red: return 31 + case .green: return 32 + case .yellow: return 33 + case .cyan: return 36 + } + } +} + +public struct TextStyle: Equatable { + public var color: TerminalColor? + public var bold: Bool + public var dim: Bool + public var inverse: Bool + + public init(color: TerminalColor? = nil, bold: Bool = false, dim: Bool = false, inverse: Bool = false) { + self.color = color + self.bold = bold + self.dim = dim + self.inverse = inverse + } + + public static let plain = TextStyle() + + var sgrParameters: String { + var codes: [Int] = [] + if bold { codes.append(1) } + if dim { codes.append(2) } + if inverse { codes.append(7) } + if let color { codes.append(color.sgrCode) } + return codes.map(String.init).joined(separator: ";") + } + + func transition(from current: TextStyle) -> String { + guard self != current else { return "" } + return self == .plain ? "\u{1B}[0m" : "\u{1B}[0m\u{1B}[\(sgrParameters)m" + } +} + +public struct Span: Equatable { + public var text: String + public var style: TextStyle + + public init(_ text: String, _ style: TextStyle = .plain) { + self.text = text + self.style = style + } +} diff --git a/cli/Sources/TerminalUI/Term.swift b/cli/Sources/TerminalUI/Term.swift new file mode 100644 index 0000000..3dc9e1d --- /dev/null +++ b/cli/Sources/TerminalUI/Term.swift @@ -0,0 +1,250 @@ +import Darwin +import Foundation + +public enum Term { + private static let localeConfigured: Void = { + setlocale(LC_CTYPE, "") + }() + + public static var colorEnabled: Bool { + ProcessInfo.processInfo.environment["NO_COLOR"] == nil + && ProcessInfo.processInfo.environment["TERM"] != "dumb" + && isatty(fileno(stdout)) == 1 + } + + private static func style(_ string: String, _ codes: String) -> String { + colorEnabled ? "\u{1B}[\(codes)m\(string)\u{1B}[0m" : string + } + + public static func bold(_ string: String) -> String { style(string, "1") } + public static func dim(_ string: String) -> String { style(string, "2") } + public static func red(_ string: String) -> String { style(string, "31") } + public static func green(_ string: String) -> String { style(string, "32") } + public static func yellow(_ string: String) -> String { style(string, "33") } + public static func cyan(_ string: String) -> String { style(string, "36") } + + public static func err(_ string: String) { + FileHandle.standardError.write(Data((string + "\n").utf8)) + } + + public static func confirm(_ prompt: String, default defaultValue: Bool = false) -> Bool { + guard isatty(fileno(stdin)) == 1 else { return defaultValue } + let hint = defaultValue ? "[Y/n]" : "[y/N]" + print("\(sanitize(prompt)) \(hint) ", terminator: "") + guard let line = readLine() else { return defaultValue } + let answer = line.trimmingCharacters(in: .whitespaces).lowercased() + if answer.isEmpty { return defaultValue } + return answer == "y" || answer == "yes" + } + + static func filledCount(fraction: Double, width: Int) -> Int { + let clamped = max(0, min(1, fraction)) + return Int((Double(max(0, width)) * clamped).rounded()) + } + + public static func sanitize(_ string: String) -> String { + var output = "" + output.reserveCapacity(string.count) + for scalar in string.unicodeScalars { + switch scalar.value { + case 0x20...0x7E, 0xA0...0x10FFFF: + if scalar.value >= 0x7F && scalar.value <= 0x9F { + output.append("�") + } else { + output.unicodeScalars.append(scalar) + } + case 0x09: + output.append(" ") + default: + output.append("�") + } + } + return output + } + + public static func displayWidth(_ spans: [Span]) -> Int { + spans.reduce(0) { $0 + displayWidth(sanitize($1.text)) } + } + + public static func displayWidth(_ string: String) -> Int { + _ = localeConfigured + var width = 0 + var escapeState = 0 + for scalar in string.unicodeScalars { + if escapeState == 1 { + escapeState = scalar.value == 0x5B ? 2 : 0 + continue + } + if escapeState == 2 { + if scalar.value >= 0x40 && scalar.value <= 0x7E { + escapeState = 0 + } + continue + } + if scalar.value == 0x1B { + escapeState = 1 + continue + } + let scalarWidth = wcwidth(wchar_t(scalar.value)) + width += scalarWidth > 0 ? Int(scalarWidth) : 0 + } + return width + } + + public static func pad(_ string: String, to width: Int) -> String { + let missing = max(0, width - displayWidth(string)) + return string + String(repeating: " ", count: missing) + } + + public static func truncate(_ string: String, to width: Int, middle: Bool = false) -> String { + let safe = sanitize(string) + guard width > 0 else { return "" } + guard displayWidth(safe) > width else { return safe } + guard width > 1 else { return "…" } + + if middle { + let leftWidth = (width - 1) / 2 + let rightWidth = width - 1 - leftWidth + return prefix(safe, width: leftWidth) + "…" + suffix(safe, width: rightWidth) + } + return prefix(safe, width: width - 1) + "…" + } + + private static func prefix(_ string: String, width: Int) -> String { + var output = "" + var used = 0 + for character in string { + let value = String(character) + let characterWidth = displayWidth(value) + guard used + characterWidth <= width else { break } + output.append(character) + used += characterWidth + } + return output + } + + static func suffix(_ string: String, width: Int) -> String { + var characters: [Character] = [] + var used = 0 + for character in string.reversed() { + let value = String(character) + let characterWidth = displayWidth(value) + guard used + characterWidth <= width else { break } + characters.append(character) + used += characterWidth + } + return String(characters.reversed()) + } +} + +extension Term { + public static func wrap(_ string: String, width: Int) -> [String] { + let characters = sanitize(string).map { (character: $0, style: TextStyle.plain) } + let (lines, last) = hardBreak(characters, width: width) + return (lines + [last]).map { String($0.map(\.character)) } + } + + static func wordWrap(_ string: String, width: Int) -> [String] { + wordWrap([Span(string)], width: width).map { $0.map(\.text).joined() } + } + + static func balancedWrap(_ string: String, width: Int) -> [String] { + let lineCount = wordWrap(string, width: width).count + let widestWord = string.split(whereSeparator: \.isWhitespace).map { displayWidth(String($0)) }.max() ?? 1 + let narrowest = (min(max(1, width), widestWord)...max(1, width)).first { wordWrap(string, width: $0).count == lineCount } ?? width + return wordWrap(string, width: narrowest) + } + + public static func wordWrap(_ spans: [Span], width: Int) -> [[Span]] { + var lines: [[StyledCharacter]] = [] + var current: [StyledCharacter] = [] + var gap: [StyledCharacter] = [] + for token in wordTokens(spans) { + guard !token.isSpaces else { + gap = token.characters + continue + } + let word = token.characters + if displayWidth(word) <= width { + if current.isEmpty { + current = word + } else if displayWidth(current) + displayWidth(gap) + displayWidth(word) <= width { + current += gap + word + } else { + lines.append(current) + current = word + } + continue + } + if !current.isEmpty { lines.append(current) } + let (broken, last) = hardBreak(word, width: width) + lines += broken + current = last + } + lines.append(current) + return lines.map(merged) + } + + private typealias StyledCharacter = (character: Character, style: TextStyle) + + private static func hardBreak(_ characters: [StyledCharacter], width: Int) -> (lines: [[StyledCharacter]], last: [StyledCharacter]) { + var lines: [[StyledCharacter]] = [] + var current: [StyledCharacter] = [] + var used = 0 + for character in characters { + let characterWidth = displayWidth([character]) + if used + characterWidth > width && !current.isEmpty { + lines.append(current) + current = [] + used = 0 + } + current.append(character) + used += characterWidth + } + return (lines, current) + } + + public static func styled(_ spans: [Span], colors: Bool = colorEnabled) -> String { + var output = "" + var current = TextStyle.plain + for span in spans { + let text = sanitize(span.text) + guard !text.isEmpty else { continue } + if colors { output += span.style.transition(from: current) } + current = span.style + output += text + } + return colors && current != .plain ? output + "\u{1B}[0m" : output + } + + private static func displayWidth(_ characters: [StyledCharacter]) -> Int { + characters.reduce(0) { $0 + max(0, displayWidth(String($1.character))) } + } + + private static func merged(_ characters: [StyledCharacter]) -> [Span] { + var spans: [Span] = [] + for (character, style) in characters { + if let last = spans.last, last.style == style { + spans[spans.count - 1].text.append(character) + } else { + spans.append(Span(String(character), style)) + } + } + return spans + } + + private static func wordTokens(_ spans: [Span]) -> [(characters: [StyledCharacter], isSpaces: Bool)] { + var tokens: [(characters: [StyledCharacter], isSpaces: Bool)] = [] + for span in spans { + for character in sanitize(span.text) { + let isSpace = character == " " + if let last = tokens.last, last.isSpaces == isSpace { + tokens[tokens.count - 1].characters.append((character, span.style)) + } else { + tokens.append(([(character, span.style)], isSpace)) + } + } + } + return tokens + } +} diff --git a/cli/Sources/puremac/Core/TerminalSession.swift b/cli/Sources/TerminalUI/TerminalSession.swift similarity index 77% rename from cli/Sources/puremac/Core/TerminalSession.swift rename to cli/Sources/TerminalUI/TerminalSession.swift index 97faf4b..487fad8 100644 --- a/cli/Sources/puremac/Core/TerminalSession.swift +++ b/cli/Sources/TerminalUI/TerminalSession.swift @@ -1,7 +1,7 @@ import Darwin import Foundation -enum TerminalKey: Equatable { +public enum TerminalKey: Equatable { case up case down case left @@ -13,13 +13,14 @@ enum TerminalKey: Equatable { case enter case escape case space + case tab case backspace case delete case character(Character) case unknown } -enum TerminalSessionError: Error { +public enum TerminalSessionError: Error { case unsupported case setupFailed } @@ -75,24 +76,25 @@ private func terminalExitHandler() { terminalRestore() } -final class TerminalSession { - static var isSupported: Bool { +public final class TerminalSession { + public static var isSupported: Bool { isatty(STDIN_FILENO) == 1 && isatty(STDOUT_FILENO) == 1 && ProcessInfo.processInfo.environment["TERM"] != "dumb" } private var isClosed = false + private let input = KeyInput() - var width: Int { + public var width: Int { terminalDimensions().width } - var height: Int { + public var height: Int { terminalDimensions().height } - init() throws { + public init() throws { guard Self.isSupported else { throw TerminalSessionError.unsupported } @@ -138,33 +140,43 @@ final class TerminalSession { close() } - func draw(lines: [String]) { - guard !isClosed else { return } + static func frame(lines: [String], height: Int) -> String { let visibleLines = Array(lines.prefix(max(1, height))) - let frame = visibleLines.map { "\u{1B}[2K" + $0 }.joined(separator: "\r\n") - terminalWrite("\u{1B}[H" + frame + "\u{1B}[J") + let body = visibleLines.map { "\u{1B}[2K" + $0 }.joined(separator: "\r\n") + return "\u{1B}[H" + body + (visibleLines.count < height ? "\u{1B}[J" : "") } - func readKey() -> TerminalKey { - guard !isClosed, let first = readByte() else { return .escape } + public func draw(_ buffer: Buffer) { + guard !isClosed else { return } + terminalWrite(Self.frame(lines: buffer.lines(), height: height)) + } - switch first { - case 0x0A, 0x0D: - return .enter - case 0x20: - return .space - case 0x08, 0x7F: - return .backspace - case 0x1B: - return readEscapeSequence() - case 0x01...0x1A: - return .unknown - default: - return decodeCharacter(firstByte: first) + public func readKey() -> TerminalKey { + guard !isClosed, let first = input.readByte() else { return .escape } + + if let key = Self.key(forSimpleByte: first) { return key } + return first == 0x1B ? readEscapeSequence() : decodeCharacter(firstByte: first) + } + + public func readKey(timeoutMilliseconds: Int) -> TerminalKey? { + guard !isClosed else { return nil } + let milliseconds = Int32(clamping: timeoutMilliseconds) + guard input.wait(milliseconds: milliseconds) else { return nil } + return readKey() + } + + static func key(forSimpleByte byte: UInt8) -> TerminalKey? { + switch byte { + case 0x0A, 0x0D: return .enter + case 0x20: return .space + case 0x09: return .tab + case 0x08, 0x7F: return .backspace + case 0x01...0x1A: return .unknown + default: return nil } } - func close() { + public func close() { guard !isClosed else { return } isClosed = true terminalRestore() @@ -179,13 +191,13 @@ final class TerminalSession { } private func readEscapeSequence() -> TerminalKey { - guard inputAvailable(milliseconds: 24), let second = readByte() else { + guard input.available(milliseconds: 24), let second = input.readByte() else { return .escape } guard second == 0x5B || second == 0x4F else { return .escape } - guard inputAvailable(milliseconds: 24), let third = readByte() else { + guard input.available(milliseconds: 24), let third = input.readByte() else { return .escape } @@ -197,7 +209,7 @@ final class TerminalSession { case 0x48: return .home case 0x46: return .end case 0x31, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38: - guard inputAvailable(milliseconds: 24), let fourth = readByte(), fourth == 0x7E else { + guard input.available(milliseconds: 24), let fourth = input.readByte(), fourth == 0x7E else { return .unknown } switch third { @@ -225,7 +237,7 @@ final class TerminalSession { var bytes = [firstByte] while bytes.count < expectedCount { - guard inputAvailable(milliseconds: 24), let byte = readByte() else { + guard input.available(milliseconds: 24), let byte = input.readByte() else { return .unknown } bytes.append(byte) @@ -237,21 +249,6 @@ final class TerminalSession { return .character(character) } - private func readByte() -> UInt8? { - var byte: UInt8 = 0 - while true { - let result = Darwin.read(STDIN_FILENO, &byte, 1) - if result == 1 { return byte } - if result == -1 && errno == EINTR { continue } - return nil - } - } - - private func inputAvailable(milliseconds: Int32) -> Bool { - var descriptor = pollfd(fd: STDIN_FILENO, events: Int16(POLLIN), revents: 0) - return poll(&descriptor, 1, milliseconds) > 0 - } - private func terminalDimensions() -> (width: Int, height: Int) { var window = winsize() guard ioctl(STDOUT_FILENO, TIOCGWINSZ, &window) == 0 else { diff --git a/cli/Sources/TerminalUI/TextInput.swift b/cli/Sources/TerminalUI/TextInput.swift new file mode 100644 index 0000000..3b7f6c0 --- /dev/null +++ b/cli/Sources/TerminalUI/TextInput.swift @@ -0,0 +1,44 @@ +public struct TextInput: Equatable { + public private(set) var text: String + + public init(_ text: String = "") { + self.text = text + } + + var isEmpty: Bool { text.isEmpty } + + mutating func insert(_ character: Character) { + text.append(character) + } + + mutating func backspace() { + text = String(text.dropLast()) + } + + @discardableResult + public mutating func handle(_ key: TerminalKey) -> Bool { + switch key { + case .space: + insert(" ") + case .backspace, .delete: + guard !isEmpty else { return false } + backspace() + case .character(let character): + guard !character.unicodeScalars.allSatisfy({ $0.value < 0x20 || $0.value == 0x7F }) else { return false } + insert(character) + default: + return false + } + return true + } + + public func fieldSpans(label: String, width: Int) -> [Span] { + let room = max(1, width - Term.displayWidth(label) - 1) + let shown = Term.displayWidth(text) > room ? "…" + Term.suffix(text, width: room - 1) : text + return [ + Span(label, TextStyle(color: .cyan)), + Span(shown), + Span(" ", TextStyle(inverse: true)), + ] + } +} diff --git a/cli/Sources/TerminalUI/Window.swift b/cli/Sources/TerminalUI/Window.swift new file mode 100644 index 0000000..394a16c --- /dev/null +++ b/cli/Sources/TerminalUI/Window.swift @@ -0,0 +1,75 @@ +public struct Window { + public var title: String + public var info: String? + public var borderStyle: TextStyle + public var titleStyle: TextStyle + public var infoStyle: TextStyle + + public init( + title: String, + info: String? = nil, + borderStyle: TextStyle = TextStyle(dim: true), + titleStyle: TextStyle = TextStyle(color: .cyan, bold: true), + infoStyle: TextStyle = TextStyle(bold: true) + ) { + self.title = title + self.info = info + self.borderStyle = borderStyle + self.titleStyle = titleStyle + self.infoStyle = infoStyle + } + + @discardableResult + public func render(in rect: Rect, buffer: inout Buffer) -> Rect { + guard rect.width >= 2, rect.height >= 2 else { return contentRect(in: rect) } + let right = rect.x + rect.width - 1 + let bottom = rect.y + rect.height - 1 + buffer.write(horizontalBorder("╭", "╮", width: rect.width), x: rect.x, y: rect.y, style: borderStyle) + buffer.write(horizontalBorder("╰", "╯", width: rect.width), x: rect.x, y: bottom, style: borderStyle) + for row in (rect.y + 1).. Rect { + guard rect.width >= 2, rect.height >= 2 else { + return Rect(x: rect.x, y: rect.y, width: 0, height: 0) + } + return Rect( + x: rect.x + 2, + y: rect.y + 1, + width: max(0, rect.width - 4), + height: max(0, rect.height - 2) + ) + } + + public func renderDivider(atRow y: Int, in rect: Rect, buffer: inout Buffer) { + guard rect.width >= 2 else { return } + buffer.write(horizontalBorder("├", "┤", width: rect.width), x: rect.x, y: y, style: borderStyle) + } + + private func horizontalBorder(_ left: String, _ right: String, width: Int) -> String { + left + String(repeating: "─", count: width - 2) + right + } + + private func renderTitle(in rect: Rect, buffer: inout Buffer) { + let infoText = info.map(Term.sanitize) + var titleSpace = rect.width - 6 + if let infoText { + let infoWidth = Term.displayWidth(infoText) + let withInfo = rect.width - 9 - infoWidth + if withInfo >= 4 { + titleSpace = withInfo + let infoColumn = rect.x + rect.width - 4 - infoWidth + buffer.write([Span(" "), Span(infoText, infoStyle), Span(" ")], x: infoColumn, y: rect.y) + } + } + let text = Term.truncate(title, to: titleSpace) + if !text.isEmpty { + buffer.write([Span(" "), Span(text, titleStyle), Span(" ")], x: rect.x + 2, y: rect.y) + } + } +} diff --git a/cli/Sources/puremac/Commands/Analyze.swift b/cli/Sources/puremac/Commands/Analyze.swift index a07e2b9..3964595 100644 --- a/cli/Sources/puremac/Commands/Analyze.swift +++ b/cli/Sources/puremac/Commands/Analyze.swift @@ -1,5 +1,6 @@ import ArgumentParser import Foundation +import TerminalUI struct Analyze: ParsableCommand { static let configuration = CommandConfiguration( @@ -71,6 +72,12 @@ struct Analyze: ParsableCommand { let skippedCount: Int var isPartial: Bool { inaccessibleCount > 0 || skippedCount > 0 } + + static func suffix(isDirectory: Bool, isPackage: Bool) -> String { + isDirectory && !isPackage ? "/" : (isPackage ? " [package]" : "") + } + + var suffix: String { Self.suffix(isDirectory: isDir, isPackage: isPackage) } } struct FolderSnapshot: Equatable { @@ -90,13 +97,43 @@ struct Analyze: ParsableCommand { var errors: [String] = [] } + enum ScanEvent: Equatable { + case measuring(position: Int, count: Int, name: String, isDirectory: Bool, isPackage: Bool) + case measured(Child) + } + + struct MeasureProgress { + let path: String + var position = 0 + var count = 0 + var name = "" + var nameIsDirectory = false + var nameIsPackage = false + var measured: [Child] = [] + + mutating func apply(_ event: ScanEvent) { + switch event { + case .measuring(let position, let count, let name, let isDirectory, let isPackage): + (self.position, self.count, self.name) = (position, count, name) + (nameIsDirectory, nameIsPackage) = (isDirectory, isPackage) + case .measured(let child): + measured.append(child) + } + } + } + struct BrowserLevel { let snapshot: FolderSnapshot var selected = 0 var offset = 0 } - func scanFolder(_ directory: String, ignore: IgnoreStore = IgnoreStore()) -> FolderSnapshot { + func scanFolder( + _ directory: String, + ignore: IgnoreStore = IgnoreStore(), + progress: ((ScanEvent) -> Void)? = nil, + isCancelled: () -> Bool = { false } + ) -> FolderSnapshot { let standardized = (directory as NSString).standardizingPath let directoryURL = URL(fileURLWithPath: standardized, isDirectory: true) let names: [String] @@ -116,20 +153,25 @@ struct Analyze: ParsableCommand { var context = MeasureContext() var children: [Child] = [] let orderedNames = names.sorted { $0.localizedStandardCompare($1) == .orderedAscending } - for name in orderedNames { + for (index, name) in orderedNames.enumerated() { + if isCancelled() { break } let url = directoryURL.appendingPathComponent(name) - if let child = measureChild(url, ignore: ignore, context: &context) { + if let progress { + let kind = Self.kind(of: url) + progress(.measuring( + position: index + 1, count: orderedNames.count, name: name, + isDirectory: kind.isDirectory, isPackage: kind.isPackage + )) + } + let measured = measureChild(url, ignore: ignore, context: &context, isCancelled: isCancelled) + if isCancelled() { break } + if let child = measured { children.append(child) + progress?(.measured(child)) } } - children.sort { - if $0.size == $1.size { - return URL(fileURLWithPath: $0.path).lastPathComponent - .localizedStandardCompare(URL(fileURLWithPath: $1.path).lastPathComponent) == .orderedAscending - } - return $0.size > $1.size - } + children = Self.ranked(children) return FolderSnapshot( path: standardized, children: children, @@ -140,17 +182,35 @@ struct Analyze: ParsableCommand { ) } + static func ranked(_ children: [Child]) -> [Child] { + children.sorted { + if $0.size == $1.size { + return URL(fileURLWithPath: $0.path).lastPathComponent + .localizedStandardCompare(URL(fileURLWithPath: $1.path).lastPathComponent) == .orderedAscending + } + return $0.size > $1.size + } + } + func sizedChildren(of directory: String) -> [Child] { scanFolder(directory).children } - private func measureChild(_ topURL: URL, ignore: IgnoreStore, context: inout MeasureContext) -> Child? { + private static func kind(of url: URL) -> (isDirectory: Bool, isPackage: Bool) { + let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .isPackageKey]) + let isDirectory = values?.isDirectory == true + return (isDirectory, isDirectory && values?.isPackage == true) + } + + private func measureChild( + _ topURL: URL, ignore: IgnoreStore, context: inout MeasureContext, isCancelled: () -> Bool + ) -> Child? { let path = topURL.path if Safety.ignoredPathProtectsCandidate(path, ignore: ignore) { context.skippedCount += 1 return nil } - let measurement = DirSizer.measure(of: path) + let measurement = DirSizer.measure(of: path, isCancelled: isCancelled) switch measurement.status { case .symlink, .cloudOrDataless: context.skippedCount += max(1, measurement.skippedEntries) @@ -166,13 +226,12 @@ struct Analyze: ParsableCommand { break } - let values = try? topURL.resourceValues(forKeys: [.isDirectoryKey, .isPackageKey]) - let isDirectory = values?.isDirectory == true + let kind = Self.kind(of: topURL) return Child( path: path, size: measurement.bytes, - isDir: isDirectory, - isPackage: isDirectory && values?.isPackage == true, + isDir: kind.isDirectory, + isPackage: kind.isPackage, inaccessibleCount: 0, skippedCount: measurement.status == .partial ? max(1, measurement.skippedEntries) : 0 ) @@ -210,19 +269,20 @@ struct Analyze: ParsableCommand { total: Int64, indent: String, depthLeft: Int, - ignore: IgnoreStore = IgnoreStore() + ignore: IgnoreStore = IgnoreStore(), + colors: Bool = Term.colorEnabled ) { for child in children where child.size > 0 || child.isPartial { let fraction = total > 0 ? Double(child.size) / Double(total) : 0 let percentage = String(format: "%5.1f%%", fraction * 100) - let size = ByteCount.human(child.size).padding(toLength: 10, withPad: " ", startingAt: 0) - let suffix = child.isDir && !child.isPackage ? "/" : (child.isPackage ? " [package]" : "") - let partial = child.isPartial ? Term.yellow(" !") : "" - let name = URL(fileURLWithPath: child.path).lastPathComponent + suffix - print("\(indent)\(size) \(Term.dim(percentage)) \(Term.bar(fraction: fraction, width: 20)) \(name)\(partial)") + let size = Render.coloredSizeCol(child.size, colors: colors) + let bar = Term.styled(Gauge(fraction: fraction).spans(width: 20, filled: SizeLevel.style(bytes: child.size, focused: false)), colors: colors) + let partial = child.isPartial ? Render.paint(" !", TextStyle(color: .yellow), colors) : "" + let name = URL(fileURLWithPath: child.path).lastPathComponent + child.suffix + print("\(indent)\(size) \(Render.paint(percentage, TextStyle(dim: true), colors)) \(bar) \(name)\(partial)") if depthLeft > 0 && child.isDir && !child.isPackage { let snapshot = scanFolder(child.path, ignore: ignore) - printLevel(Array(snapshot.children.prefix(10)), total: child.size, indent: indent + " ", depthLeft: depthLeft - 1, ignore: ignore) + printLevel(Array(snapshot.children.prefix(10)), total: child.size, indent: indent + " ", depthLeft: depthLeft - 1, ignore: ignore, colors: colors) printDiagnostics(snapshot, indent: indent + " ") } } @@ -241,8 +301,8 @@ struct Analyze: ParsableCommand { private func browse(_ target: String, ignore: IgnoreStore) throws { let session = try TerminalSession() defer { session.close() } - session.draw(lines: measuringLines(target, width: session.width)) - var levels = [BrowserLevel(snapshot: scanFolder(target, ignore: ignore))] + guard case .snapshot(let first) = measure(target, ignore: ignore, session: session, drillingDown: false) else { return } + var levels = [BrowserLevel(snapshot: first)] var running = true while running { @@ -254,7 +314,7 @@ struct Analyze: ParsableCommand { count: levels[index].snapshot.children.count, visibleRows: visibleRows ) - session.draw(lines: Self.browserLines(level: levels[index], width: session.width, height: session.height)) + session.draw(Self.renderBrowser(level: levels[index], width: session.width, height: session.height)) switch session.readKey() { case .up: @@ -271,8 +331,11 @@ struct Analyze: ParsableCommand { levels[index].selected = max(0, levels[index].snapshot.children.count - 1) case .enter, .right: guard let child = Self.focusedChild(levels[index]), child.isDir, !child.isPackage else { continue } - session.draw(lines: measuringLines(child.path, width: session.width)) - levels.append(BrowserLevel(snapshot: scanFolder(child.path, ignore: ignore))) + switch measure(child.path, ignore: ignore, session: session, drillingDown: true) { + case .snapshot(let snapshot): levels.append(BrowserLevel(snapshot: snapshot)) + case .cancelled: break + case .exit: running = false + } case .left, .backspace: if levels.count > 1 { levels.removeLast() } case .escape: @@ -284,72 +347,253 @@ struct Analyze: ParsableCommand { if let child = Self.focusedChild(levels[index]) { Self.revealInFinder(child.path) } default: break } - case .delete, .space, .unknown: + case .delete, .space, .tab, .unknown: break } } } - private func measuringLines(_ path: String, width: Int) -> [String] { - let safeWidth = max(1, width) - return [ - Term.bold(Term.cyan(Term.truncate("PureMac Space Explorer", to: safeWidth))), - "", - Term.dim(Term.truncate("Measuring allocated space...", to: safeWidth)), - Term.truncate(path, to: safeWidth, middle: true), - "", - Term.dim(Term.truncate("Ctrl+C cancel", to: safeWidth)) - ] - } - - static func browserLines(level: BrowserLevel, width: Int, height: Int) -> [String] { - let safeWidth = max(1, width) - let safeHeight = max(1, height) - if safeWidth < 40 || safeHeight < 12 { - let compact = [ - Term.bold(Term.cyan(Term.truncate("PureMac Space Explorer", to: safeWidth))), - Term.truncate("Terminal too small", to: safeWidth), - Term.dim(Term.truncate("Resize to at least 40 x 12", to: safeWidth)), - "", - Term.dim(Term.truncate("Esc/q exit", to: safeWidth)) - ] - return Array(compact.prefix(safeHeight)) + private enum MeasureResult { + case snapshot(FolderSnapshot), cancelled, exit + } + + private func measure(_ path: String, ignore: IgnoreStore, session: TerminalSession, drillingDown: Bool) -> MeasureResult { + var exitRequested = false + var isCancelling = false + let snapshot = session.runWithSpinner( + progress: MeasureProgress(path: (path as NSString).standardizingPath), + render: { tick, progress in + Self.renderMeasuringBrowser( + progress: progress, tick: tick, drillingDown: drillingDown, isCancelling: isCancelling, + width: session.width, height: session.height + ) + }, + onKey: { key, cancel in + guard let action = Self.measuringKeyAction(key, drillingDown: drillingDown) else { return } + if action == .exit { exitRequested = true } + isCancelling = true + cancel.cancel() + }, + work: { progress, cancel in + let snapshot = scanFolder(path, ignore: ignore, progress: { event in + progress.update { $0.apply(event) } + }, isCancelled: { cancel.isCancelled }) + return cancel.isCancelled ? nil : snapshot + } + ) + if exitRequested { return .exit } + return snapshot.map(MeasureResult.snapshot) ?? .cancelled + } + + enum MeasureKeyAction: Equatable { + case back, exit + } + + static func measuringKeyAction(_ key: TerminalKey, drillingDown: Bool) -> MeasureKeyAction? { + switch key { + case .escape: return drillingDown ? .back : .exit + case .left, .backspace: return drillingDown ? .back : nil + case .character(let character): return character.lowercased() == "q" ? .exit : nil + default: return nil + } + } + + private static let windowTitle = "PureMac Space Explorer" + private static let focusedLabel = "Focused: " + private static let window = Window(title: windowTitle) + private static let rowPrefixWidth = 4 + Render.sizeColumnWidth + private static let minimumGaugeWidth = 6 + private static let maximumGaugeWidth = 20 + private static let gaugeReservedWidth = 32 + private static let headerRows = 2 + private static let dividerRows = 1 + private static let minimumListRows = 3 + private static let exitKeyHint = KeyHint("q/Esc", "exit") + private static let exitText = "Esc/q exit" + private static let browserHints = KeyHints([ + KeyHint("↑↓", "move"), + KeyHint("Enter/→", "open"), + KeyHint("O", "reveal"), + KeyHint("←/⌫", "back"), + exitKeyHint + ]) + private static let firstScanHints = KeyHints([exitKeyHint]) + private static let drillDownHints = KeyHints([KeyHint("←/⌫/Esc", "back"), KeyHint("q", "exit")]) + + private struct BrowserLayout { + let header: Rect + let list: Rect + let divider: Rect + let path: Rect + let error: Rect + let footer: Rect + let pathLines: [String] + + var visibleRows: Int { max(1, list.height) } + } + + private static func layout( + snapshot: FolderSnapshot, selected: Int, hints: KeyHints = browserHints, showsPath: Bool = true, width: Int, height: Int + ) -> BrowserLayout { + let content = window.contentRect(in: Rect(x: 0, y: 0, width: width, height: height)) + let focusedPath = snapshot.children.indices.contains(selected) ? snapshot.children[selected].path : snapshot.path + let pathWidth = max(1, content.width - focusedLabel.count) + let hintRows = hints.rows(width: content.width).count + let errorRows = snapshot.errors.isEmpty ? 0 : 1 + let spareRows = content.height - headerRows - dividerRows - errorRows - hintRows - minimumListRows + var pathLines = Term.wrap(focusedPath, width: pathWidth) + let pathRows = showsPath ? min(pathLines.count, max(1, spareRows)) : 0 + var budget = pathRows * pathWidth + while pathLines.count > pathRows && budget > 1 { + pathLines = Term.wrap(Term.truncate(focusedPath, to: budget, middle: true), width: pathWidth) + budget -= 1 } + let areas = content.splitRows( + [.length(headerRows), .fill, .length(dividerRows), .length(pathRows), .length(errorRows), .length(hintRows)] + ) + return BrowserLayout( + header: areas[0], + list: areas[1], + divider: areas[2], + path: areas[3], + error: areas[4], + footer: areas[5], + pathLines: pathLines + ) + } + + private struct Measuring { + let tick: Int + let progress: MeasureProgress + let drillingDown: Bool + let isCancelling: Bool + + var hints: KeyHints { drillingDown ? drillDownHints : firstScanHints } + var exitHint: String { drillingDown ? "Esc back q exit" : exitText } + + var statusSpans: [Span] { + let status = isCancelling ? StatusScreen.cancellingText + : progress.position == 0 ? "Measuring…" : "Measuring \(progress.position) of \(progress.count)" + return [Span(Spinner.frame(tick: tick) + " " + status, TextStyle(dim: true))] + } + + func currentRow(width: Int) -> String? { + guard !progress.name.isEmpty else { return nil } + let suffix = Child.suffix(isDirectory: progress.nameIsDirectory, isPackage: progress.nameIsPackage) + let spinner = Spinner.frame(tick: tick) + " " + return spinner + Term.truncate(progress.name + suffix, to: max(1, width - spinner.count), middle: true) + } + } + + static func renderMeasuringBrowser( + progress: MeasureProgress, tick: Int, drillingDown: Bool, isCancelling: Bool = false, width: Int, height: Int + ) -> Buffer { + let children = ranked(progress.measured) + let snapshot = FolderSnapshot( + path: progress.path, + children: children, + total: children.reduce(Int64(0)) { $0 + $1.size }, + inaccessibleCount: 0, + skippedCount: 0, + errors: [] + ) + return render( + level: BrowserLevel(snapshot: snapshot, selected: -1), + measuring: Measuring(tick: tick, progress: progress, drillingDown: drillingDown, isCancelling: isCancelling), + width: width, + height: height + ) + } + + static func renderBrowser(level: BrowserLevel, width: Int, height: Int) -> Buffer { + render(level: level, measuring: nil, width: width, height: height) + } + + private static func render(level: BrowserLevel, measuring: Measuring?, width: Int, height: Int) -> Buffer { + guard TooSmallScreen.fits(width: width, height: height) else { + return TooSmallScreen.render( + title: windowTitle, exitHint: measuring?.exitHint ?? exitText, width: width, height: height + ) + } + var buffer = Buffer(width: width, height: height) let snapshot = level.snapshot - let pathLines = wrap(focusedChild(level)?.path ?? snapshot.path, width: max(1, safeWidth - 9)) - let visibleRows = visibleRowCount(snapshot: snapshot, selected: level.selected, width: safeWidth, height: safeHeight) - let upperBound = min(snapshot.children.count, level.offset + visibleRows) - let rows = level.offset < upperBound - ? snapshot.children[level.offset.. 0 ? Term.red("Folder contents are inaccessible.") : Term.dim("No measurable items.")] - : rows) - lines.append(String(repeating: "─", count: safeWidth)) - lines.append(Term.dim("↑↓ move Enter/→ open O reveal")) - lines.append(Term.dim("←/⌫ back q/Esc exit")) - for (index, line) in pathLines.enumerated() { - lines.append((index == 0 ? Term.dim("Focused: ") : String(repeating: " ", count: 9)) + line) + let hints = measuring?.hints ?? browserHints + window.render(in: buffer.area, buffer: &buffer) + let layout = layout( + snapshot: snapshot, selected: level.selected, hints: hints, showsPath: measuring == nil, + width: width, height: height + ) + + buffer.write( + Term.truncate(snapshot.path, to: layout.header.width, middle: true), + x: layout.header.x, + y: layout.header.y, + style: TextStyle(bold: true) + ) + buffer.write( + measuring?.statusSpans ?? [Span(statusLine(snapshot, width: layout.header.width), TextStyle(dim: true))], + x: layout.header.x, + y: layout.header.y + 1, + maxWidth: layout.header.width + ) + window.renderDivider(atRow: layout.divider.y, in: buffer.area, buffer: &buffer) + let currentRow = measuring?.currentRow(width: layout.list.width) + let capacity = max(0, layout.list.height - (currentRow == nil ? 0 : 1)) + renderRows(level: level, isMeasuring: measuring != nil, currentRow: currentRow, capacity: capacity, in: layout.list, buffer: &buffer) + Scrollbar(total: snapshot.children.count, visible: capacity, offset: level.offset) + .render(column: width - 1, rows: layout.list.y..<(layout.list.y + layout.list.height), buffer: &buffer) + + for (index, line) in layout.pathLines.prefix(layout.path.height).enumerated() { + let label = index == 0 ? focusedLabel : String(repeating: " ", count: focusedLabel.count) + buffer.write( + [Span(label, TextStyle(dim: true)), Span(line)], + x: layout.path.x, + y: layout.path.y + index, + maxWidth: layout.path.width + ) } if let error = snapshot.errors.first { - lines.append(Term.red("! ") + Term.truncate(error, to: max(1, safeWidth - 2), middle: true)) + let area = layout.error + buffer.write( + [Span("! ", TextStyle(color: .red)), Span(Term.truncate(error, to: max(1, area.width - 2), middle: true))], + x: area.x, + y: area.y, + maxWidth: area.width + ) + } + hints.render(in: layout.footer, buffer: &buffer) + return buffer + } + + private static func renderRows( + level: BrowserLevel, isMeasuring: Bool, currentRow: String?, capacity: Int, in list: Rect, buffer: inout Buffer + ) { + let snapshot = level.snapshot + let upperBound = min(snapshot.children.count, level.offset + capacity) + if let currentRow { + let shown = max(0, upperBound - level.offset) + buffer.write(currentRow, x: list.x, y: list.y + shown, style: TextStyle(dim: true), maxWidth: list.width) + } + guard level.offset < upperBound else { + guard !isMeasuring else { return } + let inaccessible = snapshot.inaccessibleCount > 0 + buffer.write( + inaccessible ? "Folder contents are inaccessible." : "No measurable items.", + x: list.x, + y: list.y, + style: inaccessible ? TextStyle(color: .red) : TextStyle(dim: true), + maxWidth: list.width + ) + return + } + for (offset, child) in snapshot.children[level.offset.. Int { - let focusedPath = snapshot.children.indices.contains(selected) ? snapshot.children[selected].path : snapshot.path - let pathLines = wrap(focusedPath, width: max(1, max(1, width) - 9)).count - return max(1, height - 7 - pathLines - (snapshot.errors.isEmpty ? 0 : 1)) + layout(snapshot: snapshot, selected: selected, width: width, height: height).visibleRows } static func adjustedOffset(_ offset: Int, selected: Int, count: Int, visibleRows: Int) -> Int { @@ -366,16 +610,29 @@ struct Analyze: ParsableCommand { return level.snapshot.children[level.selected] } - private static func row(_ child: Child, focused: Bool, total: Int64, width: Int) -> String { + private static func renderRow(_ child: Child, focused: Bool, total: Int64, in rect: Rect, buffer: inout Buffer) { let fraction = total > 0 ? Double(child.size) / Double(total) : 0 - let size = Render.pad(ByteCount.human(child.size), 10) - let barWidth = min(20, max(6, (width - 32) / 3)) - let suffix = child.isDir && !child.isPackage ? "/" : (child.isPackage ? " [package]" : "") + let barWidth = min(maximumGaugeWidth, max(minimumGaugeWidth, (rect.width - gaugeReservedWidth) / 3)) let partial = child.isPartial ? " !" : "" - let nameWidth = max(1, width - 14 - barWidth - partial.count) - let name = Term.truncate(URL(fileURLWithPath: child.path).lastPathComponent + suffix, to: nameWidth, middle: true) - let line = "\(focused ? "›" : " ") \(size) \(Term.bar(fraction: fraction, width: barWidth)) \(name)\(partial)" - return focused ? Term.inverse(Term.pad(line, to: width)) : line + let nameColumn = rect.width - rowPrefixWidth - barWidth + let nameWidth = max(1, nameColumn - partial.count) + let name = Term.truncate(URL(fileURLWithPath: child.path).lastPathComponent + child.suffix, to: nameWidth, middle: true) + let rowStyle = TextStyle(inverse: focused) + let sizeStyle = SizeLevel.style(bytes: child.size, focused: focused) + let gauge = Gauge(fraction: fraction).spans(width: barWidth, filled: sizeStyle).map { + Span($0.text, TextStyle(color: $0.style.color, dim: $0.style.dim, inverse: focused)) + } + buffer.write( + [FocusPointer.span(focused: focused), + Span(" ", rowStyle), + Span(Render.sizeCol(child.size), sizeStyle), + Span(" ", rowStyle)] + + gauge + + [Span(" " + Term.pad(name + partial, to: nameColumn), rowStyle)], + x: rect.x, + y: rect.y, + maxWidth: rect.width + ) } private static func statusLine(_ snapshot: FolderSnapshot, width: Int) -> String { @@ -385,27 +642,6 @@ struct Analyze: ParsableCommand { return Term.truncate(parts.joined(separator: " · "), to: width) } - static func wrap(_ string: String, width: Int) -> [String] { - let safe = Term.sanitize(string) - guard !safe.isEmpty else { return [""] } - var lines: [String] = [] - var current = "" - var used = 0 - for character in safe { - let value = String(character) - let characterWidth = max(0, Term.displayWidth(value)) - if used + characterWidth > width && !current.isEmpty { - lines.append(current) - current = "" - used = 0 - } - current.append(character) - used += characterWidth - } - if !current.isEmpty { lines.append(current) } - return lines.isEmpty ? [""] : lines - } - private static func revealInFinder(_ path: String) { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/open") diff --git a/cli/Sources/puremac/Commands/Clean.swift b/cli/Sources/puremac/Commands/Clean.swift index b791f74..a6166f6 100644 --- a/cli/Sources/puremac/Commands/Clean.swift +++ b/cli/Sources/puremac/Commands/Clean.swift @@ -1,5 +1,6 @@ import ArgumentParser import Foundation +import TerminalUI struct Clean: ParsableCommand { static let configuration = CommandConfiguration( @@ -12,7 +13,7 @@ struct Clean: ParsableCommand { puremac clean ai AI-tool caches & logs only puremac clean trash Empty user + mounted-volume Trash - Interactive review starts with every item unselected. Finishing the selector only opens an exact deletion review; a separate confirmation defaults to No. --plain uses a numbered selector with ranges, all, and none. --force removes the scanner's standard preselection without either prompt. --dry-run and --json never delete or open a selector. + Interactive review starts with every item unselected. \(Render.reviewHelp) --force removes the scanner's standard preselection without either prompt. --dry-run and --json never delete or open a selector. """ ) @@ -52,12 +53,25 @@ struct Clean: ParsableCommand { let ids = category.map { [$0] } ?? Clean.validIDs let ignore = try IgnoreStore(fileURL: AppPaths.ignoreFile) + let scanning = "Scanning " + ids.map(Clean.title).joined(separator: ", ") + let progress = ScanProgress.forMode(mode) + if mode == .fullscreen { + let labels = CleanupFlow.Labels( + scanning: scanning, + empty: Clean.emptyMessage, + reviewTitle: "Choose items to clean", + noun: "item" + ) + let result = try CleanupFlow.run(labels: labels, ignore: ignore) { status, cancel in + Clean.scan(ids: ids, ignore: ignore, progress: progress, status: status, cancel: cancel) + } + try CleanupFlow.finish(result, labels: labels) + return + } if !json { - Term.err(Term.dim("Scanning " + ids.map(Clean.title).joined(separator: ", ") + " …")) + Term.err(Term.dim(scanning + " …")) } - let cats = ids.map { id in - CategoryScanner.scan(categoryID: id, title: Clean.title(id), ignore: ignore) - }.filter { !$0.groups.isEmpty } + let cats = Clean.scan(ids: ids, ignore: ignore, progress: progress) if json { print(try JSONReport.string(cats)) @@ -65,7 +79,7 @@ struct Clean: ParsableCommand { } if cats.isEmpty { - print(Term.green("No cleanup candidates found in readable locations.")) + print(Term.green(Clean.emptyMessage)) return } @@ -84,19 +98,13 @@ struct Clean: ParsableCommand { reviewed = cats Render.scanResults(cats) Render.selectionLine(cats) - case .fullscreen: - guard let selection = try InteractiveReview.select(cats, title: "Choose items to clean") else { - print(Term.dim("Cancelled. Nothing removed.")) - return - } - reviewed = selection case .plain: guard let selection = try PlainSelectionReview.select(cats, title: "Choose items to clean") else { print(Term.dim("Cancelled. Nothing removed.")) return } reviewed = selection - case .reportOnly, .unavailable: + case .fullscreen, .reportOnly, .unavailable: return } @@ -120,6 +128,30 @@ struct Clean: ParsableCommand { if out.hadFailures { throw ExitCode.failure } } + static let emptyMessage = "No cleanup candidates found in readable locations." + + typealias CategoryScanFunction = (String, String, IgnoreStore, ScanProgress, () -> Bool) -> CategoryScan + + static func scan( + ids: [String], + ignore: IgnoreStore, + progress: ScanProgress, + status: WorkProgress? = nil, + cancel: CancelFlag? = nil, + scanCategory: CategoryScanFunction = CategoryScanner.scan + ) -> [CategoryScan] { + var scans: [CategoryScan] = [] + for (index, id) in ids.enumerated() { + if cancel?.isCancelled == true { break } + let title = Clean.title(id) + status?.update { $0.beginCategory(title: title, position: index + 1, count: ids.count) } + let scan = scanCategory(id, title, ignore, progress) { cancel?.isCancelled == true } + status?.update { $0.addFound(in: scan) } + scans.append(scan) + } + return scans.filter { !$0.groups.isEmpty } + } + static func title(_ id: String) -> String { Catalog.categoryTitles.first { $0.id == id }?.title ?? id.capitalized } diff --git a/cli/Sources/puremac/Commands/Config.swift b/cli/Sources/puremac/Commands/Config.swift index a510e42..10eb8e2 100644 --- a/cli/Sources/puremac/Commands/Config.swift +++ b/cli/Sources/puremac/Commands/Config.swift @@ -1,5 +1,6 @@ import ArgumentParser import Foundation +import TerminalUI struct Config: ParsableCommand { static let configuration = CommandConfiguration( @@ -49,18 +50,28 @@ struct Config: ParsableCommand { return } - print(Term.bold("PureMac CLI settings")) - print(Term.dim("Values used when a command-line option is omitted")) - print("") - for key in ConfigStore.knownKeys { - let isSaved = store.values[key] != nil - let value = store.values[key] ?? ConfigStore.defaultValues[key] ?? "" - let source = isSaved ? "saved" : "default" - print(" \(key.padding(toLength: 26, withPad: " ", startingAt: 0)) \(value) days " + Term.dim(source)) + let folders = try ProjectFoldersStore(fileURL: AppPaths.projectsFile).roots + for line in Self.sheet(store: store, projectFolders: folders).plainLines() { print(line) } + } + + static let sheetTitle = "PureMac CLI settings" + + static func sheet(store: ConfigStore, projectFolders: [String]) -> InfoSheet { + let days = store.olderThanDays + let note = days == ConfigStore.defaultDays ? Span("default", TextStyle(dim: true)) : Span("saved", TextStyle(color: .cyan)) + let keys = [InfoRow.metric(label: ConfigStore.daysKey, value: [Span("\(days) days")], note: note)] + let files = [("Settings", AppPaths.configFile), ("Protected", AppPaths.ignoreFile), ("Projects", AppPaths.projectsFile)].map { label, url in + InfoRow.metric(label: label, value: [Span(Render.shorten(url.path))], note: nil) } - print("") - print(Term.bold("Files")) - print(" \("Settings".padding(toLength: 12, withPad: " ", startingAt: 0)) \(Render.shorten(AppPaths.configFile.path))") - print(" \("Protected".padding(toLength: 12, withPad: " ", startingAt: 0)) \(Render.shorten(AppPaths.ignoreFile.path))") + return InfoSheet( + title: sheetTitle, + subtitle: "Values used when a command-line option is omitted", + sections: [ + InfoSection(title: nil, labelWidth: 26, rows: keys), + InfoSection(title: "Files", labelWidth: 12, rows: files), + InfoSection(title: "Project folders", rows: projectFolders.map { .path(Render.shorten($0)) }), + ], + footnote: nil + ) } } diff --git a/cli/Sources/puremac/Commands/Ignore.swift b/cli/Sources/puremac/Commands/Ignore.swift index 8276f42..c161135 100644 --- a/cli/Sources/puremac/Commands/Ignore.swift +++ b/cli/Sources/puremac/Commands/Ignore.swift @@ -1,5 +1,6 @@ import ArgumentParser import Foundation +import TerminalUI struct Ignore: ParsableCommand { static let configuration = CommandConfiguration( @@ -32,14 +33,23 @@ struct Ignore: ParsableCommand { struct List: ParsableCommand { static let configuration = CommandConfiguration(abstract: "Show protected paths.") + func run() throws { let store = try IgnoreStore(fileURL: AppPaths.ignoreFile) - if store.roots.isEmpty { - print(Term.dim("No protected paths. Add one with `puremac ignore add `.")) + guard !store.roots.isEmpty else { + print(Term.styled(Self.emptyHint())) return } print(Term.bold("Protected paths:")) for root in store.roots.sorted() { print(" " + Render.shorten(root)) } } + + static func emptyHint() -> [Span] { + [ + Span("No protected paths. Add one with `", TextStyle(dim: true)), + Span("puremac ignore add ", TextStyle(color: .cyan)), + Span("`.", TextStyle(dim: true)), + ] + } } } diff --git a/cli/Sources/puremac/Commands/Optimize.swift b/cli/Sources/puremac/Commands/Optimize.swift index eeda248..bb2d676 100644 --- a/cli/Sources/puremac/Commands/Optimize.swift +++ b/cli/Sources/puremac/Commands/Optimize.swift @@ -1,5 +1,6 @@ import ArgumentParser import Foundation +import TerminalUI struct Optimize: ParsableCommand { static let configuration = CommandConfiguration( @@ -29,29 +30,129 @@ struct Optimize: ParsableCommand { func run() throws { switch task ?? "status" { case "ram": - try showMemory(includeHeading: true) - print("") - print(Term.dim("No memory was purged. macOS reclaims inactive memory when applications need it.")) + Self.printPlain(Self.memorySheet(try SystemInfo.memoryStatus(), pressure: SystemInfo.memoryPressure()), withHeader: false) case "purgeable": - try showStorage(includeHeading: true) - print("") - print(Term.dim("No storage was changed. APFS managed capacity is an estimate controlled by macOS.")) + Self.printPlain(Self.storageSheet(try SystemInfo.volumeStatus("/")), withHeader: false) case "snapshots": try showSnapshots() default: - print(Term.bold("Mac status")) - print(Term.dim("Read-only measurements from macOS")) - print("") - try showMemory(includeHeading: true) - print("") - try showStorage(includeHeading: true) - print("") - print(" " + Term.dim("Uptime") + " " + Self.formatUptime(ProcessInfo.processInfo.systemUptime)) - print("") - print(Term.dim("No maintenance ran. Use `puremac clean --dry-run` to review removable files.")) + Self.printPlain(try Self.currentStatusSheet()) } } + static let statusTitle = "Mac status" + static let statusFootnote = "No maintenance ran. Use `puremac clean --dry-run` to review removable files." + + static func currentStatusSheet() throws -> InfoSheet { + let memory = try SystemInfo.memoryStatus() + let volume = try SystemInfo.volumeStatus("/") + return statusSheet( + memory: memory, + pressure: SystemInfo.memoryPressure(), + volume: volume, + machine: SystemInfo.machine() + ) + } + + static func statusSheet( + memory: MemoryStatus, + pressure: MemoryPressure?, + volume: VolumeStatus, + machine: MachineInfo + ) -> InfoSheet { + InfoSheet( + title: statusTitle, + subtitle: "Read-only measurements from macOS", + sections: [ + thisMacSection(machine), + memorySection(memory, pressure: pressure), + storageSection(volume), + ], + footnote: statusFootnote + ) + } + + static func memorySheet(_ status: MemoryStatus, pressure: MemoryPressure?) -> InfoSheet { + InfoSheet( + title: memoryTitle, + sections: [memorySection(status, pressure: pressure)], + footnote: "No memory was purged. macOS reclaims inactive memory when applications need it." + ) + } + + static func storageSheet(_ status: VolumeStatus) -> InfoSheet { + InfoSheet( + title: storageTitle, + sections: [storageSection(status)], + footnote: "No storage was changed. APFS managed capacity is an estimate controlled by macOS." + ) + } + + static let memoryTitle = "Memory" + static let storageTitle = "Storage" + + static func thisMacSection(_ machine: MachineInfo) -> InfoSection { + let facts: [(String, String?)] = [ + ("Chip", machine.chip), + ("Cores", machine.cores), + ("Memory", machine.memory.map(ByteCount.memory)), + ("macOS", machine.macOS), + ("Model", machine.model), + ("Uptime", formatUptime(machine.uptime)), + ] + return InfoSection(title: "This Mac", rows: facts.compactMap { label, value in value.map { metric(label, $0) } }) + } + + static func memorySection(_ status: MemoryStatus, pressure: MemoryPressure?) -> InfoSection { + let used = ByteCount.memory(status.total - status.free) + var rows: [InfoRow] = [ + .gauge( + fraction: status.usedFraction, + color: pressure?.color, + text: "\(used) in use of \(ByteCount.memory(status.total))" + ) + ] + if let pressure { + rows.append(.metric(label: "Pressure", value: [Span(pressure.label, TextStyle(color: pressure.color))], note: nil)) + } + rows += [ + metric("Installed", ByteCount.memory(status.total)), + metric("Free now", ByteCount.memory(status.free)), + metric("Active", ByteCount.memory(status.active)), + metric("Inactive", ByteCount.memory(status.inactive)), + metric("Compressed", ByteCount.memory(status.compressed)), + metric("Wired", ByteCount.memory(status.wired)), + ] + return InfoSection(title: memoryTitle, rows: rows) + } + + static func storageSection(_ status: VolumeStatus) -> InfoSection { + let color = status.freeColor + var rows: [InfoRow] = [ + .gauge( + fraction: status.usedFraction, + color: color, + text: "\(ByteCount.human(status.availableNow)) free of \(ByteCount.human(status.total))" + ), + .metric(label: "Free now", value: [Span(ByteCount.human(status.availableNow), TextStyle(color: color))], note: nil), + metric("Total", ByteCount.human(status.total)), + metric("Managed by macOS", ByteCount.human(status.systemManagedCapacity)), + ] + if status.systemManagedCapacity > 0 { + let note = "Managed capacity may include purgeable files and is not guaranteed free space." + rows.append(.message([Span(note, TextStyle(dim: true))])) + } + return InfoSection(title: storageTitle, rows: rows) + } + + private static func metric(_ label: String, _ value: String) -> InfoRow { + .metric(label: label, value: [Span(value)], note: nil) + } + + private static func printPlain(_ sheet: InfoSheet, withHeader: Bool = true) { + for line in sheet.plainLines(withHeader: withHeader) { print(line) } + } + static func parseLocalSnapshots(_ output: String) -> [String] { let snapshots = output.split(whereSeparator: \Character.isNewline) .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } @@ -88,28 +189,6 @@ struct Optimize: ParsableCommand { return "\(minutes)m" } - private func showMemory(includeHeading: Bool) throws { - let status = try SystemInfo.memoryStatus() - if includeHeading { print(Term.bold("Memory")) } - printMetric("Installed", ByteCount.human(status.total)) - printMetric("Free now", ByteCount.human(status.free)) - printMetric("Active", ByteCount.human(status.active)) - printMetric("Inactive", ByteCount.human(status.inactive)) - printMetric("Compressed", ByteCount.human(status.compressed)) - printMetric("Wired", ByteCount.human(status.wired)) - } - - private func showStorage(includeHeading: Bool) throws { - let status = try SystemInfo.volumeStatus("/") - if includeHeading { print(Term.bold("Storage")) } - printMetric("Free now", ByteCount.human(status.availableNow)) - printMetric("Total", ByteCount.human(status.total)) - printMetric("Managed by macOS", ByteCount.human(status.systemManagedCapacity)) - if status.systemManagedCapacity > 0 { - print(Term.dim(" Managed capacity may include purgeable files and is not guaranteed free space.")) - } - } - private func showSnapshots() throws { let result = Shell.run("/usr/bin/tmutil", ["listlocalsnapshots", "/"]) guard result.status == 0 else { @@ -128,8 +207,4 @@ struct Optimize: ParsableCommand { print("") print(Term.dim("Read-only list. Snapshot storage is managed by Time Machine and macOS.")) } - - private func printMetric(_ label: String, _ value: String) { - print(" \(label.padding(toLength: 17, withPad: " ", startingAt: 0)) \(value)") - } } diff --git a/cli/Sources/puremac/Commands/Purge.swift b/cli/Sources/puremac/Commands/Purge.swift index 8c2cba8..821367a 100644 --- a/cli/Sources/puremac/Commands/Purge.swift +++ b/cli/Sources/puremac/Commands/Purge.swift @@ -1,20 +1,21 @@ import ArgumentParser import Foundation +import TerminalUI struct Purge: ParsableCommand { static let configuration = CommandConfiguration( abstract: "Find removable build artifacts inside project folders (node_modules, target, .venv, …).", discussion: """ - With no path, scans ~/Projects, ~/Code, ~/dev, ~/GitHub, ~/Workspace. + With no path, scans the project folders from Preferences, by default ~/Projects, ~/Code, ~/dev, ~/GitHub, ~/Workspace. Interactive review starts empty so recent and older artifacts are both selectable. - puremac purge Scan the default project folders + puremac purge Scan the configured project folders puremac purge ~/Work/app Scan a specific folder - Finishing the selector only opens an exact deletion review; a separate confirmation defaults to No. --plain uses a numbered selector with ranges, all, and none. --force removes only artifacts older than the configured threshold without either prompt. --dry-run and --json never delete or open a selector. + \(Render.reviewHelp) --force removes only artifacts older than the configured threshold without either prompt. --dry-run and --json never delete or open a selector. """ ) - @Argument(help: "Folder(s) to scan. Omit to scan the default project locations.") + @Argument(help: "Folder(s) to scan. Omit to scan the configured project folders.") var paths: [String] = [] @Option(name: .customLong("older-than"), help: "Age threshold used by --force and read-only reports (default 7, or config).") @@ -55,17 +56,38 @@ struct Purge: ParsableCommand { let ignore = try IgnoreStore(fileURL: AppPaths.ignoreFile) let days = try resolvedDays() - let roots = paths.isEmpty - ? Purger.defaultRoots - : paths.map { Safety.absolutePath($0) } + let roots = try resolvedRoots() + guard !roots.isEmpty || json else { + print(Term.dim(Purge.noFoldersMessage)) + return + } - if !json { Term.err(Term.dim("Scanning " + roots.map { Render.shorten($0) }.joined(separator: ", ") + " …")) } + let scanning = "Scanning " + roots.map { Render.shorten($0) }.joined(separator: ", ") + if mode == .fullscreen { + let labels = CleanupFlow.Labels( + scanning: scanning, + empty: Purge.emptyMessage, + reviewTitle: "Choose project artifacts", + noun: "artifact" + ) + let result = try CleanupFlow.run(labels: labels, ignore: ignore) { status, cancel in + let cat = Purger.scan( + roots: roots, olderThanDays: days, ignore: ignore, + progress: { event in status.update { $0.apply(event) } }, + isCancelled: { cancel.isCancelled } + ) + return cat.groups.isEmpty ? [] : [cat] + } + try CleanupFlow.finish(result, labels: labels) + return + } + if !json { Term.err(Term.dim(scanning + " …")) } let cat = Purger.scan(roots: roots, olderThanDays: days, ignore: ignore) if json { print(try JSONReport.string([cat])); return } if cat.groups.isEmpty { - print(Term.green("No project artifacts found in readable locations.")) + print(Term.green(Purge.emptyMessage)) return } @@ -82,19 +104,13 @@ struct Purge: ParsableCommand { case .forced: reviewed = [cat] renderScan(cat, days: days) - case .fullscreen: - guard let selection = try InteractiveReview.select([cat], title: "Choose project artifacts") else { - print(Term.dim("Cancelled. Nothing removed.")) - return - } - reviewed = selection case .plain: guard let selection = try PlainSelectionReview.select([cat], title: "Choose project artifacts") else { print(Term.dim("Cancelled. Nothing removed.")) return } reviewed = selection - case .reportOnly, .unavailable: + case .fullscreen, .reportOnly, .unavailable: return } @@ -114,27 +130,33 @@ struct Purge: ParsableCommand { if out.hadFailures { throw ExitCode.failure } } + static let noFoldersMessage = "No project folders configured. Add one in Preferences." + static let emptyMessage = "No project artifacts found in readable locations." + private func resolvedDays() throws -> Int { if olderThanDays >= 0 { return olderThanDays } - let config = try ConfigStore(fileURL: AppPaths.configFile) - if let v = config.values["purge.older_than_days"], let n = Int(v), (0...3650).contains(n) { return n } - return 7 + return try ConfigStore(fileURL: AppPaths.configFile).olderThanDays + } + + func resolvedRoots() throws -> [String] { + guard paths.isEmpty else { return paths.map { Safety.absolutePath($0) } } + return try ProjectFoldersStore(fileURL: AppPaths.projectsFile).roots } - private func renderScan(_ cat: CategoryScan, days: Int) { + func renderScan(_ cat: CategoryScan, days: Int, colors: Bool = Term.colorEnabled) { let projectCount = cat.groups.count print("") - print(Term.bold("Found \(ByteCount.human(cat.allBytes)) across \(projectCount) project\(projectCount == 1 ? "" : "s")")) + print(Render.paint("Found \(ByteCount.human(cat.allBytes)) across \(projectCount) project\(projectCount == 1 ? "" : "s")", TextStyle(bold: true), colors)) for group in cat.groups { print("") - print(" \(Term.cyan(group.tool)) \(Term.dim(ByteCount.human(group.allBytes)))") + print(" \(Render.paint(group.tool, TextStyle(color: .cyan), colors)) \(Render.paint(ByteCount.human(group.allBytes), TextStyle(dim: true), colors))") for item in group.items { - let mark = item.selected ? Term.green("✓") : Term.dim("·") - let recent = item.selected ? "" : Term.dim(" recent") - print(" \(mark) \(Render.sizeCol(item.sizeBytes)) \(Render.shorten(item.path))\(recent)") + let mark = item.selected ? Render.paint("✓", TextStyle(color: .green), colors) : Render.paint("·", TextStyle(dim: true), colors) + let recent = item.selected ? "" : Render.paint(" recent", TextStyle(dim: true), colors) + print(" \(mark) \(Render.coloredSizeCol(item.sizeBytes, colors: colors)) \(Render.shorten(item.path))\(recent)") } } print("") - print(Term.dim("Artifacts newer than \(days) days are shown but left unchecked.")) + print(Render.paint("Artifacts newer than \(days) days are shown but left unchecked.", TextStyle(dim: true), colors)) } } diff --git a/cli/Sources/puremac/Core/ButtonFocus.swift b/cli/Sources/puremac/Core/ButtonFocus.swift new file mode 100644 index 0000000..7da8e59 --- /dev/null +++ b/cli/Sources/puremac/Core/ButtonFocus.swift @@ -0,0 +1,24 @@ +import TerminalUI + +struct ButtonFocus { + let count: Int + private(set) var index: Int + + mutating func move(_ delta: Int) { + index = min(count - 1, max(0, index + delta)) + } + + mutating func cycle() { + index = (index + 1) % count + } + + mutating func navigate(_ key: TerminalKey) -> Bool { + switch key { + case .left: move(-1) + case .right: move(1) + case .tab: cycle() + default: return false + } + return true + } +} diff --git a/cli/Sources/puremac/Core/ByteCount.swift b/cli/Sources/puremac/Core/ByteCount.swift index 21e1174..6c52441 100644 --- a/cli/Sources/puremac/Core/ByteCount.swift +++ b/cli/Sources/puremac/Core/ByteCount.swift @@ -1,13 +1,17 @@ import Foundation enum ByteCount { - static func human(_ bytes: Int64) -> String { + static func human(_ bytes: Int64) -> String { format(bytes, base: 1000) } + + static func memory(_ bytes: Int64) -> String { format(bytes, base: 1024) } + + private static func format(_ bytes: Int64, base: Double) -> String { if bytes <= 0 { return "Zero KB" } let units = ["bytes", "KB", "MB", "GB", "TB", "PB"] var value = Double(bytes) var idx = 0 - while value >= 1000 && idx < units.count - 1 { - value /= 1000 + while value >= base && idx < units.count - 1 { + value /= base idx += 1 } if idx == 0 { return "\(Int(value)) bytes" } diff --git a/cli/Sources/puremac/Core/CategoryScanner.swift b/cli/Sources/puremac/Core/CategoryScanner.swift index 1a3cf01..485b559 100644 --- a/cli/Sources/puremac/Core/CategoryScanner.swift +++ b/cli/Sources/puremac/Core/CategoryScanner.swift @@ -1,17 +1,37 @@ import Foundation +enum ScanProgress: Equatable { + case stderr, silent + + static func forMode(_ mode: CleanupCommandMode) -> ScanProgress { + mode == .fullscreen ? .silent : .stderr + } + + func write(_ text: String) { + guard self == .stderr else { return } + FileHandle.standardError.write(Data(text.utf8)) + } +} + enum CategoryScanner { static let minSize: Int64 = 1024 - static func scan(categoryID id: String, title: String, ignore: IgnoreStore) -> CategoryScan { - FileHandle.standardError.write(Data(" scanning \(title)…\r".utf8)) - let groups = id == "trash" ? scanTrash(ignore: ignore) : scanCatalog(id: id, ignore: ignore) - FileHandle.standardError.write(Data(" \r".utf8)) + static func scan( + categoryID id: String, + title: String, + ignore: IgnoreStore, + progress: ScanProgress = .stderr, + isCancelled: () -> Bool = { false } + ) -> CategoryScan { + progress.write(" scanning \(title)…\r") + let groups = id == "trash" + ? scanTrash(roots: trashRoots(), ignore: ignore, isCancelled: isCancelled) + : scanCatalog(targets: Catalog.targets(for: id), ignore: ignore, isCancelled: isCancelled) + progress.write(" \r") return CategoryScan(id: id, title: title, groups: groups) } - private static func scanCatalog(id: String, ignore: IgnoreStore) -> [ToolGroup] { - let targets = Catalog.targets(for: id) + static func scanCatalog(targets: [Target], ignore: IgnoreStore, isCancelled: () -> Bool) -> [ToolGroup] { var candidates: [(target: Int, path: String)] = [] for (ti, target) in targets.enumerated() { let paths = target.contents @@ -26,7 +46,8 @@ enum CategoryScanner { } } candidates = uniqueCandidates(candidates) - let measurements = concurrentMeasurements(candidates.map { $0.path }) + if isCancelled() { return [] } + let measurements = concurrentMeasurements(candidates.map { $0.path }, isCancelled: isCancelled) var itemsByTarget = [Int: [ScanItem]](minimumCapacity: targets.count) for (i, c) in candidates.enumerated() @@ -60,7 +81,7 @@ enum CategoryScanner { concurrentMeasurements(paths).map(\.bytes) } - static func concurrentMeasurements(_ paths: [String]) -> [DirSizer.Measurement] { + static func concurrentMeasurements(_ paths: [String], isCancelled: () -> Bool = { false }) -> [DirSizer.Measurement] { guard !paths.isEmpty else { return [] } var out = [DirSizer.Measurement]( repeating: DirSizer.Measurement(bytes: 0, status: .unavailable, skippedEntries: 1), @@ -68,7 +89,7 @@ enum CategoryScanner { ) out.withUnsafeMutableBufferPointer { buf in DispatchQueue.concurrentPerform(iterations: paths.count) { i in - buf[i] = DirSizer.measure(of: paths[i]) + buf[i] = DirSizer.measure(of: paths[i], isCancelled: isCancelled) } } return out @@ -98,20 +119,24 @@ enum CategoryScanner { return accepted.map { ($0.target, $0.path) } } - private static func scanTrash(ignore: IgnoreStore) -> [ToolGroup] { - let fm = FileManager.default + private static func trashRoots() -> [String] { var roots = ["\(Safety.home)/.Trash"] let uid = getuid() - if let vols = try? fm.contentsOfDirectory(atPath: "/Volumes") { + if let vols = try? FileManager.default.contentsOfDirectory(atPath: "/Volumes") { for vol in vols { roots.append("/Volumes/\(vol)/.Trashes/\(uid)") } } + return roots + } + static func scanTrash(roots: [String], ignore: IgnoreStore, isCancelled: () -> Bool) -> [ToolGroup] { + let fm = FileManager.default var groups: [ToolGroup] = [] for root in roots where !Safety.hasSymlinkComponent(root) && !Safety.isCloudOrDataless(root) { + if isCancelled() { break } guard let children = try? fm.contentsOfDirectory(atPath: root), !children.isEmpty else { continue } let paths = children.map { "\(root)/\($0)" }.filter { passesPreFilter($0, ignore: ignore) } let unique = uniqueCandidates(paths.map { (target: 0, path: $0) }) - let measurements = concurrentMeasurements(unique.map(\.path)) + let measurements = concurrentMeasurements(unique.map(\.path), isCancelled: isCancelled) let items = zip(unique.map(\.path), measurements).compactMap { (path, measurement) -> ScanItem? in measurement.status == .complete && measurement.bytes >= minSize ? ScanItem(path: path, sizeBytes: measurement.bytes, modified: DirSizer.modified(of: path)) diff --git a/cli/Sources/puremac/Core/Cleaner.swift b/cli/Sources/puremac/Core/Cleaner.swift index 6c12604..70fd04a 100644 --- a/cli/Sources/puremac/Core/Cleaner.swift +++ b/cli/Sources/puremac/Core/Cleaner.swift @@ -9,10 +9,16 @@ struct CleanOutcome { } enum Cleaner { - static func remove(_ items: [ScanItem], ignore: IgnoreStore, dryRun: Bool) -> CleanOutcome { + static func remove( + _ items: [ScanItem], + ignore: IgnoreStore, + dryRun: Bool, + beforeItem: ((_ index: Int, _ count: Int, _ item: ScanItem) -> Void)? = nil + ) -> CleanOutcome { var out = CleanOutcome() let fm = FileManager.default - for item in items { + for (index, item) in items.enumerated() { + beforeItem?(index, items.count, item) if Safety.hasSymlinkComponent(item.path) { out.skipped.append((item.path, "symlink")) continue diff --git a/cli/Sources/puremac/Core/CleanupFlow.swift b/cli/Sources/puremac/Core/CleanupFlow.swift new file mode 100644 index 0000000..c0a610b --- /dev/null +++ b/cli/Sources/puremac/Core/CleanupFlow.swift @@ -0,0 +1,224 @@ +import ArgumentParser +import TerminalUI + +enum CleanupFlowResult { + case nothingFound + case cancelled + case finished(CleanOutcome) +} + +struct RemovalProgress { + let position: Int + let count: Int + let path: String + + init(index: Int, count: Int, path: String) { + position = index + 1 + self.count = count + self.path = path + } +} + +enum CleanupFlow { + private static let removingMaxContentWidth = 60 + + struct Labels { + let scanning: String + let empty: String + let reviewTitle: String + let noun: String + + var initialStatus: ScanStatus { ScanStatus(headline: scanning) } + } + + typealias Scan = (WorkProgress, CancelFlag) -> [CategoryScan] + + static func run( + labels: Labels, + ignore: IgnoreStore, + scan: @escaping Scan + ) throws -> CleanupFlowResult { + let session = try TerminalSession() + defer { session.close() } + + var scanCancelled = false + let categories = session.runWithSpinner( + progress: labels.initialStatus, + render: { tick, status in + scanningScreen( + status: status, tick: tick, isCancelling: scanCancelled, width: session.width, height: session.height + ) + }, + onKey: { key, cancel in + if cancelsScan(key) { + scanCancelled = true + cancel.cancel() + } + }, + work: scan + ) + if scanCancelled { return .cancelled } + guard !categories.isEmpty else { + showInfo(on: session) { emptyScreen(labels: labels, width: session.width, height: session.height) } + return .nothingFound + } + + guard let review = InteractiveReview.select( + categories, session: session, title: labels.reviewTitle, noun: labels.noun + ) else { return .cancelled } + + let items = review.selectedResult().flatMap { $0.selectedItems } + let outcome = session.runWithSpinner( + progress: RemovalProgress(index: 0, count: items.count, path: items.first?.path ?? ""), + render: { tick, progress in + removingScreen( + review: review, labels: labels, progress: progress, tick: tick, + width: session.width, height: session.height + ) + }, + work: { progress, _ in + Cleaner.remove(items, ignore: ignore, dryRun: false) { index, count, item in + progress.set(RemovalProgress(index: index, count: count, path: item.path)) + } + } + ) + showInfo(on: session) { + resultScreen(outcome: outcome, review: review, labels: labels, width: session.width, height: session.height) + } + return .finished(outcome) + } + + static func finish(_ result: CleanupFlowResult, labels: Labels) throws { + switch result { + case .nothingFound: + print(Term.green(labels.empty)) + case .cancelled: + print(Term.dim("Cancelled. Nothing removed.")) + case .finished(let outcome): + Render.cleanupSummary(outcome, dryRun: false) + if outcome.hadFailures { throw ExitCode.failure } + } + } + + static func closesInfoDialog(_ key: TerminalKey) -> Bool { + key == .enter || isEscapeOrQ(key) + } + + static func showInfo(on session: TerminalSession, _ screen: () -> Buffer) { + repeat { + session.draw(screen()) + } while !closesInfoDialog(session.readKey()) + } + + static func cancelsScan(_ key: TerminalKey) -> Bool { + isEscapeOrQ(key) + } + + private static func isEscapeOrQ(_ key: TerminalKey) -> Bool { + switch key { + case .escape: return true + case .character(let character): return character.lowercased() == "q" + default: return false + } + } + + static func scanningScreen(status: ScanStatus, tick: Int, isCancelling: Bool = false, width: Int, height: Int) -> Buffer { + StatusScreen.render( + title: "PureMac", + rows: [ + StatusRow(Spinner.frame(tick: tick) + " " + (isCancelling ? StatusScreen.cancellingText : status.headline), truncatesInMiddle: true), + StatusRow(status.folder.map(Render.shorten) ?? "", style: TextStyle(dim: true), truncatesInMiddle: true), + StatusRow("Found \(Render.counted(status.foundCount, "item")) · \(ByteCount.human(status.foundBytes))") + ], + hint: StatusScreen.scanHint, + width: width, + height: height + ) + } + + static func emptyScreen(labels: Labels, width: Int, height: Int) -> Buffer { + infoScreen( + title: "Nothing to clean", + message: labels.empty, + on: scanningScreen(status: labels.initialStatus, tick: 0, width: width, height: height) + ) + } + + static func infoScreen(title: String, message: String, on background: Buffer) -> Buffer { + StatusScreen.overlay( + Dialog(title: title, message: [Span(message)], buttons: [DialogButton("OK")], focusedButton: 0), + on: background, + tooSmallExitHint: closeHint + ) + } + + static func removingScreen( + review: InteractiveReviewModel, labels: Labels, progress: RemovalProgress, tick: Int, width: Int, height: Int + ) -> Buffer { + let area = Rect(x: 0, y: 0, width: width, height: height) + return StatusScreen.overlay( + removingDialog(noun: labels.noun, progress: progress, tick: tick, area: area), + on: selectionScreen(review, labels: labels, width: width, height: height), + tooSmallExitHint: "Removing…" + ) + } + + static func resultScreen(outcome: CleanOutcome, review: InteractiveReviewModel, labels: Labels, width: Int, height: Int) -> Buffer { + StatusScreen.overlay( + resultDialog(outcome, area: Rect(x: 0, y: 0, width: width, height: height)), + on: selectionScreen(review, labels: labels, width: width, height: height), + tooSmallExitHint: closeHint + ) + } + + static func removingDialog(noun: String, progress: RemovalProgress, tick: Int, area: Rect) -> Dialog { + let rowWidth = min(removingMaxContentWidth, Dialog.maxContentWidth(in: area)) + let current = Term.truncate( + Spinner.frame(tick: tick) + " " + Render.shorten(progress.path), to: rowWidth, middle: true + ) + return Dialog( + title: "Removing \(Render.counted(progress.count, noun))…", + titleStyle: StatusScreen.warningTitle, + borderStyle: StatusScreen.warningBorder, + message: [ + Span(Term.pad(current, to: rowWidth)), + Span("\(progress.position) of \(progress.count)", TextStyle(dim: true)) + ], + messageAlignment: .leading + ) + } + + private static let closeHint = "Enter close" + + private static func resultDialog(_ outcome: CleanOutcome, area: Rect) -> Dialog { + var message = [Span(ByteCount.human(outcome.freedBytes) + Render.removedDetail(outcome))] + if !outcome.skipped.isEmpty { + message.append(Span(Render.skippedLabel(outcome) + Render.skippedNote, TextStyle(color: .yellow))) + } + if !outcome.failed.isEmpty { + let red = TextStyle(color: .red) + message.append(Span(Render.failedLabel(outcome), red)) + let rowWidth = Dialog.maxContentWidth(in: area) + message += outcome.failed.prefix(3).map { + Span(Term.truncate("! " + Render.shorten($0.path), to: rowWidth, middle: true), red) + } + } + return Dialog( + title: Render.cleanupCompleteTitle, + titleStyle: TextStyle(color: .green, bold: true), + message: message, + buttons: [DialogButton("Done")], + focusedButton: 0 + ) + } + + private static func selectionScreen(_ review: InteractiveReviewModel, labels: Labels, width: Int, height: Int) -> Buffer { + InteractiveReview.render( + model: review, + title: labels.reviewTitle, + noun: labels.noun, + width: width, + height: height + ) + } +} diff --git a/cli/Sources/puremac/Core/FocusPointer.swift b/cli/Sources/puremac/Core/FocusPointer.swift new file mode 100644 index 0000000..897dd41 --- /dev/null +++ b/cli/Sources/puremac/Core/FocusPointer.swift @@ -0,0 +1,7 @@ +import TerminalUI + +enum FocusPointer { + static func span(focused: Bool) -> Span { + Span(focused ? "›" : " ", TextStyle(color: focused ? .cyan : nil, inverse: focused)) + } +} diff --git a/cli/Sources/puremac/Core/InfoSheet.swift b/cli/Sources/puremac/Core/InfoSheet.swift new file mode 100644 index 0000000..c7bd1bf --- /dev/null +++ b/cli/Sources/puremac/Core/InfoSheet.swift @@ -0,0 +1,77 @@ +import TerminalUI + +struct InfoSheet { + var title: String + var subtitle: String? + var sections: [InfoSection] + var footnote: String? + + private static let gaugeWidth = 20 + + func plainLines(withHeader: Bool = true, colors: Bool = Term.colorEnabled) -> [String] { + var lines: [[Span]] = [] + if withHeader { + lines.append([Span(title, TextStyle(bold: true))]) + if let subtitle { lines.append([Span(subtitle, TextStyle(dim: true))]) } + lines.append([]) + } + return (lines + bodyLines()).map { Term.styled($0, colors: colors) } + } + + private func bodyLines() -> [[Span]] { + var lines: [[Span]] = [] + for section in sections { + if !lines.isEmpty { lines.append([]) } + if let title = section.title { lines.append([Span(title, TextStyle(bold: true))]) } + lines += section.rows.flatMap { Self.lines($0, labelWidth: section.labelWidth, wrappingTo: nil) } + } + if let footnote { + lines.append([]) + lines.append([Span(footnote, TextStyle(dim: true))]) + } + return lines + } + + static func lines(_ row: InfoRow, labelWidth: Int, indent: String = " ", wrappingTo width: Int?) -> [[Span]] { + switch row { + case .metric(let label, let value, let note): + let labelSpan = Span(Term.pad(label, to: labelWidth), TextStyle(dim: true)) + let noteSpans = note.map { [Span(" "), $0] } ?? [] + let row = [Span(indent), labelSpan, Span(" ")] + value + noteSpans + guard let width, Term.displayWidth(row) > width else { return [row] } + let deeper = indent + " " + return [[Span(indent), Span(label, TextStyle(dim: true))], [Span(deeper)] + fitted(value, to: width - deeper.count - Term.displayWidth(noteSpans)) + noteSpans] + case .gauge(let fraction, let color, let text): + let barWidth = width.map { min(gaugeWidth, $0 - indent.count - 1 - Term.displayWidth(text)) } ?? gaugeWidth + let bar = Gauge(fraction: fraction).spans(width: barWidth, filled: color.map { TextStyle(color: $0) } ?? .plain) + return [[Span(indent)] + bar + [Span(bar.isEmpty ? text : " " + text)]] + case .path(let path): + return [[Span(indent + path)]] + case .message(let spans): + guard let width else { return [[Span(indent)] + spans] } + return wrapped(spans, indent: indent, width: width) + } + } + + private static func fitted(_ spans: [Span], to width: Int) -> [Span] { + guard spans.count == 1, let span = spans.first else { return spans } + return [Span(Term.truncate(span.text, to: max(1, width), middle: true), span.style)] + } + + private static func wrapped(_ spans: [Span], indent: String, width: Int) -> [[Span]] { + Term.wordWrap(spans, width: max(1, width - indent.count)).map { [Span(indent)] + $0 } + } +} + +struct InfoSection { + var title: String? + var labelWidth = 17 + var rows: [InfoRow] +} + +enum InfoRow { + case metric(label: String, value: [Span], note: Span?) + case gauge(fraction: Double, color: TerminalColor?, text: String) + case message([Span]) + case path(String) +} diff --git a/cli/Sources/puremac/Core/InteractiveReview.swift b/cli/Sources/puremac/Core/InteractiveReview.swift index 65c3c21..214e61f 100644 --- a/cli/Sources/puremac/Core/InteractiveReview.swift +++ b/cli/Sources/puremac/Core/InteractiveReview.swift @@ -1,4 +1,5 @@ import Foundation +import TerminalUI struct ReviewAddress: Hashable { let category: Int @@ -12,15 +13,25 @@ enum ReviewDisplayRow { case item(ReviewAddress) } +enum ConfirmationButton: Int, CaseIterable { + case cancel, remove +} + +enum ReviewKeyRoute: Equatable { + case handled, remove, quit, passthrough +} + struct InteractiveReviewModel { var categories: [CategoryScan] - var query = "" + private(set) var queryInput = TextInput() var isSearching = false - var isReviewing = false var status = "" var pathOffset = 0 private(set) var focus = 0 - private(set) var reviewFocus = 0 + private(set) var confirmation: ConfirmationButton? + + var query: String { queryInput.text } + var isConfirming: Bool { confirmation != nil } init(categories: [CategoryScan]) { self.categories = categories.map { category in @@ -79,44 +90,36 @@ struct InteractiveReviewModel { categories.reduce(Int64(0)) { $0 + $1.allBytes } } + var hiddenSelectedCount: Int { + let shown = Set(matchingAddresses) + return selectedAddresses.filter { !shown.contains($0) }.count + } + var focusedAddress: ReviewAddress? { - let addresses = isReviewing ? selectedAddresses : matchingAddresses - let index = isReviewing ? reviewFocus : focus - guard addresses.indices.contains(index) else { return nil } - return addresses[index] + let addresses = matchingAddresses + guard addresses.indices.contains(focus) else { return nil } + return addresses[focus] } mutating func move(_ delta: Int) { - let count = isReviewing ? selectedAddresses.count : matchingAddresses.count + let count = matchingAddresses.count guard count > 0 else { return } - if isReviewing { - reviewFocus = min(count - 1, max(0, reviewFocus + delta)) - } else { - focus = min(count - 1, max(0, focus + delta)) - } + focus = min(count - 1, max(0, focus + delta)) pathOffset = 0 } mutating func moveToStart() { - if isReviewing { - reviewFocus = 0 - } else { - focus = 0 - } + focus = 0 pathOffset = 0 } mutating func moveToEnd() { - if isReviewing { - reviewFocus = max(0, selectedAddresses.count - 1) - } else { - focus = max(0, matchingAddresses.count - 1) - } + focus = max(0, matchingAddresses.count - 1) pathOffset = 0 } mutating func toggleFocused() { - guard !isReviewing, let address = focusedAddress else { return } + guard let address = focusedAddress else { return } categories[address.category].groups[address.group].items[address.item].selected.toggle() } @@ -131,41 +134,85 @@ struct InteractiveReviewModel { for address in allAddresses { categories[address.category].groups[address.group].items[address.item].selected = false } - reviewFocus = 0 status = "Selection cleared" } mutating func updateQuery(_ value: String) { - query = value + queryInput = TextInput(value) focus = 0 pathOffset = 0 status = "" } - mutating func beginReview() -> Bool { + mutating func editQuery(_ key: TerminalKey) { + var edited = queryInput + if edited.handle(key) { updateQuery(edited.text) } + } + + mutating func beginConfirmation() -> Bool { guard selectedCount > 0 else { status = "Select at least one item to continue" return false } - isReviewing = true - reviewFocus = 0 - pathOffset = 0 status = "" + confirmation = .cancel return true } - mutating func endReview() { - isReviewing = false - pathOffset = 0 - status = "" + mutating func cancelConfirmation() { + confirmation = nil + } + + mutating func handleConfirmationKey(_ key: TerminalKey) -> ReviewKeyRoute { + guard let focused = confirmation else { return .passthrough } + var buttons = ButtonFocus(count: ConfirmationButton.allCases.count, index: focused.rawValue) + if buttons.navigate(key) { + confirmation = ConfirmationButton(rawValue: buttons.index) + return .handled + } + switch key { + case .enter: + if focused == .remove { return .remove } + cancelConfirmation() + case .escape: + cancelConfirmation() + case .character(let character): + switch character.lowercasedString { + case "y": return .remove + case "n": cancelConfirmation() + case "q": return .quit + default: break + } + default: + break + } + return .handled } - func displayRows(reviewing: Bool) -> [ReviewDisplayRow] { - let addresses = reviewing ? selectedAddresses : matchingAddresses + mutating func routeKey(_ key: TerminalKey) -> ReviewKeyRoute { + if isConfirming { return handleConfirmationKey(key) } + if key == .enter, !isSearching { + _ = beginConfirmation() + return .handled + } + return .passthrough + } + + func acceptsKey(_ key: TerminalKey, tooSmall: Bool) -> Bool { + guard tooSmall, let focused = confirmation else { return true } + switch key { + case .escape: return true + case .enter: return focused == .cancel + case .character(let character): return ["n", "q"].contains(character.lowercasedString) + default: return false + } + } + + var displayRows: [ReviewDisplayRow] { var rows: [ReviewDisplayRow] = [] var lastCategory: Int? var lastGroup: Int? - for address in addresses { + for address in matchingAddresses { if lastCategory != address.category { rows.append(.category(address.category)) lastCategory = address.category @@ -208,15 +255,29 @@ struct InteractiveReviewModel { } enum InteractiveReview { - static func select(_ categories: [CategoryScan], title: String) throws -> [CategoryScan]? { - let session = try TerminalSession() - defer { session.close() } - + static func select( + _ categories: [CategoryScan], + session: TerminalSession, + title: String, + noun: String + ) -> InteractiveReviewModel? { var model = InteractiveReviewModel(categories: categories) while true { - session.draw(lines: render(model: model, title: title, width: session.width, height: session.height)) + session.draw(render(model: model, title: title, noun: noun, width: session.width, height: session.height)) let key = session.readKey() + guard model.acceptsKey(key, tooSmall: !TooSmallScreen.fits(width: session.width, height: session.height)) else { + continue + } + switch model.routeKey(key) { + case .remove: + model.cancelConfirmation() + return model + case .quit: return nil + case .handled: continue + case .passthrough: break + } + if model.isSearching { switch key { case .enter: @@ -224,18 +285,8 @@ enum InteractiveReview { case .escape: model.isSearching = false model.updateQuery("") - case .backspace, .delete: - if !model.query.isEmpty { - model.updateQuery(String(model.query.dropLast())) - } - case .space: - model.updateQuery(model.query + " ") - case .character(let character): - if !character.isASCIIControl { - model.updateQuery(model.query + String(character)) - } default: - break + model.editQuery(key) } continue } @@ -259,28 +310,17 @@ enum InteractiveReview { model.pathOffset += max(8, session.width / 3) case .space: model.toggleFocused() - case .enter: - if model.isReviewing { - return model.selectedResult() - } - _ = model.beginReview() case .escape: - if model.isReviewing { - model.endReview() - } else { - return nil - } + return nil case .character(let character): switch character.lowercasedString { case "a": - if !model.isReviewing { model.selectAllMatching() } + model.selectAllMatching() case "n": - if !model.isReviewing { model.selectNone() } + model.selectNone() case "/": - if !model.isReviewing { - model.isSearching = true - model.status = "" - } + model.isSearching = true + model.status = "" case "q": return nil default: @@ -292,125 +332,166 @@ enum InteractiveReview { } } + private static let itemPrefixWidth = 5 + Render.sizeColumnWidth + private static let summaryGap = 2 + static func render( model: InteractiveReviewModel, title: String, + noun: String, width: Int, height: Int - ) -> [String] { - let safeWidth = max(1, width) - let safeHeight = max(1, height) - if safeWidth < 40 || safeHeight < 12 { - return compactScreen(width: safeWidth, height: safeHeight) - } - let titleText = Term.truncate(title, to: safeWidth - 2) - let selection = "\(model.selectedCount) selected · \(ByteCount.human(model.selectedBytes))" - let found = "\(model.matchingAddresses.count) shown · \(ByteCount.human(model.allBytes))" - var lines = [ - Term.dim("PUREMAC / \(model.isReviewing ? "FINAL REVIEW" : "SELECT ITEMS")"), - Term.bold(Term.cyan(titleText)), - statusLine(left: found, right: selection, width: safeWidth) - ] - - if model.isSearching { - let query = Term.truncate(model.query, to: max(1, safeWidth - 10)) - lines.append(Term.cyan("Search ") + query + Term.inverse(" ")) - } else if !model.status.isEmpty { - lines.append(Term.yellow(Term.truncate(model.status, to: safeWidth))) - } else if model.query.isEmpty { - lines.append("") - } else { - lines.append(Term.dim("Filter ") + Term.truncate(model.query, to: max(1, safeWidth - 9))) + ) -> Buffer { + guard TooSmallScreen.fits(width: width, height: height) else { + return TooSmallScreen.render(title: "PureMac", exitHint: "Esc/q cancel", width: width, height: height) } - - let footerHeight = 5 - let bodyHeight = max(3, safeHeight - lines.count - footerHeight) - let rows = model.displayRows(reviewing: model.isReviewing) + var buffer = Buffer(width: width, height: height) + let window = Window( + title: title, + info: "\(model.selectedCount) selected · \(ByteCount.human(model.selectedBytes))" + ) + let content = window.render(in: buffer.area, buffer: &buffer) + let hints = KeyHints(selectHints) + let areas = content.splitRows( + [.length(1), .fill, .length(1), .length(1), .length(hints.rows(width: content.width).count)] + ) + let (header, list, divider, path, footer) = (areas[0], areas[1], areas[2], areas[3], areas[4]) + + renderHeader(model: model, in: header, buffer: &buffer) + window.renderDivider(atRow: divider.y, in: buffer.area, buffer: &buffer) + buffer.write(focusedPathSpans(model: model, width: path.width), x: path.x, y: path.y, maxWidth: path.width) + hints.render(in: footer, buffer: &buffer) + + let rows = model.displayRows let focusedAddress = model.focusedAddress let focusedRow = rows.firstIndex { row in if case .item(let address) = row { return address == focusedAddress } return false } ?? 0 - let start = max(0, min(max(0, rows.count - bodyHeight), focusedRow - bodyHeight / 2)) - let visibleRows = rows.dropFirst(start).prefix(bodyHeight) - - for row in visibleRows { - switch row { - case .category(let categoryIndex): - let category = model.categories[categoryIndex] - let label = Term.truncate(Term.sanitize(category.title).uppercased(), to: safeWidth - 4) - lines.append(" " + Term.bold(Term.cyan(label))) - case .group(let categoryIndex, let groupIndex): - let group = model.categories[categoryIndex].groups[groupIndex] - let suffix = "\(group.items.count) \(group.items.count == 1 ? "item" : "items")" - let available = max(1, safeWidth - 8 - suffix.count) - lines.append(" " + Term.dim(Term.pad(Term.truncate(group.tool, to: available), to: available) + suffix)) - case .item(let address): - let item = model.item(at: address) - let focused = address == focusedAddress - let mark = item.selected ? Term.cyan("●") : Term.dim("○") - let pointer = focused ? Term.cyan("›") : " " - let size = Term.pad(ByteCount.human(item.sizeBytes), to: 10) - let pathWidth = max(6, safeWidth - 17) - let path = Term.truncate(Render.shorten(item.path), to: pathWidth, middle: true) - let content = "\(pointer) \(mark) \(size) \(path)" - lines.append(focused ? Term.inverse(content) : content) - } + let start = Scrollbar.centeredOffset(total: rows.count, visible: list.height, focus: focusedRow) + for (index, row) in rows.enumerated().dropFirst(start).prefix(list.height) { + let rect = Rect(x: list.x, y: list.y + index - start, width: list.width, height: 1) + renderRow(row, focused: focusedAddress, model: model, in: rect, buffer: &buffer) } + Scrollbar(total: rows.count, visible: list.height, offset: start) + .render(column: width - 1, rows: list.y..<(list.y + list.height), buffer: &buffer) + guard let focus = model.confirmation else { return buffer } + let dialog = confirmationDialog( + count: model.selectedCount, + total: model.selectedBytes, + hidden: model.hiddenSelectedCount, + noun: noun, + focus: focus + ) + return StatusScreen.overlay(dialog, on: buffer) + } - while lines.count < safeHeight - footerHeight { - lines.append("") - } + static func confirmationDialog( + count: Int, + total: Int64, + hidden: Int, + noun: String, + focus: ConfirmationButton + ) -> Dialog { + var message = [Span("\(ByteCount.human(total)) will be deleted")] + if hidden > 0 { message.append(Span("\(hidden) hidden by search", TextStyle(dim: true))) } + return Dialog( + title: "Remove \(Render.counted(count, noun))?", + titleStyle: StatusScreen.warningTitle, + borderStyle: StatusScreen.warningBorder, + message: message, + buttons: [DialogButton("Cancel"), DialogButton("Remove", role: .destructive)], + focusedButton: focus.rawValue, + hints: StatusScreen.choiceHints + ) + } - let divider = String(repeating: "─", count: safeWidth) - lines.append(Term.dim(divider)) - lines.append(focusedPathLine(model: model, width: safeWidth)) - if model.isReviewing { - lines.append(Term.dim("↑↓ scroll ←→ path")) - lines.append(Term.dim("Enter confirm Esc edit")) - lines.append(Term.yellow("q cancel command confirmation follows")) + private static let selectHints = [ + KeyHint("↑↓", "move"), + KeyHint("←→", "path"), + KeyHint("Space", "toggle"), + KeyHint("A", "all"), + KeyHint("N", "none"), + KeyHint("/", "search"), + KeyHint("Enter", "remove"), + KeyHint("Esc/q", "cancel") + ] + + private static func renderHeader(model: InteractiveReviewModel, in rect: Rect, buffer: inout Buffer) { + let dim = TextStyle(dim: true) + let found = "\(model.matchingAddresses.count) shown · \(ByteCount.human(model.allBytes))" + let left: [Span] + if model.isSearching { + left = model.queryInput.fieldSpans(label: "Search ", width: rect.width) + } else if !model.status.isEmpty { + left = [Span(Term.truncate(model.status, to: rect.width), TextStyle(color: .yellow))] + } else if !model.query.isEmpty { + let label = "Filter " + left = [Span(label + Term.truncate(model.query, to: max(1, rect.width - label.count)), dim)] } else { - lines.append(Term.dim("↑↓ move ←→ path Space toggle")) - lines.append(Term.dim("A all N none / search")) - lines.append(Term.dim("Enter review Esc/q cancel")) + buffer.write(Term.truncate(found, to: rect.width), x: rect.x, y: rect.y, style: dim) + return } - return Array(lines.prefix(safeHeight)) - } - - private static func compactScreen(width: Int, height: Int) -> [String] { - let lines = [ - Term.bold(Term.cyan(Term.truncate("PureMac", to: width))), - Term.truncate("Terminal too small", to: width), - Term.dim(Term.truncate("Resize to at least 40 x 12", to: width)), - "", - Term.dim(Term.truncate("Esc/q cancel", to: width)) - ] - return Array(lines.prefix(height)) - } - - static func statusLine(left: String, right: String, width: Int) -> String { - let available = max(1, width - 2) - let leftWidth = Term.displayWidth(left) - let rightWidth = Term.displayWidth(right) - if leftWidth + rightWidth <= available { - return Term.pad(left, to: available - rightWidth) + " " + Term.bold(right) + let leftWidth = buffer.write(left, x: rect.x, y: rect.y, maxWidth: rect.width) + let foundWidth = Term.displayWidth(found) + if leftWidth + summaryGap + foundWidth <= rect.width { + buffer.write(found, x: rect.x + rect.width - foundWidth, y: rect.y, style: dim) } + } - let rightBudget = min(rightWidth, max(1, available / 2)) - let leftBudget = max(1, available - rightBudget) - let clippedLeft = Term.truncate(left, to: leftBudget) - let clippedRight = Term.truncate(right, to: rightBudget) - return Term.pad(clippedLeft, to: leftBudget) + " " + Term.bold(clippedRight) + private static func renderRow( + _ row: ReviewDisplayRow, + focused focusedAddress: ReviewAddress?, + model: InteractiveReviewModel, + in rect: Rect, + buffer: inout Buffer + ) { + switch row { + case .category(let categoryIndex): + let title = Term.truncate(model.categories[categoryIndex].title, to: rect.width) + buffer.write(title, x: rect.x, y: rect.y, style: TextStyle(bold: true), maxWidth: rect.width) + case .group(let categoryIndex, let groupIndex): + let group = model.categories[categoryIndex].groups[groupIndex] + let suffix = "\(group.items.count) \(group.items.count == 1 ? "item" : "items")" + let label = Term.truncate(group.tool, to: max(1, rect.width - suffix.count - 1)) + buffer.write( + Term.pad(label, to: rect.width - suffix.count) + suffix, + x: rect.x, + y: rect.y, + style: TextStyle(dim: true), + maxWidth: rect.width + ) + case .item(let address): + let item = model.item(at: address) + let focused = address == focusedAddress + let rowStyle = TextStyle(inverse: focused) + let sizeStyle = SizeLevel.style(bytes: item.sizeBytes, focused: focused) + let pathWidth = max(1, rect.width - itemPrefixWidth) + let path = Term.truncate(Render.shorten(item.path), to: pathWidth, middle: true) + buffer.write( + [ + FocusPointer.span(focused: focused), + Span(" ", rowStyle), + Span(item.selected ? "●" : "○", TextStyle(color: item.selected ? .cyan : nil, dim: !item.selected, inverse: focused)), + Span(" ", rowStyle), + Span(Render.sizeCol(item.sizeBytes), sizeStyle), + Span(" " + Term.pad(path, to: pathWidth), rowStyle) + ], + x: rect.x, + y: rect.y, + maxWidth: rect.width + ) + } } - private static func focusedPathLine(model: InteractiveReviewModel, width: Int) -> String { + private static func focusedPathSpans(model: InteractiveReviewModel, width: Int) -> [Span] { + let label = TextStyle(dim: true) guard let address = model.focusedAddress else { - return Term.dim("Path No matching item") + return [Span("Path No matching item", label)] } let path = Render.shorten(model.item(at: address).path) - let available = max(1, width - 6) - let window = horizontalWindow(path, offset: model.pathOffset, width: available) - return Term.dim("Path ") + window + let window = horizontalWindow(path, offset: model.pathOffset, width: max(1, width - 6)) + return [Span("Path ", label), Span(window)] } static func horizontalWindow(_ value: String, offset: Int, width: Int) -> String { @@ -437,10 +518,6 @@ enum InteractiveReview { } private extension Character { - var isASCIIControl: Bool { - unicodeScalars.allSatisfy { $0.value < 0x20 || $0.value == 0x7F } - } - var lowercasedString: String { String(self).lowercased() } diff --git a/cli/Sources/puremac/Core/PathListEditor.swift b/cli/Sources/puremac/Core/PathListEditor.swift new file mode 100644 index 0000000..f9c2152 --- /dev/null +++ b/cli/Sources/puremac/Core/PathListEditor.swift @@ -0,0 +1,310 @@ +import Foundation +import TerminalUI + +struct PathListError: LocalizedError { + let message: String + + init(_ message: String) { self.message = message } + + static func duplicate(_ path: String) -> PathListError { + PathListError("Already in the list: \(Render.shorten(path))") + } + + var errorDescription: String? { message } +} + +struct RemovalPrompt { + var title: (String) -> String + var button: String +} + +struct PathListConfig { + var title: String + var emptyText: String + var load: () throws -> [String] + var add: (String) throws -> Void + var remove: (String) throws -> Void + var removalPrompt: RemovalPrompt? + var inputLabel = "Add folder" +} + +extension PathListConfig { + private static func standardized(_ path: String) -> String { + (Safety.absolutePath(path) as NSString).standardizingPath + } + + static var projectFolders: PathListConfig { + PathListConfig( + title: "Project folders", + emptyText: "No project folders. Press A to add one.", + load: { try ProjectFoldersStore(fileURL: AppPaths.projectsFile).roots }, + add: { input in + let typed = input.trimmingCharacters(in: .whitespaces) + guard !typed.isEmpty else { throw PathListError("Enter a folder path") } + let verdict = Safety.isValidScanRoot(typed) + guard verdict.ok else { throw PathListError(verdict.reason ?? "Not a valid project folder") } + var store = try ProjectFoldersStore(fileURL: AppPaths.projectsFile) + let root = standardized(typed) + guard try store.add(root) else { throw PathListError.duplicate(root) } + }, + remove: { path in + var store = try ProjectFoldersStore(fileURL: AppPaths.projectsFile) + _ = try store.remove(path) + }, + removalPrompt: nil + ) + } + + static var protectedPaths: PathListConfig { + PathListConfig( + title: "Protected paths", + emptyText: "No protected paths. Press A to add one.", + load: { try IgnoreStore(fileURL: AppPaths.ignoreFile).roots.sorted() }, + add: { path in + var store = try IgnoreStore(fileURL: AppPaths.ignoreFile) + guard try store.add(path) else { + throw PathListError.duplicate(standardized(path)) + } + }, + remove: { path in + var store = try IgnoreStore(fileURL: AppPaths.ignoreFile) + _ = try store.remove(path) + }, + removalPrompt: RemovalPrompt( + title: { "Stop protecting \($0)?" }, + button: "Remove protection" + ), + inputLabel: "Add path" + ) + } +} + +struct PathListEditor { + private let config: PathListConfig + private(set) var paths: [String] = [] + private(set) var focus = 0 + private(set) var input: TextInput? + private(set) var confirmation: ButtonFocus? + private(set) var error: String? + private(set) var loadError: String? + + private static let browseHints = KeyHints([ + KeyHint("↑↓", "move"), KeyHint("A", "add"), KeyHint("D", "remove"), KeyHint("Esc", "back"), + ]) + private static let inputHints = KeyHints([KeyHint("Enter", "add"), KeyHint("Esc", "cancel")]) + private static let maxErrorRows = 2 + private static let buttonCount = 2 + private static let cancelIndex = 0 + private static let removeIndex = buttonCount - 1 + private static let notFound = " not found" + + init(_ config: PathListConfig) { + self.config = config + reload() + } + + mutating func handle(_ key: TerminalKey) -> Bool { + error = nil + if confirmation != nil { + handleConfirmation(key) + } else if input != nil { + handleInput(key) + } else { + return handleList(key) + } + return false + } + + func acceptsKey(_ key: TerminalKey, tooSmall: Bool) -> Bool { + guard tooSmall else { return true } + if let confirmation { + if case .character(let character) = key, ["n", "q"].contains(character.lowercased()) { return true } + return key == .escape || (key == .enter && confirmation.index == Self.cancelIndex) + } + return key == .escape + } + + private mutating func handleList(_ key: TerminalKey) -> Bool { + switch key { + case .up: move(-1) + case .down: move(1) + case .escape, .left, .backspace: return true + case .character(let character): + switch character.lowercased() { + case "q": return true + case "a": input = TextInput("~/") + case "d": beginRemoval() + default: break + } + default: break + } + return false + } + + private mutating func handleInput(_ key: TerminalKey) { + switch key { + case .enter: + guard let text = input?.text else { return } + let add = config.add + let before = Set(paths) + guard perform({ try add(text) }) else { return } + input = nil + if let added = paths.firstIndex(where: { !before.contains($0) }) { focus = added } + case .escape: input = nil + default: input?.handle(key) + } + } + + private mutating func handleConfirmation(_ key: TerminalKey) { + guard var buttons = confirmation else { return } + if buttons.navigate(key) { + confirmation = buttons + return + } + switch key { + case .enter: + confirmation = nil + if buttons.index == Self.removeIndex { removeFocused() } + case .escape: confirmation = nil + case .character(let character) where ["n", "q"].contains(character.lowercased()): confirmation = nil + default: break + } + } + + private mutating func move(_ delta: Int) { + focus = min(max(0, paths.count - 1), max(0, focus + delta)) + } + + private mutating func beginRemoval() { + guard paths.indices.contains(focus) else { return } + if config.removalPrompt != nil { + confirmation = ButtonFocus(count: Self.buttonCount, index: Self.cancelIndex) + } else { + removeFocused() + } + } + + private mutating func removeFocused() { + let path = paths[focus] + let remove = config.remove + perform { try remove(path) } + } + + @discardableResult + private mutating func perform(_ change: () throws -> Void) -> Bool { + do { + try change() + } catch { + self.error = error.localizedDescription + return false + } + reload() + return true + } + + private mutating func reload() { + do { + paths = try config.load() + loadError = nil + } catch { + paths = [] + loadError = error.localizedDescription + } + move(0) + } + + func render(width: Int, height: Int) -> Buffer { + guard TooSmallScreen.fits(width: width, height: height) else { + return TooSmallScreen.render(title: config.title, exitHint: "Esc back", width: width, height: height) + } + var buffer = Buffer(width: width, height: height) + let window = Window(title: config.title) + let content = window.render(in: buffer.area, buffer: &buffer) + let hints = input == nil ? Self.browseHints : Self.inputHints + let errorRows = errorRows(width: content.width) + let areas = content.splitRows([ + .fill, + .length(1), + .length(errorRows.count), + .length(input == nil ? 0 : 1), + .length(hints.rows(width: content.width).count), + ]) + let (list, divider, status, field, footer) = (areas[0], areas[1], areas[2], areas[3], areas[4]) + + window.renderDivider(atRow: divider.y, in: buffer.area, buffer: &buffer) + hints.render(in: footer, buffer: &buffer) + for (offset, row) in errorRows.enumerated() { + buffer.write(row, x: status.x, y: status.y + offset, maxWidth: status.width) + } + if let input { + buffer.write(input.fieldSpans(label: config.inputLabel + " ", width: field.width), x: field.x, y: field.y, maxWidth: field.width) + } + renderList(in: list, buffer: &buffer) + guard let confirmation, let prompt = config.removalPrompt, paths.indices.contains(focus) else { return buffer } + let dialog = Dialog( + title: Self.removalTitle(prompt, path: paths[focus], area: buffer.area), + titleStyle: StatusScreen.warningTitle, + borderStyle: StatusScreen.warningBorder, + buttons: [DialogButton("Cancel"), DialogButton(prompt.button, role: .destructive)], + focusedButton: confirmation.index, + hints: StatusScreen.choiceHints + ) + return StatusScreen.overlay(dialog, on: buffer) + } + + private static func removalTitle(_ prompt: RemovalPrompt, path: String, area: Rect) -> String { + let room = Dialog.maxTitleWidth(in: area) - Term.displayWidth(prompt.title("")) + return prompt.title(Term.truncate(Render.shorten(path), to: max(1, room), middle: true)) + } + + private func errorRows(width: Int) -> [[Span]] { + guard let error else { return [] } + return Array(Term.wordWrap([Span(error, TextStyle(color: .yellow))], width: width).prefix(Self.maxErrorRows)) + } + + private func renderList(in rect: Rect, buffer: inout Buffer) { + guard !paths.isEmpty else { + let message = loadError.map { Span($0, TextStyle(color: .red)) } ?? Span(config.emptyText, TextStyle(dim: true)) + for (offset, row) in Term.wordWrap([message], width: rect.width).prefix(rect.height).enumerated() { + buffer.write(row, x: rect.x, y: rect.y + offset, maxWidth: rect.width) + } + return + } + let start = Scrollbar.centeredOffset(total: paths.count, visible: rect.height, focus: focus) + for (index, path) in paths.enumerated().dropFirst(start).prefix(rect.height) { + renderRow(path, focused: index == focus, in: Rect(x: rect.x, y: rect.y + index - start, width: rect.width, height: 1), buffer: &buffer) + } + Scrollbar(total: paths.count, visible: rect.height, offset: start) + .render(column: buffer.width - 1, rows: rect.y..<(rect.y + rect.height), buffer: &buffer) + } + + private func renderRow(_ path: String, focused: Bool, in rect: Rect, buffer: inout Buffer) { + let rowStyle = TextStyle(inverse: focused) + let note = FileManager.default.fileExists(atPath: path) ? "" : Self.notFound + let noteWidth = Term.displayWidth(note) + let shown = Term.truncate(Render.shorten(path), to: max(1, rect.width - 2 - noteWidth), middle: true) + let rest = max(0, rect.width - 2 - Term.displayWidth(shown) - noteWidth) + buffer.write( + [ + FocusPointer.span(focused: focused), + Span(" " + shown, rowStyle), + Span(note, TextStyle(dim: true, inverse: focused)), + Span(String(repeating: " ", count: rest), rowStyle), + ], + x: rect.x, + y: rect.y, + maxWidth: rect.width + ) + } + + static func run(_ config: PathListConfig, in session: TerminalSession) { + var editor = PathListEditor(config) + while true { + session.draw(editor.render(width: session.width, height: session.height)) + let key = session.readKey() + let tooSmall = !TooSmallScreen.fits(width: session.width, height: session.height) + guard editor.acceptsKey(key, tooSmall: tooSmall) else { continue } + if editor.handle(key) { return } + } + } +} diff --git a/cli/Sources/puremac/Core/PreferencesScreen.swift b/cli/Sources/puremac/Core/PreferencesScreen.swift new file mode 100644 index 0000000..72563e4 --- /dev/null +++ b/cli/Sources/puremac/Core/PreferencesScreen.swift @@ -0,0 +1,193 @@ +import Foundation +import TerminalUI + +enum PreferencesAction: Equatable { + case back, openProjectFolders, openProtectedPaths +} + +struct PreferencesScreen { + enum Row: Int, CaseIterable { + case age, projectFolders, protectedPaths + } + + private(set) var days = ConfigStore.defaultDays + private(set) var folderCount = Purger.defaultRoots.count + private(set) var pathCount = 0 + private(set) var focus = Row.age + private var loadError: String? + private var configUnreadable = false + private var saveError: String? + private let setupError: String? + + static let presets = [1, 3, 7, 14, 30, 60, 90, 180, 365] + private static let title = "Preferences" + private static let hints = KeyHints([ + KeyHint("↑↓", "move"), KeyHint("←→", "change"), KeyHint("Enter", "open"), KeyHint("Esc", "back"), + ]) + private static let ageLabel = "Preselect artifacts older than" + private static let foldersLabel = "Project folders" + private static let pathsLabel = "Protected paths" + private static var widestValue: Int { Term.displayWidth(ageValue(presets.max() ?? 0)) } + private static let maxErrorRows = 3 + + init(setupError: Error? = nil) { + self.setupError = setupError.map { "Could not create settings files: \($0.localizedDescription)" } + reload() + } + + private static func ageValue(_ days: Int) -> String { + "‹ \(Render.counted(days, "day")) ›" + } + + mutating func reload() { + loadError = nil + configUnreadable = false + do { + let store = try ConfigStore(fileURL: AppPaths.configFile) + days = store.olderThanDays + } catch { + days = ConfigStore.defaultDays + loadError = error.localizedDescription + configUnreadable = true + } + do { + folderCount = try ProjectFoldersStore(fileURL: AppPaths.projectsFile).roots.count + } catch { + folderCount = Purger.defaultRoots.count + loadError = loadError ?? error.localizedDescription + } + do { + pathCount = try IgnoreStore(fileURL: AppPaths.ignoreFile).roots.count + } catch { + pathCount = 0 + loadError = loadError ?? error.localizedDescription + } + } + + mutating func handle(_ key: TerminalKey) -> PreferencesAction? { + saveError = nil + switch key { + case .up: move(-1) + case .down: move(1) + case .left: if focus == .age { changeDays(to: Self.presets.last { $0 < days }) } + case .right: if focus == .age { changeDays(to: Self.presets.first { $0 > days }) } + case .enter: + switch focus { + case .age: break + case .projectFolders: return .openProjectFolders + case .protectedPaths: return .openProtectedPaths + } + case .escape: return .back + case .character(let character): return character.lowercased() == "q" ? .back : nil + default: break + } + return nil + } + + func acceptsKey(_ key: TerminalKey, tooSmall: Bool) -> Bool { + !tooSmall || key == .escape + } + + private mutating func move(_ delta: Int) { + let index = min(Row.allCases.count - 1, max(0, focus.rawValue + delta)) + focus = Row(rawValue: index) ?? focus + } + + private mutating func changeDays(to target: Int?) { + guard let target, !configUnreadable else { return } + do { + var store = try ConfigStore(fileURL: AppPaths.configFile) + try store.set(ConfigStore.daysKey, String(target)) + days = target + } catch { + saveError = error.localizedDescription + } + } + + func render(width: Int, height: Int) -> Buffer { + guard TooSmallScreen.fits(width: width, height: height) else { + return TooSmallScreen.render(title: Self.title, exitHint: "Esc back", width: width, height: height) + } + var buffer = Buffer(width: width, height: height) + let window = Window(title: Self.title) + let content = window.render(in: buffer.area, buffer: &buffer) + let lines = listLines(width: content.width) + let footerRows = Self.hints.rows(width: content.width).count + let areas = content.splitRows([.fill, .length(1), .length(1), .length(footerRows)]) + let (main, saved, divider, footer) = (areas[0], areas[1], areas[2], areas[3]) + let parts = main.splitRows([.length(lines.count), .fill]) + let (list, status) = (parts[0], parts[1]) + + for (offset, line) in lines.prefix(list.height).enumerated() { + buffer.write(line, x: list.x, y: list.y + offset, maxWidth: list.width) + } + let errors = errorRows(width: content.width) + for (offset, row) in errors.prefix(status.height).enumerated() { + buffer.write(row, x: status.x, y: status.y + offset, maxWidth: status.width) + } + if let first = errors.first, status.height == 0 { + buffer.write(first, x: saved.x, y: saved.y, maxWidth: saved.width) + } else { + let location = Term.truncate("Saved in " + Render.shorten(AppPaths.configDir.path), to: saved.width, middle: true) + buffer.write(location, x: saved.x, y: saved.y, style: TextStyle(dim: true)) + } + window.renderDivider(atRow: divider.y, in: buffer.area, buffer: &buffer) + Self.hints.render(in: footer, buffer: &buffer) + return buffer + } + + private func errorRows(width: Int) -> [[Span]] { + let messages = [ + setupError.map { Span($0, TextStyle(color: .yellow)) }, + saveError.map { Span($0, TextStyle(color: .yellow)) }, + loadError.map { Span($0, TextStyle(color: .red)) }, + ].compactMap { $0 } + return Array(messages.flatMap { Term.wordWrap([$0], width: width) }.prefix(Self.maxErrorRows)) + } + + private func listLines(width: Int) -> [[Span]] { + let rows: [(row: Row, label: String, value: String)] = [ + (.age, Self.ageLabel, Self.ageValue(days)), + (.projectFolders, Self.foldersLabel, "\(Render.counted(folderCount, "folder")) ›"), + (.protectedPaths, Self.pathsLabel, "\(Render.counted(pathCount, "path")) ›"), + ] + let valueWidth = rows.map { Term.displayWidth($0.value) }.reduce(Self.widestValue, max) + let widestLabel = rows.map { Term.displayWidth($0.label) }.max() ?? 1 + let labelWidth = max(1, min(widestLabel, width - 2 - 2 - valueWidth)) + func line(_ item: (row: Row, label: String, value: String)) -> [Span] { + let text = " " + Term.pad(Term.truncate(item.label, to: labelWidth), to: labelWidth) + " " + item.value + return [ + FocusPointer.span(focused: focus == item.row), + Span(Term.pad(text, to: width - 1), TextStyle(inverse: focus == item.row)), + ] + } + return [ + [Span("Project cleanup", TextStyle(bold: true))], + line(rows[0]), + line(rows[1]), + [], + [Span("Protection", TextStyle(bold: true))], + line(rows[2]), + ] + } + + static func run(setupError: Error?, in session: TerminalSession) { + var screen = PreferencesScreen(setupError: setupError) + while true { + session.draw(screen.render(width: session.width, height: session.height)) + let key = session.readKey() + let tooSmall = !TooSmallScreen.fits(width: session.width, height: session.height) + guard screen.acceptsKey(key, tooSmall: tooSmall) else { continue } + switch screen.handle(key) { + case .back: return + case .openProjectFolders: + PathListEditor.run(.projectFolders, in: session) + screen.reload() + case .openProtectedPaths: + PathListEditor.run(.protectedPaths, in: session) + screen.reload() + case nil: break + } + } + } +} diff --git a/cli/Sources/puremac/Core/Purger.swift b/cli/Sources/puremac/Core/Purger.swift index fac3a8f..91d3272 100644 --- a/cli/Sources/puremac/Core/Purger.swift +++ b/cli/Sources/puremac/Core/Purger.swift @@ -18,19 +18,27 @@ enum Purger { artifactNames.contains(name) || artifactPrefixes.contains { name.hasPrefix($0) } } - static func scan(roots: [String], olderThanDays: Int, ignore: IgnoreStore) -> CategoryScan { + static func scan( + roots: [String], + olderThanDays: Int, + ignore: IgnoreStore, + progress: ((PurgeScanEvent) -> Void)? = nil, + isCancelled: () -> Bool = { false } + ) -> CategoryScan { let fm = FileManager.default let cutoff = Date().addingTimeInterval(-Double(olderThanDays) * 86_400) var byProject: [String: [ScanItem]] = [:] var order: [String] = [] - for rootURL in scanRoots(roots) { + walk: for rootURL in scanRoots(roots) { + progress?(.root(rootURL.path)) guard let en = fm.enumerator(at: rootURL, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], options: [.skipsPackageDescendants], errorHandler: { _, _ in true }) else { continue } for case let url as URL in en { + if isCancelled() { break walk } let name = url.lastPathComponent let vals = try? url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) if vals?.isSymbolicLink == true { en.skipDescendants(); continue } @@ -41,16 +49,19 @@ enum Purger { continue } + progress?(.walking(url.path)) + if isArtifact(name) { en.skipDescendants() if Safety.ignoredPathProtectsCandidate(url.path, ignore: ignore) { continue } - let measurement = DirSizer.measure(of: url.path) + let measurement = DirSizer.measure(of: url.path, isCancelled: isCancelled) guard measurement.status == .complete, measurement.bytes >= CategoryScanner.minSize else { continue } let mod = DirSizer.modified(of: url.path) let old = (mod ?? .distantFuture) < cutoff let project = projectLabel(url, level: en.level, root: rootURL) let item = ScanItem(path: url.path, sizeBytes: measurement.bytes, modified: mod, selected: old) + progress?(.found(bytes: measurement.bytes)) byProject[project, default: []].append(item) if !order.contains(project) { order.append(project) } } diff --git a/cli/Sources/puremac/Core/Render.swift b/cli/Sources/puremac/Core/Render.swift index 0d4a6ca..9bf4512 100644 --- a/cli/Sources/puremac/Core/Render.swift +++ b/cli/Sources/puremac/Core/Render.swift @@ -1,34 +1,37 @@ import Darwin import Foundation +import TerminalUI enum Render { static let rule = String(repeating: "─", count: 60) + static let sizeColumnWidth = 10 + static let reviewHelp = "Return on the selection screen opens a confirmation with Cancel focused. --plain uses a numbered selector with ranges, all, and none, then prints the exact deletion review and asks (default No)." - static func scanResults(_ categories: [CategoryScan]) { + static func scanResults(_ categories: [CategoryScan], colors: Bool = Term.colorEnabled) { let total = categories.reduce(Int64(0)) { $0 + $1.allBytes } let itemCount = categories.flatMap(\.groups).flatMap(\.items).count print("") - print(Term.bold("Scan complete")) + print(paint("Scan complete", TextStyle(bold: true), colors)) print( - Term.bold(ByteCount.human(total)) - + Term.dim(" across \(itemCount) item\(itemCount == 1 ? "" : "s")") + paint(ByteCount.human(total), TextStyle(bold: true), colors) + + paint(" across \(itemCount) item\(itemCount == 1 ? "" : "s")", TextStyle(dim: true), colors) ) for category in categories where !category.groups.isEmpty { print("") print( - Term.cyan(Term.bold(Term.sanitize(category.title))) - + Term.dim(" \(ByteCount.human(category.allBytes))") + paint(category.title, TextStyle(color: .cyan, bold: true), colors) + + paint(" \(ByteCount.human(category.allBytes))", TextStyle(dim: true), colors) ) for group in category.groups { print( - " " + Term.bold(Term.sanitize(group.tool)) - + Term.dim(" \(group.items.count) item\(group.items.count == 1 ? "" : "s")") + " " + paint(group.tool, TextStyle(bold: true), colors) + + paint(" \(group.items.count) item\(group.items.count == 1 ? "" : "s")", TextStyle(dim: true), colors) ) for item in group.items { - let mark = item.selected ? Term.cyan("●") : Term.dim("○") + let mark = item.selected ? paint("●", TextStyle(color: .cyan), colors) : paint("○", TextStyle(dim: true), colors) let path = displayedPath(item.path, available: max(12, outputWidth - 18)) - print(" \(mark) \(sizeCol(item.sizeBytes)) \(path)") + print(" \(mark) \(coloredSizeCol(item.sizeBytes, colors: colors)) \(path)") } } } @@ -44,32 +47,33 @@ enum Render { ) } - static func deletionReview(_ categories: [CategoryScan]) { + static func deletionReview(_ categories: [CategoryScan], colors: Bool = Term.colorEnabled) { let selected = categories.flatMap { $0.selectedItems } let total = selected.reduce(Int64(0)) { $0 + $1.sizeBytes } + let rule = paint(String(repeating: "─", count: min(72, outputWidth)), TextStyle(dim: true), colors) print("") - print(Term.yellow(Term.bold("Permanent deletion review"))) - print(Term.dim(String(repeating: "─", count: min(72, outputWidth)))) + print(paint("Permanent deletion review", TextStyle(color: .yellow, bold: true), colors)) + print(rule) for category in categories { let selectedInCategory = category.selectedItems guard !selectedInCategory.isEmpty else { continue } print( - Term.cyan(Term.sanitize(category.title)) - + Term.dim(" \(ByteCount.human(selectedInCategory.reduce(0) { $0 + $1.sizeBytes }))") + paint(category.title, TextStyle(color: .cyan), colors) + + paint(" \(ByteCount.human(selectedInCategory.reduce(0) { $0 + $1.sizeBytes }))", TextStyle(dim: true), colors) ) for group in category.groups { for item in group.items where item.selected { - print(" \(sizeCol(item.sizeBytes)) \(Term.sanitize(group.tool))") + print(" \(coloredSizeCol(item.sizeBytes, colors: colors)) \(Term.sanitize(group.tool))") print(" \(Term.sanitize(item.path))") } } } - print(Term.dim(String(repeating: "─", count: min(72, outputWidth)))) + print(rule) print( - Term.bold(ByteCount.human(total)) - + Term.dim(" \(selected.count) item\(selected.count == 1 ? "" : "s")") + paint(ByteCount.human(total), TextStyle(bold: true), colors) + + paint(" \(selected.count) item\(selected.count == 1 ? "" : "s")", TextStyle(dim: true), colors) ) } @@ -79,22 +83,22 @@ enum Render { print(Term.bold("Dry run complete")) print( Term.cyan(ByteCount.human(outcome.freedBytes)) - + Term.dim(" reclaimable across \(outcome.removed) item\(outcome.removed == 1 ? "" : "s")") + + Term.dim(" reclaimable across \(itemCount(outcome.removed))") ) print(Term.dim("Nothing was deleted.")) } else { - print(Term.green(Term.bold("Cleanup complete"))) + print(Term.green(Term.bold(cleanupCompleteTitle))) print( Term.bold(ByteCount.human(outcome.freedBytes)) - + Term.dim(" removed across \(outcome.removed) item\(outcome.removed == 1 ? "" : "s")") + + Term.dim(removedDetail(outcome)) ) } if !outcome.skipped.isEmpty { - print(Term.yellow("\(outcome.skipped.count) skipped") + Term.dim(" protected or ignored")) + print(Term.yellow(skippedLabel(outcome)) + Term.dim(skippedNote)) } if !outcome.failed.isEmpty { - print(Term.red("\(outcome.failed.count) failed")) + print(Term.red(failedLabel(outcome))) for failure in outcome.failed.prefix(10) { Term.err(" ! \(Term.sanitize(failure.path))") Term.err(" \(Term.sanitize(failure.error))") @@ -103,8 +107,40 @@ enum Render { print("") } + static let cleanupCompleteTitle = "Cleanup complete" + + static func counted(_ count: Int, _ noun: String) -> String { + "\(count) \(noun)\(count == 1 ? "" : "s")" + } + + static func itemCount(_ count: Int) -> String { + counted(count, "item") + } + + static func removedDetail(_ outcome: CleanOutcome) -> String { + " removed across \(itemCount(outcome.removed))" + } + + static func skippedLabel(_ outcome: CleanOutcome) -> String { + "\(outcome.skipped.count) skipped" + } + + static let skippedNote = " protected or ignored" + + static func failedLabel(_ outcome: CleanOutcome) -> String { + "\(outcome.failed.count) failed" + } + static func sizeCol(_ bytes: Int64) -> String { - pad(ByteCount.human(bytes), 10) + pad(ByteCount.human(bytes), sizeColumnWidth) + } + + static func paint(_ text: String, _ style: TextStyle, _ colors: Bool) -> String { + Term.styled([Span(text, style)], colors: colors) + } + + static func coloredSizeCol(_ bytes: Int64, colors: Bool) -> String { + paint(sizeCol(bytes), SizeLevel.style(bytes: bytes, focused: false), colors) } static func pad(_ string: String, _ width: Int) -> String { diff --git a/cli/Sources/puremac/Core/ScanStatus.swift b/cli/Sources/puremac/Core/ScanStatus.swift new file mode 100644 index 0000000..68771a4 --- /dev/null +++ b/cli/Sources/puremac/Core/ScanStatus.swift @@ -0,0 +1,34 @@ +enum PurgeScanEvent: Equatable { + case root(String) + case walking(String) + case found(bytes: Int64) +} + +struct ScanStatus { + var headline: String + var folder: String? + var foundCount = 0 + var foundBytes: Int64 = 0 + + mutating func apply(_ event: PurgeScanEvent) { + switch event { + case .root(let path): + headline = "Scanning " + Render.shorten(path) + folder = nil + case .walking(let path): + folder = path + case .found(let bytes): + foundCount += 1 + foundBytes += bytes + } + } + + mutating func beginCategory(title: String, position: Int, count: Int) { + headline = "Scanning \(title), \(position) of \(count)" + } + + mutating func addFound(in category: CategoryScan) { + foundCount += category.groups.reduce(0) { $0 + $1.items.count } + foundBytes += category.allBytes + } +} diff --git a/cli/Sources/puremac/Core/Scanner.swift b/cli/Sources/puremac/Core/Scanner.swift index 3960411..d0dfeb0 100644 --- a/cli/Sources/puremac/Core/Scanner.swift +++ b/cli/Sources/puremac/Core/Scanner.swift @@ -89,7 +89,7 @@ enum DirSizer { measure(of: path).bytes } - static func measure(of path: String) -> Measurement { + static func measure(of path: String, isCancelled: () -> Bool = { false }) -> Measurement { let fm = FileManager.default if Safety.hasSymlinkComponent(path) { return Measurement(bytes: 0, status: .symlink, skippedEntries: 1) @@ -107,10 +107,10 @@ enum DirSizer { } return Measurement(bytes: bytes, status: .complete, skippedEntries: 0) } - return foundationSize(URL(fileURLWithPath: path)) + return foundationSize(URL(fileURLWithPath: path), isCancelled: isCancelled) } - private static func foundationSize(_ url: URL) -> Measurement { + private static func foundationSize(_ url: URL, isCancelled: () -> Bool) -> Measurement { var total: Int64 = 0 var skipped = 0 let keys: [URLResourceKey] = [ @@ -131,6 +131,9 @@ enum DirSizer { return Measurement(bytes: 0, status: .unavailable, skippedEntries: 1) } for case let item as URL in en { + if isCancelled() { + return Measurement(bytes: total, status: .partial, skippedEntries: skipped + 1) + } let values = try? item.resourceValues(forKeys: Set(keys)) if values?.isSymbolicLink == true { en.skipDescendants() diff --git a/cli/Sources/puremac/Core/SizeLevel.swift b/cli/Sources/puremac/Core/SizeLevel.swift new file mode 100644 index 0000000..eadc1ab --- /dev/null +++ b/cli/Sources/puremac/Core/SizeLevel.swift @@ -0,0 +1,25 @@ +import TerminalUI + +enum SizeLevel { + case small, medium, large + + init(bytes: Int64) { + switch bytes { + case ..<100_000_000: self = .small + case ..<1_000_000_000: self = .medium + default: self = .large + } + } + + var color: TerminalColor { + switch self { + case .small: return .green + case .medium: return .yellow + case .large: return .red + } + } + + static func style(bytes: Int64, focused: Bool) -> TextStyle { + focused ? TextStyle(inverse: true) : TextStyle(color: SizeLevel(bytes: bytes).color) + } +} diff --git a/cli/Sources/puremac/Core/StartConfirmation.swift b/cli/Sources/puremac/Core/StartConfirmation.swift new file mode 100644 index 0000000..6b59cce --- /dev/null +++ b/cli/Sources/puremac/Core/StartConfirmation.swift @@ -0,0 +1,82 @@ +import TerminalUI + +enum StartChoice: Equatable { + case start, back +} + +struct StartConfirmation { + let title: String + let message: String + let menuSelection: Int + + static let explore = StartConfirmation( + title: "Explore your home folder?", + message: "Measuring every folder can take a few minutes.", + menuSelection: HomeMenu.exploreIndex + ) + + static func projectCleanup(folderCount: Int) -> StartConfirmation { + StartConfirmation( + title: "Scan your project folders?", + message: "Searching \(Render.counted(folderCount, "folder")) for build artifacts can take a few minutes.", + menuSelection: HomeMenu.projectCleanupIndex + ) + } + + private static let buttons = [DialogButton("Cancel"), DialogButton("Start")] + static let startIndex = buttons.count - 1 + + private(set) var focus = ButtonFocus(count: buttons.count, index: startIndex) + + mutating func handle(_ key: TerminalKey) -> StartChoice? { + if focus.navigate(key) { return nil } + switch key { + case .enter: + return focus.index == Self.startIndex ? .start : .back + case .escape: + return .back + case .character(let character): + switch String(character).lowercased() { + case "s": return .start + case "q", "n": return .back + default: return nil + } + default: + return nil + } + } + + func acceptsKey(_ key: TerminalKey, tooSmall: Bool) -> Bool { + guard tooSmall else { return true } + if key == .escape { return true } + guard case .character(let character) = key else { return false } + return ["q", "n"].contains(String(character).lowercased()) + } + + func render(width: Int, height: Int) -> Buffer { + StatusScreen.overlay(dialog, on: HomeMenu.screen(selection: menuSelection, width: width, height: height), tooSmallExitHint: "Esc back") + } + + func ask() throws -> Bool { + let session = try TerminalSession() + defer { session.close() } + var model = self + while true { + session.draw(model.render(width: session.width, height: session.height)) + let key = session.readKey() + let tooSmall = !TooSmallScreen.fits(width: session.width, height: session.height) + guard model.acceptsKey(key, tooSmall: tooSmall) else { continue } + if let choice = model.handle(key) { return choice == .start } + } + } + + private var dialog: Dialog { + Dialog( + title: title, + message: [Span(message)], + buttons: Self.buttons, + focusedButton: focus.index, + hints: StatusScreen.choiceHints + ) + } +} diff --git a/cli/Sources/puremac/Core/StatusScreen.swift b/cli/Sources/puremac/Core/StatusScreen.swift new file mode 100644 index 0000000..a1b27ec --- /dev/null +++ b/cli/Sources/puremac/Core/StatusScreen.swift @@ -0,0 +1,56 @@ +import TerminalUI + +struct StatusRow { + let text: String + let style: TextStyle + let truncatesInMiddle: Bool + + init(_ text: String, style: TextStyle = .plain, truncatesInMiddle: Bool = false) { + self.text = text + self.style = style + self.truncatesInMiddle = truncatesInMiddle + } +} + +enum StatusScreen { + static let warningTitle = TextStyle(color: .yellow, bold: true) + static let cancellingText = "Cancelling…" + static let scanHint = KeyHint("Esc/q", "cancel") + static let warningBorder = TextStyle(color: .yellow) + static let choiceHints = KeyHints([KeyHint("Enter", "select"), KeyHint("Esc", "back")]) + + static func render(title: String, rows: [StatusRow], hint: KeyHint, width: Int, height: Int) -> Buffer { + let hints = KeyHints([hint]) + guard TooSmallScreen.fits(width: width, height: height) else { + return TooSmallScreen.render(title: title, exitHint: "\(hint.key) \(hint.action)", width: width, height: height) + } + var buffer = Buffer(width: width, height: height) + let window = Window(title: title) + let content = window.render(in: buffer.area, buffer: &buffer) + let areas = content.splitRows([.length(rows.count), .fill, .length(1), .length(hints.rows(width: content.width).count)]) + let (header, divider, footer) = (areas[0], areas[2], areas[3]) + + for (offset, row) in rows.enumerated() { + buffer.write( + Term.truncate(row.text, to: header.width, middle: row.truncatesInMiddle), + x: header.x, y: header.y + offset, style: row.style + ) + } + window.renderDivider(atRow: divider.y, in: buffer.area, buffer: &buffer) + hints.render(in: footer, buffer: &buffer) + return buffer + } + + static func overlay(_ dialog: Dialog, on background: Buffer, tooSmallExitHint: String? = nil) -> Buffer { + guard TooSmallScreen.fits(width: background.width, height: background.height) else { + guard let tooSmallExitHint else { return background } + return TooSmallScreen.render( + title: "PureMac", exitHint: tooSmallExitHint, width: background.width, height: background.height + ) + } + var buffer = background + buffer.fade() + dialog.render(in: buffer.area, buffer: &buffer) + return buffer + } +} diff --git a/cli/Sources/puremac/Core/Stores.swift b/cli/Sources/puremac/Core/Stores.swift index 73844f9..c64e677 100644 --- a/cli/Sources/puremac/Core/Stores.swift +++ b/cli/Sources/puremac/Core/Stores.swift @@ -16,9 +16,28 @@ enum AppPaths { static var ignoreFile: URL { configDir.appendingPathComponent("ignore") } static var configFile: URL { configDir.appendingPathComponent("config.json") } + static var projectsFile: URL { configDir.appendingPathComponent("projects") } - static func ensureDir() throws { - try FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true) + static func ensureDefaults( + configFile: URL = configFile, + ignoreFile: URL = ignoreFile, + projectsFile: URL = projectsFile + ) throws { + let results = [ + Result { try createIfMissing(configFile, try ConfigStore.defaultFileData()) }, + Result { try createIfMissing(ignoreFile, Data()) }, + Result { try createIfMissing(projectsFile, PathListFile.contents(Purger.defaultRoots)) }, + ] + for case .failure(let error) in results { throw error } + } + + private static func createIfMissing(_ file: URL, _ data: Data) throws { + guard !FileManager.default.fileExists(atPath: file.path) else { return } + try FileManager.default.createDirectory( + at: file.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try data.write(to: file, options: .withoutOverwriting) } } @@ -26,6 +45,8 @@ enum StoreError: LocalizedError { case invalidConfigFile(String) case unknownConfigKey(String) case invalidConfigValue(key: String, value: String) + case invalidProjectRoot(String) + case invalidProjectsFile(String) case invalidIgnorePath case invalidIgnoreFile(String) @@ -37,6 +58,10 @@ enum StoreError: LocalizedError { return "Unknown setting '\(key)'." case .invalidConfigValue(let key, let value): return "Invalid value '\(value)' for \(key). Use a whole number from 1 to 3650." + case .invalidProjectRoot(let path): + return "Project folders must be absolute paths: \(path)" + case .invalidProjectsFile(let path): + return "Project folders file contains an invalid or relative path: \(path)" case .invalidIgnorePath: return "Protected paths cannot be empty, the filesystem root, or contain newline or NUL characters." case .invalidIgnoreFile(let path): @@ -45,6 +70,112 @@ enum StoreError: LocalizedError { } } +enum PathListFile { + static func standardized(_ path: String, relativeTo currentDirectoryPath: String, allowRelative: Bool) -> String? { + guard !path.isEmpty, !containsControl(path) else { return nil } + let expanded = (path as NSString).expandingTildeInPath + let absolute: String + if expanded.hasPrefix("/") { + absolute = expanded + } else { + guard allowRelative else { return nil } + absolute = currentDirectoryPath + "/" + expanded + } + let standardized = lexicalStandardizedAbsolutePath(absolute) + guard standardized != "/", !containsControl(standardized) else { return nil } + return standardized + } + + static func load(from fileURL: URL, invalid: Error) throws -> [String]? { + guard FileManager.default.fileExists(atPath: fileURL.path) else { return nil } + let contents = try String(contentsOf: fileURL, encoding: .utf8) + var seen = Set() + var roots: [String] = [] + for line in contents.split(separator: "\n") { + guard let root = standardized(String(line), relativeTo: "/", allowRelative: false) else { throw invalid } + if seen.insert(root).inserted { roots.append(root) } + } + return roots + } + + static func adding(_ root: String, to roots: [String], save: ([String]) throws -> Void) throws -> [String]? { + guard !roots.contains(root) else { return nil } + let updated = roots + [root] + try save(updated) + return updated + } + + static func removing(_ root: String, from roots: [String], save: ([String]) throws -> Void) throws -> [String]? { + guard roots.contains(root) else { return nil } + let updated = roots.filter { $0 != root } + try save(updated) + return updated + } + + static func contents(_ roots: [String]) -> Data { + Data((roots.isEmpty ? "" : roots.joined(separator: "\n") + "\n").utf8) + } + + static func save(_ roots: [String], to fileURL: URL) throws { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try contents(roots).write(to: fileURL, options: .atomic) + } + + private static func containsControl(_ text: String) -> Bool { + text.unicodeScalars.contains { $0.value == 0 || CharacterSet.newlines.contains($0) } + } + + private static func lexicalStandardizedAbsolutePath(_ path: String) -> String { + var components: [Substring] = [] + for component in path.split(separator: "/") { + if component == "." { continue } + if component == ".." { + if !components.isEmpty { components.removeLast() } + continue + } + components.append(component) + } + return "/" + components.joined(separator: "/") + } +} + +struct ProjectFoldersStore { + private(set) var roots: [String] + private let fileURL: URL + + init(fileURL: URL) throws { + self.fileURL = fileURL + roots = try PathListFile.load(from: fileURL, invalid: StoreError.invalidProjectsFile(fileURL.path)) + ?? Purger.defaultRoots + } + + mutating func add(_ path: String) throws -> Bool { + let standardized = try Self.standardized(path) + guard let updated = try PathListFile.adding(standardized, to: roots, save: { try PathListFile.save($0, to: fileURL) }) + else { return false } + roots = updated + return true + } + + mutating func remove(_ path: String) throws -> Bool { + let standardized = try Self.standardized(path) + guard let updated = try PathListFile.removing(standardized, from: roots, save: { try PathListFile.save($0, to: fileURL) }) + else { return false } + roots = updated + return true + } + + private static func standardized(_ path: String) throws -> String { + guard let standardized = PathListFile.standardized(path, relativeTo: "/", allowRelative: false) else { + throw StoreError.invalidProjectRoot(path) + } + return standardized + } +} + struct IgnoreStore { private(set) var roots: [String] private let fileURL: URL @@ -83,9 +214,7 @@ struct IgnoreStore { relativeTo: currentDirectoryPath, allowRelative: true ) - guard !roots.contains(standardized) else { return false } - let updated = roots + [standardized] - try save(updated) + guard let updated = try PathListFile.adding(standardized, to: roots, save: save) else { return false } roots = updated return true } @@ -96,9 +225,7 @@ struct IgnoreStore { relativeTo: currentDirectoryPath, allowRelative: true ) - guard roots.contains(standardized) else { return false } - let updated = roots.filter { $0 != standardized } - try save(updated) + guard let updated = try PathListFile.removing(standardized, from: roots, save: save) else { return false } roots = updated return true } @@ -108,73 +235,22 @@ struct IgnoreStore { relativeTo currentDirectoryPath: String, allowRelative: Bool ) throws -> String { - guard !path.isEmpty, - !path.unicodeScalars.contains(where: { - $0.value == 0 || CharacterSet.newlines.contains($0) - }) - else { - throw StoreError.invalidIgnorePath - } - let expanded = (path as NSString).expandingTildeInPath - let absolute: String - if expanded.hasPrefix("/") { - absolute = expanded - } else { - guard allowRelative else { - throw StoreError.invalidIgnorePath - } - absolute = currentDirectoryPath + "/" + expanded - } - let standardized = try lexicalStandardizedAbsolutePath(absolute) - guard standardized.hasPrefix("/"), standardized != "/", - !standardized.unicodeScalars.contains(where: { - $0.value == 0 || CharacterSet.newlines.contains($0) - }) - else { + guard let standardized = PathListFile.standardized( + path, + relativeTo: currentDirectoryPath, + allowRelative: allowRelative + ) else { throw StoreError.invalidIgnorePath } return standardized } - private static func lexicalStandardizedAbsolutePath(_ path: String) throws -> String { - guard path.hasPrefix("/") else { - throw StoreError.invalidIgnorePath - } - var components: [Substring] = [] - for component in path.split(separator: "/") { - if component == "." { continue } - if component == ".." { - if !components.isEmpty { components.removeLast() } - continue - } - components.append(component) - } - return "/" + components.joined(separator: "/") - } - private static func load(from fileURL: URL) throws -> [String] { - guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] } - let contents = try String(contentsOf: fileURL, encoding: .utf8) - var seen = Set() - var roots: [String] = [] - for line in contents.split(separator: "\n").map(String.init) { - do { - let root = try standardized(line, relativeTo: "/", allowRelative: false) - if seen.insert(root).inserted { roots.append(root) } - } catch { - throw StoreError.invalidIgnoreFile(fileURL.path) - } - } - return roots + try PathListFile.load(from: fileURL, invalid: StoreError.invalidIgnoreFile(fileURL.path)) ?? [] } private func save(_ roots: [String]) throws { - try FileManager.default.createDirectory( - at: fileURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - let contents = roots.isEmpty ? "" : roots.sorted().joined(separator: "\n") + "\n" - try contents.write(to: fileURL, atomically: true, encoding: .utf8) + try PathListFile.save(roots.sorted(), to: fileURL) } } @@ -182,13 +258,11 @@ struct ConfigStore { private(set) var values: [String: String] private let fileURL: URL - static let knownKeys = ["purge.older_than_days"] - static let defaultValues = ["purge.older_than_days": "7"] + static let daysKey = "purge.older_than_days" + static let knownKeys = [daysKey] + static let defaultDays = 7 - init() { - fileURL = AppPaths.configFile - values = (try? Self.load(from: fileURL)) ?? [:] - } + var olderThanDays: Int { values[Self.daysKey].flatMap { Int($0) } ?? Self.defaultDays } init(fileURL: URL) throws { self.fileURL = fileURL @@ -199,10 +273,7 @@ struct ConfigStore { let normalized = try Self.normalizedValue(value, for: key) var updated = values updated[key] = normalized - let data = try JSONSerialization.data( - withJSONObject: updated, - options: [.prettyPrinted, .sortedKeys] - ) + let data = try Self.encode(updated) try FileManager.default.createDirectory( at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true @@ -222,6 +293,14 @@ struct ConfigStore { return String(days) } + static func defaultFileData() throws -> Data { + try encode([daysKey: String(defaultDays)]) + } + + private static func encode(_ values: [String: String]) throws -> Data { + try JSONSerialization.data(withJSONObject: values, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) + } + private static func load(from fileURL: URL) throws -> [String: String] { guard FileManager.default.fileExists(atPath: fileURL.path) else { return [:] } let data = try Data(contentsOf: fileURL) @@ -230,9 +309,8 @@ struct ConfigStore { else { throw StoreError.invalidConfigFile(fileURL.path) } - for (key, value) in values { - _ = try normalizedValue(value, for: key) - } - return values + var normalized: [String: String] = [:] + for (key, value) in values { normalized[key] = try normalizedValue(value, for: key) } + return normalized } } diff --git a/cli/Sources/puremac/Core/SystemInfo.swift b/cli/Sources/puremac/Core/SystemInfo.swift index 6487350..f5d9fd2 100644 --- a/cli/Sources/puremac/Core/SystemInfo.swift +++ b/cli/Sources/puremac/Core/SystemInfo.swift @@ -1,5 +1,6 @@ import Darwin import Foundation +import TerminalUI struct MemoryStatus: Equatable { let total: Int64 @@ -8,6 +9,39 @@ struct MemoryStatus: Equatable { let active: Int64 let wired: Int64 let compressed: Int64 + + var usedFraction: Double { + total > 0 ? Double(total - free) / Double(total) : 0 + } +} + +enum MemoryPressure: Equatable { + case normal, warning, critical + + init?(level: Int) { + switch level { + case 1: self = .normal + case 2: self = .warning + case 4: self = .critical + default: return nil + } + } + + var label: String { + switch self { + case .normal: return "Normal" + case .warning: return "Warning" + case .critical: return "Critical" + } + } + + var color: TerminalColor { + switch self { + case .normal: return .green + case .warning: return .yellow + case .critical: return .red + } + } } struct VolumeStatus: Equatable { @@ -18,6 +52,59 @@ struct VolumeStatus: Equatable { var systemManagedCapacity: Int64 { max(0, availableForImportantUsage - availableNow) } + + var freeFraction: Double { + total > 0 ? Double(availableNow) / Double(total) : 1 + } + + var usedFraction: Double { 1 - freeFraction } + + var freeColor: TerminalColor { + switch freeFraction { + case ..<0.1: return .red + case ..<0.2: return .yellow + default: return .green + } + } +} + +struct MachineInfo: Equatable { + let chip: String? + let cores: String? + let memory: Int64? + let macOS: String + let model: String? + let uptime: TimeInterval +} + +struct Sysctl { + let string: (String) -> String? + let int: (String) -> Int64? + + static let system = Sysctl(string: readString, int: readInt) + + private static func readString(_ name: String) -> String? { + var size = 0 + guard sysctlbyname(name, nil, &size, nil, 0) == 0, size > 0 else { return nil } + var buffer = [CChar](repeating: 0, count: size) + guard sysctlbyname(name, &buffer, &size, nil, 0) == 0 else { return nil } + return String(cString: buffer) + } + + private static func readInt(_ name: String) -> Int64? { + var size = 0 + guard sysctlbyname(name, nil, &size, nil, 0) == 0 else { return nil } + switch size { + case MemoryLayout.size: + var value: Int32 = 0 + return sysctlbyname(name, &value, &size, nil, 0) == 0 ? Int64(value) : nil + case MemoryLayout.size: + var value: Int64 = 0 + return sysctlbyname(name, &value, &size, nil, 0) == 0 ? value : nil + default: + return nil + } + } } enum SystemInfoError: LocalizedError { @@ -62,6 +149,10 @@ enum SystemInfo { ) } + static func memoryPressure() -> MemoryPressure? { + Sysctl.system.int("kern.memorystatus_vm_pressure_level").flatMap { MemoryPressure(level: Int($0)) } + } + static func volumeStatus(_ path: String) throws -> VolumeStatus { let url = URL(fileURLWithPath: path) let values = try url.resourceValues(forKeys: [ @@ -82,4 +173,28 @@ enum SystemInfo { ) } + static func machine( + sysctl: Sysctl = .system, + version: OperatingSystemVersion = ProcessInfo.processInfo.operatingSystemVersion, + uptime: TimeInterval = ProcessInfo.processInfo.systemUptime + ) -> MachineInfo { + var macOS = "\(version.majorVersion).\(version.minorVersion)" + if version.patchVersion > 0 { macOS += ".\(version.patchVersion)" } + if let build = sysctl.string("kern.osversion") { macOS += " (\(build))" } + return MachineInfo( + chip: sysctl.string("machdep.cpu.brand_string"), + cores: coreSummary(sysctl), + memory: sysctl.int("hw.memsize"), + macOS: macOS, + model: sysctl.string("hw.model"), + uptime: uptime + ) + } + + private static func coreSummary(_ sysctl: Sysctl) -> String? { + if let performance = sysctl.int("hw.perflevel0.physicalcpu"), let efficiency = sysctl.int("hw.perflevel1.physicalcpu") { + return "\(Render.counted(Int(performance + efficiency), "core")) (\(performance) performance, \(efficiency) efficiency)" + } + return sysctl.int("hw.ncpu").map { Render.counted(Int($0), "core") } + } } diff --git a/cli/Sources/puremac/Core/SystemStatusScreen.swift b/cli/Sources/puremac/Core/SystemStatusScreen.swift new file mode 100644 index 0000000..e4fa9f0 --- /dev/null +++ b/cli/Sources/puremac/Core/SystemStatusScreen.swift @@ -0,0 +1,188 @@ +import TerminalUI + +struct SystemStatusScreen { + struct Panel { + var section: InfoSection + var window: Window + + init(section: InfoSection, info: Span? = nil) { + self.section = section + window = Window(title: section.title ?? "", info: info?.text) + if let style = info?.style { window.infoStyle = style } + } + } + + static let title = "System status" + private static let hints = KeyHints([KeyHint("↑↓", "scroll"), KeyHint("Esc", "back")]) + private static let sideBySideWidth = 80 + private static let frame = Window(title: "") + + private static func inset(width: Int) -> Rect { + frame.contentRect(in: Rect(x: 0, y: 0, width: width, height: 3)) + } + + private static var chromeRows: Int { 3 - inset(width: 3).height } + + var thisMac: Panel + var memory: Panel + var storage: Panel + + static func make( + machine: MachineInfo, + memory: Result, + pressure: MemoryPressure?, + volume: Result + ) -> SystemStatusScreen { + SystemStatusScreen( + thisMac: Panel(section: Optimize.thisMacSection(machine)), + memory: panel( + Optimize.memoryTitle, memory, + section: { Optimize.memorySection($0, pressure: pressure) }, + info: { _ in pressure.map { Span($0.label, TextStyle(color: $0.color, bold: true)) } } + ), + storage: panel( + Optimize.storageTitle, volume, + section: Optimize.storageSection, + info: { Span("\(Int($0.freeFraction * 100))% free", TextStyle(color: $0.freeColor, bold: true)) } + ) + ) + } + + static func current() -> SystemStatusScreen { + make( + machine: SystemInfo.machine(), + memory: Result { try SystemInfo.memoryStatus() }, + pressure: SystemInfo.memoryPressure(), + volume: Result { try SystemInfo.volumeStatus("/") } + ) + } + + static func run(in session: TerminalSession) { + let screen = current() + var scroll = InfoSheetScroll() + while true { + session.draw(screen.render(width: session.width, height: session.height, offset: scroll.offset)) + if scroll.handle(session.readKey(), maxOffset: screen.maxOffset(width: session.width, height: session.height)) { return } + } + } + + func render(width: Int, height: Int, offset: Int) -> Buffer { + guard TooSmallScreen.fits(width: width, height: height) else { + return TooSmallScreen.render(title: Self.title, exitHint: "Esc back", width: width, height: height) + } + var buffer = Buffer(width: width, height: height) + let layout = layout(width: width, height: height) + let start = max(0, min(offset, layout.maxOffset)) + for (panel, rect, lines) in layout.panels { + let shifted = Rect(x: rect.x, y: rect.y - start, width: rect.width, height: rect.height) + let content = panel.window.render(in: shifted, buffer: &buffer) + for (index, spans) in lines.enumerated() { + InfoSheet.write(spans, in: Rect(x: content.x, y: content.y + index, width: content.width, height: 1), buffer: &buffer) + } + } + let inset = Self.inset(width: width) + for (index, spans) in layout.footnote.enumerated() { + buffer.write(spans, x: inset.x, y: layout.footnoteTop + index - start, maxWidth: inset.width) + } + buffer.clear(layout.footer) + if layout.scrolls { + Scrollbar(total: layout.total, visible: layout.viewport, offset: start) + .render(column: width - 1, rows: 0.. Int { + guard TooSmallScreen.fits(width: width, height: height) else { return 0 } + return layout(width: width, height: height).maxOffset + } + + private struct Layout { + var panels: [(panel: Panel, rect: Rect, lines: [[Span]])] + var footnote: [[Span]] + var footnoteTop: Int + var viewport: Int + var footer: Rect + var scrolls: Bool + var total: Int { footnoteTop + footnote.count } + var maxOffset: Int { max(0, total - viewport) } + } + + private func layout(width: Int, height: Int) -> Layout { + let full = layout(width: width, height: height, panelWidth: width) + return full.maxOffset > 0 ? layout(width: width, height: height, panelWidth: width - 1) : full + } + + private func layout(width: Int, height: Int, panelWidth: Int) -> Layout { + let footerRows = Self.hints.rows(width: Self.inset(width: width).width).count + let viewport = max(0, height - footerRows) + let sideBySide = width >= Self.sideBySideWidth + let groups = sideBySide ? [[thisMac], [memory, storage]] : [[thisMac], [memory], [storage]] + var panels: [(panel: Panel, rect: Rect, lines: [[Span]])] = [] + var top = 0 + for group in groups { + let columns = Rect(x: 0, y: top, width: panelWidth, height: 1).splitColumns(group.map { _ in .fill }) + let lines = zip(group, columns).map { panel, column in + let width = panel.window.contentRect(in: Rect(x: column.x, y: top, width: column.width, height: Self.chromeRows + 1)).width + return panel.section.rows.flatMap { InfoSheet.lines($0, labelWidth: panel.section.labelWidth, indent: "", wrappingTo: width) } + } + let panelHeight = (lines.map(\.count).max() ?? 0) + Self.chromeRows + for (index, panel) in group.enumerated() { + let column = columns[index] + panels.append((panel, Rect(x: column.x, y: top, width: column.width, height: panelHeight), lines[index])) + } + top += panelHeight + } + let note = [Span(Optimize.statusFootnote, TextStyle(dim: true))] + return Layout( + panels: panels, + footnote: Term.wordWrap(note, width: max(1, Self.inset(width: panelWidth).width)), + footnoteTop: top, + viewport: viewport, + footer: Rect(x: 0, y: viewport, width: width, height: footerRows), + scrolls: panelWidth < width + ) + } + + private static func panel( + _ title: String, + _ result: Result, + section: (Value) -> InfoSection, + info: (Value) -> Span? + ) -> Panel { + switch result { + case .success(let value): + return Panel(section: section(value), info: info(value)) + case .failure(let error): + let message = Span(error.localizedDescription, TextStyle(color: .red)) + return Panel(section: InfoSection(title: title, rows: [.message([message])])) + } + } +} + +extension InfoSheet { + static func write(_ spans: [Span], in rect: Rect, buffer: inout Buffer) { + guard Term.displayWidth(spans) > rect.width else { + buffer.write(spans, x: rect.x, y: rect.y, maxWidth: rect.width) + return + } + let used = buffer.write(spans, x: rect.x, y: rect.y, maxWidth: rect.width - 1) + buffer.write("…", x: rect.x + used, y: rect.y) + } +} + +struct InfoSheetScroll { + private(set) var offset = 0 + + mutating func handle(_ key: TerminalKey, maxOffset: Int) -> Bool { + switch key { + case .up: offset = max(0, min(offset, maxOffset) - 1) + case .down: offset = min(maxOffset, offset + 1) + case .escape, .enter, .left, .backspace: return true + case .character(let character): return character.lowercased() == "q" + default: break + } + return false + } +} diff --git a/cli/Sources/puremac/Core/Term.swift b/cli/Sources/puremac/Core/Term.swift deleted file mode 100644 index ad5ba82..0000000 --- a/cli/Sources/puremac/Core/Term.swift +++ /dev/null @@ -1,139 +0,0 @@ -import Darwin -import Foundation - -enum Term { - private static let localeConfigured: Void = { - setlocale(LC_CTYPE, "") - }() - - static var colorEnabled: Bool { - ProcessInfo.processInfo.environment["NO_COLOR"] == nil - && ProcessInfo.processInfo.environment["TERM"] != "dumb" - && isatty(fileno(stdout)) == 1 - } - - static func style(_ string: String, _ codes: String) -> String { - colorEnabled ? "\u{1B}[\(codes)m\(string)\u{1B}[0m" : string - } - - static func bold(_ string: String) -> String { style(string, "1") } - static func dim(_ string: String) -> String { style(string, "2") } - static func red(_ string: String) -> String { style(string, "31") } - static func green(_ string: String) -> String { style(string, "32") } - static func yellow(_ string: String) -> String { style(string, "33") } - static func blue(_ string: String) -> String { style(string, "34") } - static func cyan(_ string: String) -> String { style(string, "36") } - static func inverse(_ string: String) -> String { style(string, "7") } - - static func err(_ string: String) { - FileHandle.standardError.write(Data((string + "\n").utf8)) - } - - static func confirm(_ prompt: String, default defaultValue: Bool = false) -> Bool { - guard isatty(fileno(stdin)) == 1 else { return defaultValue } - let hint = defaultValue ? "[Y/n]" : "[y/N]" - print("\(sanitize(prompt)) \(hint) ", terminator: "") - guard let line = readLine() else { return defaultValue } - let answer = line.trimmingCharacters(in: .whitespaces).lowercased() - if answer.isEmpty { return defaultValue } - return answer == "y" || answer == "yes" - } - - static func bar(fraction: Double, width: Int = 24) -> String { - let safeWidth = max(0, width) - let clamped = max(0, min(1, fraction)) - let filled = Int((Double(safeWidth) * clamped).rounded()) - return String(repeating: "█", count: filled) - + String(repeating: "░", count: safeWidth - filled) - } - - static func sanitize(_ string: String) -> String { - var output = "" - output.reserveCapacity(string.count) - for scalar in string.unicodeScalars { - switch scalar.value { - case 0x20...0x7E, 0xA0...0x10FFFF: - if scalar.value >= 0x7F && scalar.value <= 0x9F { - output.append("�") - } else { - output.unicodeScalars.append(scalar) - } - case 0x09: - output.append(" ") - default: - output.append("�") - } - } - return output - } - - static func displayWidth(_ string: String) -> Int { - _ = localeConfigured - var width = 0 - var escapeState = 0 - for scalar in string.unicodeScalars { - if escapeState == 1 { - escapeState = scalar.value == 0x5B ? 2 : 0 - continue - } - if escapeState == 2 { - if scalar.value >= 0x40 && scalar.value <= 0x7E { - escapeState = 0 - } - continue - } - if scalar.value == 0x1B { - escapeState = 1 - continue - } - let scalarWidth = wcwidth(wchar_t(scalar.value)) - width += scalarWidth > 0 ? Int(scalarWidth) : 0 - } - return width - } - - static func pad(_ string: String, to width: Int) -> String { - let missing = max(0, width - displayWidth(string)) - return string + String(repeating: " ", count: missing) - } - - static func truncate(_ string: String, to width: Int, middle: Bool = false) -> String { - let safe = sanitize(string) - guard width > 0 else { return "" } - guard displayWidth(safe) > width else { return safe } - guard width > 1 else { return "…" } - - if middle { - let leftWidth = (width - 1) / 2 - let rightWidth = width - 1 - leftWidth - return prefix(safe, width: leftWidth) + "…" + suffix(safe, width: rightWidth) - } - return prefix(safe, width: width - 1) + "…" - } - - private static func prefix(_ string: String, width: Int) -> String { - var output = "" - var used = 0 - for character in string { - let value = String(character) - let characterWidth = displayWidth(value) - guard used + characterWidth <= width else { break } - output.append(character) - used += characterWidth - } - return output - } - - private static func suffix(_ string: String, width: Int) -> String { - var characters: [Character] = [] - var used = 0 - for character in string.reversed() { - let value = String(character) - let characterWidth = displayWidth(value) - guard used + characterWidth <= width else { break } - characters.append(character) - used += characterWidth - } - return String(characters.reversed()) - } -} diff --git a/cli/Sources/puremac/Core/TerminalMenu.swift b/cli/Sources/puremac/Core/TerminalMenu.swift index 1eb06b2..d849f71 100644 --- a/cli/Sources/puremac/Core/TerminalMenu.swift +++ b/cli/Sources/puremac/Core/TerminalMenu.swift @@ -1,8 +1,14 @@ import Foundation +import TerminalUI struct TerminalMenuModel { let optionCount: Int - private(set) var selection = 0 + private(set) var selection: Int + + init(optionCount: Int, selection: Int = 0) { + self.optionCount = optionCount + self.selection = min(max(0, optionCount - 1), max(0, selection)) + } mutating func move(_ delta: Int) { guard optionCount > 0 else { return } @@ -29,16 +35,17 @@ enum TerminalMenu { static func choose( title: String, subtitle: String, - options: [(title: String, detail: String)] + options: [(title: String, detail: String)], + initialSelection: Int = 0 ) throws -> Int? { guard !options.isEmpty else { return nil } let session = try TerminalSession() defer { session.close() } - var model = TerminalMenuModel(optionCount: options.count) + var model = TerminalMenuModel(optionCount: options.count, selection: initialSelection) while true { session.draw( - lines: render( + render( title: title, subtitle: subtitle, options: options, @@ -86,50 +93,68 @@ enum TerminalMenu { selection: Int, width: Int, height: Int - ) -> [String] { - let safeWidth = max(1, width) - let safeHeight = max(1, height) - if safeWidth < 40 || safeHeight < 12 { - let compact = [ - Term.bold(Term.cyan(Term.truncate("PureMac", to: safeWidth))), - Term.truncate("Terminal too small", to: safeWidth), - Term.dim(Term.truncate("Resize to at least 40 x 12", to: safeWidth)), - "", - Term.dim(Term.truncate("Esc/q cancel", to: safeWidth)) - ] - return Array(compact.prefix(safeHeight)) + ) -> Buffer { + guard TooSmallScreen.fits(width: width, height: height) else { + return TooSmallScreen.render(title: "PureMac", exitHint: "Esc/q cancel", width: width, height: height) } - let safeSelection = min(max(0, selection), max(0, options.count - 1)) - var lines = [ - Term.dim("PUREMAC / CHOOSE A TOOL"), - Term.bold(Term.cyan(Term.truncate(title, to: safeWidth))), - Term.dim(Term.truncate(subtitle, to: safeWidth)), - "" - ] - - let footerHeight = 3 - let rowHeight = 2 - let visibleCount = max(1, (safeHeight - lines.count - footerHeight) / rowHeight) - let start = max(0, min(max(0, options.count - visibleCount), safeSelection - visibleCount / 2)) - let visible = options.enumerated().dropFirst(start).prefix(visibleCount) + var buffer = Buffer(width: width, height: height) + let window = Window(title: title) + let content = window.render(in: buffer.area, buffer: &buffer) + let hints = KeyHints([ + KeyHint("↑↓", "move"), + KeyHint("PgUp/PgDn", "page"), + KeyHint("1-9", "open"), + KeyHint("Enter", "open"), + KeyHint("Esc/q", "cancel") + ]) + let areas = content.splitRows([.length(2), .fill, .length(1), .length(hints.rows(width: content.width).count)]) + let (header, list, divider, footer) = (areas[0], areas[1], areas[2], areas[3]) - for (index, option) in visible { - let focused = index == safeSelection - let number = Term.pad("\(index + 1).", to: 4) - let titleWidth = max(1, safeWidth - 6) - let titleLine = "\(focused ? "›" : " ") \(number)\(Term.truncate(option.title, to: titleWidth))" - let detailLine = " " + Term.truncate(option.detail, to: max(1, safeWidth - 6)) - lines.append(focused ? Term.inverse(titleLine) : titleLine) - lines.append(focused ? Term.bold(detailLine) : Term.dim(detailLine)) - } + buffer.write(Term.truncate(subtitle, to: header.width), x: header.x, y: header.y, style: TextStyle(dim: true)) + window.renderDivider(atRow: divider.y, in: buffer.area, buffer: &buffer) + hints.render(in: footer, buffer: &buffer) - while lines.count < safeHeight - footerHeight { - lines.append("") + let safeSelection = min(max(0, selection), max(0, options.count - 1)) + let rowHeight = 2 + let visibleCount = max(1, list.height / rowHeight) + let start = Scrollbar.centeredOffset(total: options.count, visible: visibleCount, focus: safeSelection) + for (index, option) in options.enumerated().dropFirst(start).prefix(visibleCount) { + let rect = Rect(x: list.x, y: list.y + (index - start) * rowHeight, width: list.width, height: rowHeight) + renderOption(option, number: index + 1, focused: index == safeSelection, in: rect, buffer: &buffer) } + Scrollbar(total: options.count, visible: visibleCount, offset: start) + .render(column: width - 1, rows: list.y..<(list.y + visibleCount * rowHeight), buffer: &buffer) + return buffer + } - lines.append(Term.dim(String(repeating: "─", count: safeWidth))) - lines.append(Term.dim("↑↓ move PgUp/PgDn page 1-9 open")) - lines.append(Term.dim("Enter open Esc/q cancel")) - return Array(lines.prefix(safeHeight)) + private static func renderOption( + _ option: (title: String, detail: String), + number: Int, + focused: Bool, + in rect: Rect, + buffer: inout Buffer + ) { + let indent = 6 + let textWidth = max(1, rect.width - indent) + let rowStyle = TextStyle(inverse: focused) + let numberText = "\(number)." + buffer.write( + [ + FocusPointer.span(focused: focused), + Span(" ", rowStyle), + Span(Term.pad(numberText, to: 4), focused ? rowStyle : TextStyle(color: .cyan)), + Span(Term.pad(Term.truncate(option.title, to: textWidth), to: textWidth), rowStyle) + ], + x: rect.x, + y: rect.y, + maxWidth: rect.width + ) + buffer.write( + Term.truncate(option.detail, to: textWidth), + x: rect.x + indent, + y: rect.y + 1, + style: TextStyle(dim: !focused), + maxWidth: textWidth + ) } } diff --git a/cli/Sources/puremac/Core/TooSmallScreen.swift b/cli/Sources/puremac/Core/TooSmallScreen.swift new file mode 100644 index 0000000..9068568 --- /dev/null +++ b/cli/Sources/puremac/Core/TooSmallScreen.swift @@ -0,0 +1,25 @@ +import TerminalUI + +enum TooSmallScreen { + static let minimumWidth = 40 + static let minimumHeight = 12 + + static func fits(width: Int, height: Int) -> Bool { + width >= minimumWidth && height >= minimumHeight + } + + static func render(title: String, exitHint: String, width: Int, height: Int) -> Buffer { + var buffer = Buffer(width: width, height: height) + let rows: [(String, TextStyle)] = [ + (title, TextStyle(color: .cyan, bold: true)), + ("Terminal too small", .plain), + ("Resize to at least \(minimumWidth) x \(minimumHeight)", TextStyle(dim: true)), + ("", .plain), + (exitHint, TextStyle(dim: true)) + ] + for (row, (text, style)) in rows.enumerated() { + buffer.write(Term.truncate(text, to: width), x: 0, y: row, style: style) + } + return buffer + } +} diff --git a/cli/Sources/puremac/PureMac.swift b/cli/Sources/puremac/PureMac.swift index e755d75..d7c10a5 100644 --- a/cli/Sources/puremac/PureMac.swift +++ b/cli/Sources/puremac/PureMac.swift @@ -1,5 +1,6 @@ import ArgumentParser import Foundation +import TerminalUI let puremacVersion = "1.1.1" @@ -22,25 +23,69 @@ struct PureMac: ParsableCommand { print(Self.helpMessage()) return } - try HomeMenu.run() + var setupError: Error? + do { try AppPaths.ensureDefaults() } catch { setupError = error } + try HomeMenu.run(setupError: setupError) } } enum HomeMenu { - static func run() throws { - let options: [(title: String, detail: String)] = [ - ("Clean caches", "Choose tools and individual paths to remove"), - ("Project cleanup", "Review dependencies and generated build artifacts"), - ("Explore storage", "Browse folders and find what takes up space"), - ("System status", "Inspect memory and available disk space"), - ("Preferences", "View cleanup settings and configuration paths"), - ("Protected paths", "View folders excluded from cleanup") - ] - while let choice = try TerminalMenu.choose( - title: "PureMac", - subtitle: "Your Mac, with room to work. / \(puremacVersion)", - options: options - ) { + static let title = "PureMac" + static let subtitle = "Your Mac, with room to work. / \(puremacVersion)" + static let projectCleanupIndex = 1 + static let exploreIndex = 2 + static let statusIndex = 3 + static let preferencesIndex = 4 + static let protectedPathsIndex = 5 + static let options: [(title: String, detail: String)] = [ + ("Clean caches", "Choose tools and individual paths to remove"), + ("Project cleanup", "Review dependencies and generated build artifacts"), + ("Explore storage", "Browse folders and find what takes up space"), + ("System status", "Inspect memory and available disk space"), + ("Preferences", "Change cleanup age, project folders, and protection"), + ("Protected paths", "Choose paths excluded from cleanup") + ] + + static func screen(selection: Int, width: Int, height: Int) -> Buffer { + TerminalMenu.render( + title: title, subtitle: subtitle, options: options, selection: selection, width: width, height: height + ) + } + + enum ProjectCleanupEntry { + case confirm(StartConfirmation) + case notice(String) + } + + static func projectCleanupEntry(folderCount: () throws -> Int) -> ProjectCleanupEntry { + do { + let count = try folderCount() + return count > 0 ? .confirm(.projectCleanup(folderCount: count)) : .notice(Purge.noFoldersMessage) + } catch { + return .notice(error.localizedDescription) + } + } + + static func projectCleanupNotice(message: String, width: Int, height: Int) -> Buffer { + CleanupFlow.infoScreen( + title: options[projectCleanupIndex].title, + message: message, + on: screen(selection: projectCleanupIndex, width: width, height: height) + ) + } + + private static func showProjectCleanupNotice(_ message: String) throws { + let session = try TerminalSession() + defer { session.close() } + CleanupFlow.showInfo(on: session) { + projectCleanupNotice(message: message, width: session.width, height: session.height) + } + } + + static func run(setupError: Error?) throws { + var focus = 0 + while let choice = try TerminalMenu.choose(title: title, subtitle: subtitle, options: options, initialSelection: focus) { + focus = choice switch choice { case 0: guard let category = try TerminalMenu.choose( @@ -56,15 +101,33 @@ enum HomeMenu { ) else { continue } let arguments = [[], ["dev"], ["junk"], ["ai"], ["trash"]] try Clean.parse(arguments[category]).run() - case 1: try Purge.parse([]).run() - case 2: try Analyze.parse([]).run() - case 3: try Optimize.parse([]).run() - case 4: try Config.parse([]).run() - case 5: try Ignore.List.parse([]).run() + case projectCleanupIndex: + let entry = projectCleanupEntry { try ProjectFoldersStore(fileURL: AppPaths.projectsFile).roots.count } + switch entry { + case .notice(let message): + try showProjectCleanupNotice(message) + continue + case .confirm(let confirmation): + guard try confirmation.ask() else { continue } + } + try Purge.parse([]).run() + case exploreIndex: + guard try StartConfirmation.explore.ask() else { continue } + try Analyze.parse([]).run() + case statusIndex: + let session = try TerminalSession() + defer { session.close() } + SystemStatusScreen.run(in: session) + case preferencesIndex: + let session = try TerminalSession() + defer { session.close() } + PreferencesScreen.run(setupError: setupError, in: session) + case protectedPathsIndex: + let session = try TerminalSession() + defer { session.close() } + PathListEditor.run(.protectedPaths, in: session) default: return } - print(Term.dim("\nPress Return to return to PureMac."), terminator: " ") - guard readLine() != nil else { return } } } } diff --git a/cli/Tests/TerminalUITests/BackgroundWorkTests.swift b/cli/Tests/TerminalUITests/BackgroundWorkTests.swift new file mode 100644 index 0000000..928bcb5 --- /dev/null +++ b/cli/Tests/TerminalUITests/BackgroundWorkTests.swift @@ -0,0 +1,156 @@ +import XCTest +@testable import TerminalUI + +final class BackgroundWorkTests: XCTestCase { + private let wait: DispatchTimeInterval = .seconds(5) + + func testUpdateMutatesTheStoredValueInPlace() { + let progress = WorkProgress([1, 2]) + progress.update { $0.append(3) } + XCTAssertEqual(progress.value, [1, 2, 3]) + } + + func testRedrawsUntilWorkFinishesAndTickIncreases() { + let release = DispatchSemaphore(value: 0) + var ticks: [Int] = [] + + let result = BackgroundWork.run( + progress: 0, + draw: { tick, _ in + ticks.append(tick) + if ticks.count == 5 { release.signal() } + }, + readKey: { _ in nil }, + onKey: { _, _ in }, + work: { _, _ in + _ = release.wait(timeout: .now() + self.wait) + return "done" + } + ) + + XCTAssertEqual(result, "done") + XCTAssertGreaterThanOrEqual(ticks.count, 5) + XCTAssertEqual(ticks, Array(0.. (frame: Rect, buffer: Buffer, rows: [String]) { + var buffer = Buffer(width: area.width, height: area.height) + if let fill { + for row in 0.. Dialog { + Dialog(title: "Hi", message: [Span(message)], buttons: buttons) + } + + func testMaxContentWidthLeavesTheMarginAroundTheFrame() { + XCTAssertEqual(Dialog.maxContentWidth(in: Rect(x: 0, y: 0, width: 80, height: 24)), 70) + XCTAssertEqual(Dialog.maxContentWidth(in: Rect(x: 0, y: 0, width: 4, height: 24)), 1) + } + + func testFrameIsCenteredAndSizedToContent() { + let dialog = simple() + XCTAssertEqual(dialog.frame(in: area), Rect(x: 14, y: 2, width: 12, height: 7)) + XCTAssertEqual(render(dialog, in: area).rows, [ + "╭─ Hi ─────╮", + "│ │", + "│ Hello │", + "│ │", + "│ [ OK ] │", + "│ │", + "╰──────────╯" + ]) + } + + func testFrameIsCenteredInOffsetArea() { + let frame = simple().frame(in: Rect(x: 10, y: 2, width: 40, height: 12)) + XCTAssertEqual(frame, Rect(x: 24, y: 4, width: 12, height: 7)) + } + + func testContentWidthIsWidestOfMessageButtonsAndHints() { + let wideButtons = simple(buttons: [DialogButton("Cancel"), DialogButton("Remove")]) + XCTAssertEqual(wideButtons.frame(in: area).width, 22 + 6) + var withHints = simple() + withHints.hints = KeyHints([KeyHint("Esc", "back"), KeyHint("Enter", "select")]) + XCTAssertEqual(withHints.frame(in: area).width, Term.displayWidth("Esc back Enter select") + 6) + } + + func testWidthIsCappedToMarginsAndMessageWraps() { + let dialog = simple(message: String(repeating: "word ", count: 30)) + let result = render(dialog, in: area) + XCTAssertEqual(result.frame.width, 36) + XCTAssertEqual(result.frame.x, 2) + XCTAssertTrue(result.rows[2].contains("word word")) + XCTAssertTrue(result.rows.allSatisfy { Term.displayWidth($0) == 36 }) + } + + func testMessageWrapsOnSpacesAndEachParagraphOnItsOwn() { + let dialog = Dialog( + title: "T", + message: [Span("aaa bbb ccc ddd"), Span("ee")], + buttons: [] + ) + let result = render(dialog, in: Rect(x: 0, y: 0, width: 20, height: 12)) + XCTAssertEqual(result.frame.width, 16) + XCTAssertEqual(Array(result.rows[2...4]), ["│ aaa bbb │", "│ ccc ddd │", "│ ee │"]) + } + + func testLongWordIsHardBrokenByDisplayWidth() { + let dialog = Dialog(title: "T", message: [Span("abcdefghij 界界界")], buttons: []) + let result = render(dialog, in: Rect(x: 0, y: 0, width: 15, height: 12)) + let rows = result.buffer.plainLines().dropFirst(result.frame.y + 2).prefix(4).map { String($0.dropFirst(2).dropLast(2)) } + XCTAssertEqual(rows, ["│ abcde │", "│ fghij │", "│ 界界 │", "│ 界 │"]) + } + + func testDropsBlankRowFirstWhenHeightIsShort() { + let message = (1...7).map { Span("line \($0)") } + let dialog = Dialog(title: "T", message: message, buttons: [DialogButton("OK")]) + let result = render(dialog, in: area) + XCTAssertEqual(result.frame.height, 10) + XCTAssertEqual(result.rows.count, 10) + XCTAssertTrue(result.rows[7].contains("line 7")) + XCTAssertTrue(result.rows[8].contains("[ OK ]")) + } + + func testDropsTrailingMessageRowsAfterBlankRow() { + let message = (1...10).map { Span("line \($0)") } + var dialog = Dialog(title: "T", message: message, buttons: [DialogButton("OK")]) + dialog.hints = KeyHints([KeyHint("Esc", "back")]) + let result = render(dialog, in: area) + XCTAssertEqual(result.frame, Rect(x: result.frame.x, y: 1, width: result.frame.width, height: 10)) + XCTAssertTrue(result.rows[6].contains("line 6")) + XCTAssertFalse(result.rows.joined().contains("line 7")) + XCTAssertTrue(result.rows[7].contains("[ OK ]")) + XCTAssertTrue(result.rows[8].contains("Esc back")) + } + + private func buttonStyles(focused: Int?, buttons: [DialogButton]) -> (start: Int, row: Int, buffer: Buffer) { + var dialog = simple(buttons: buttons) + dialog.focusedButton = focused + let result = render(dialog, in: area) + let rowIndex = result.frame.y + 4 + let line = result.buffer.plainLines()[rowIndex] + let start = line.distance(from: line.startIndex, to: line.range(of: "Cancel")!.lowerBound) - 2 + return (start, rowIndex, result.buffer) + } + + private let both = [DialogButton("Cancel"), DialogButton("Remove", role: .destructive)] + + func testButtonsAreTwoSpacesApart() { + let result = render(simple(buttons: both), in: area) + XCTAssertEqual(result.rows[4], "│ [ Cancel ] [ Remove ] │") + } + + func testFocusedButtonIsWrittenWithPointersAtTheSameWidth() { + var dialog = simple(buttons: both) + dialog.focusedButton = 0 + XCTAssertEqual(render(dialog, in: area).rows[4], "│ › Cancel ‹ [ Remove ] │") + dialog.focusedButton = 1 + XCTAssertEqual(render(dialog, in: area).rows[4], "│ [ Cancel ] › Remove ‹ │") + } + + func testFocusedNormalButtonIsInverse() { + let found = buttonStyles(focused: 0, buttons: both) + for column in found.start..<(found.start + 10) { + XCTAssertEqual(found.buffer[column, found.row].style, TextStyle(inverse: true)) + } + XCTAssertEqual(found.buffer[found.start + 12, found.row].style, TextStyle(color: .red)) + XCTAssertEqual(found.buffer[found.start + 10, found.row].style, .plain) + } + + func testFocusedDestructiveButtonIsRedAndInverse() { + let found = buttonStyles(focused: 1, buttons: both) + XCTAssertEqual(found.buffer[found.start, found.row].style, .plain) + for column in (found.start + 12)..<(found.start + 22) { + XCTAssertEqual(found.buffer[column, found.row].style, TextStyle(color: .red, inverse: true)) + } + } + + func testNoFocusLeavesButtonsUnhighlighted() { + let found = buttonStyles(focused: nil, buttons: both) + XCTAssertEqual(found.buffer[found.start, found.row].style, .plain) + XCTAssertEqual(found.buffer[found.start + 12, found.row].style, TextStyle(color: .red)) + } + + func testMessageButtonsAndHintsAreCenteredWithBlankRowsBetweenGroups() { + var dialog = simple() + dialog.hints = KeyHints([KeyHint("Esc", "back")]) + XCTAssertEqual(render(dialog, in: area).rows, [ + "╭─ Hi ───────╮", + "│ │", + "│ Hello │", + "│ │", + "│ [ OK ] │", + "│ │", + "│ Esc back │", + "│ │", + "╰────────────╯" + ]) + } + + func testHintRowNarrowerThanContentIsCentered() { + var dialog = Dialog(title: "Hi", message: [Span("Hello, world!")]) + dialog.hints = KeyHints([KeyHint("Esc", "back")]) + XCTAssertEqual(render(dialog, in: area).rows, [ + "╭─ Hi ────────────╮", + "│ │", + "│ Hello, world! │", + "│ │", + "│ Esc back │", + "│ │", + "╰─────────────────╯" + ]) + } + + func testWrappedHintRowsAreCenteredIndependently() { + var dialog = Dialog(title: "T", message: [Span(String(repeating: "x", count: 40))]) + dialog.hints = KeyHints([ + KeyHint("Enter", "select"), KeyHint("Esc", "back"), KeyHint("Tab", "next"), KeyHint("Space", "toggle") + ]) + let rows = render(dialog, in: Rect(x: 0, y: 0, width: 42, height: 14)).rows + XCTAssertEqual(Array(rows.suffix(4)), [ + "│ Enter select Esc back Tab next │", + "│ Space toggle │", + "│" + String(repeating: " ", count: 36) + "│", + "╰" + String(repeating: "─", count: 36) + "╯" + ]) + } + + func testBlankRowsAppearOnlyBetweenGroupsThatExist() { + var messageAndHints = Dialog(title: "T", message: [Span("Hello")]) + messageAndHints.hints = KeyHints([KeyHint("Esc", "back")]) + XCTAssertEqual(render(messageAndHints, in: area).rows, [ + "╭─ T ────────╮", + "│ │", + "│ Hello │", + "│ │", + "│ Esc back │", + "│ │", + "╰────────────╯" + ]) + XCTAssertEqual(render(Dialog(title: "Title", buttons: [DialogButton("OK")]), in: area).rows, [ + "╭─ Title ───╮", + "│ │", + "│ [ OK ] │", + "│ │", + "╰───────────╯" + ]) + } + + func testDialogWithoutMessageHasABlankRowAboveItsFirstGroup() { + var dialog = Dialog(title: "T", buttons: [DialogButton("OK")]) + dialog.hints = KeyHints([KeyHint("Esc", "back")]) + XCTAssertEqual(render(dialog, in: area).rows, [ + "╭─ T ────────╮", + "│ │", + "│ [ OK ] │", + "│ │", + "│ Esc back │", + "│ │", + "╰────────────╯" + ]) + XCTAssertEqual(render(dialog, in: Rect(x: 0, y: 0, width: 40, height: 8)).rows, [ + "╭─ T ────────╮", + "│ │", + "│ [ OK ] │", + "│ │", + "│ Esc back │", + "╰────────────╯" + ]) + XCTAssertEqual(render(dialog, in: Rect(x: 0, y: 0, width: 40, height: 6)).rows, [ + "╭─ T ────────╮", + "│ [ OK ] │", + "│ Esc back │", + "╰────────────╯" + ]) + } + + func testDropsBlankRowsBottomUpBeforeAnyMessageRow() { + var dialog = Dialog(title: "T", message: (1...4).map { Span("line \($0)") }, buttons: [DialogButton("OK")]) + dialog.hints = KeyHints([KeyHint("Esc", "back")]) + let droppedTwoBlankRows = render(dialog, in: area).rows + XCTAssertEqual(droppedTwoBlankRows.count, 10) + XCTAssertTrue(droppedTwoBlankRows[1].allSatisfy { $0 == " " || $0 == "│" }) + XCTAssertTrue(droppedTwoBlankRows[2].contains("line 1")) + XCTAssertTrue(droppedTwoBlankRows[5].contains("line 4")) + XCTAssertTrue(droppedTwoBlankRows[6].allSatisfy { $0 == " " || $0 == "│" }) + XCTAssertTrue(droppedTwoBlankRows[7].contains("[ OK ]")) + XCTAssertTrue(droppedTwoBlankRows[8].contains("Esc back")) + + dialog.message = (1...5).map { Span("line \($0)") } + let droppedThreeBlankRows = render(dialog, in: area).rows + XCTAssertEqual(droppedThreeBlankRows.count, 10) + XCTAssertTrue(droppedThreeBlankRows[1].allSatisfy { $0 == " " || $0 == "│" }) + XCTAssertTrue(droppedThreeBlankRows[6].contains("line 5")) + XCTAssertTrue(droppedThreeBlankRows[7].contains("[ OK ]")) + XCTAssertTrue(droppedThreeBlankRows[8].contains("Esc back")) + } + + func testBottomBlankRowIsDroppedBeforeTheOtherBlankRows() { + var dialog = Dialog(title: "T", message: [Span("a"), Span("b")], buttons: [DialogButton("OK")]) + dialog.hints = KeyHints([KeyHint("Esc", "back")]) + let fits = render(dialog, in: Rect(x: 0, y: 0, width: 40, height: 12)) + XCTAssertEqual(fits.rows.count, 10) + XCTAssertTrue(fits.rows[8].allSatisfy { $0 == " " || $0 == "│" }) + let tight = render(dialog, in: Rect(x: 0, y: 0, width: 40, height: 11)).rows + XCTAssertEqual(tight.count, 9) + XCTAssertTrue(tight[1].allSatisfy { $0 == " " || $0 == "│" }) + XCTAssertTrue(tight[4].allSatisfy { $0 == " " || $0 == "│" }) + XCTAssertTrue(tight[6].allSatisfy { $0 == " " || $0 == "│" }) + XCTAssertTrue(tight[7].contains("Esc back")) + } + + func testLeadingMessageAlignmentKeepsRowsInTheFirstContentColumn() { + var dialog = Dialog( + title: "T", + message: [Span("a"), Span("longer text")], + buttons: [DialogButton("OK")] + ) + dialog.messageAlignment = .leading + XCTAssertEqual(render(dialog, in: area).rows, [ + "╭─ T ───────────╮", + "│ │", + "│ a │", + "│ longer text │", + "│ │", + "│ [ OK ] │", + "│ │", + "╰───────────────╯" + ]) + } + + func testCenteredMessageWrapsBalancedAndLeadingKeepsGreedyWrap() { + let text = "Searching 1 folder for build artifacts can take a few minutes." + let wide = Rect(x: 0, y: 0, width: 64, height: 18) + var dialog = Dialog(title: "T", message: [Span(text)], buttons: []) + let centered = render(dialog, in: wide).rows.map { $0.trimmingCharacters(in: CharacterSet(charactersIn: "│ ")) } + XCTAssertTrue(centered.contains("Searching 1 folder for build")) + XCTAssertTrue(centered.contains("artifacts can take a few minutes.")) + + dialog.messageAlignment = .leading + let leading = render(dialog, in: wide).rows.map { $0.trimmingCharacters(in: CharacterSet(charactersIn: "│ ")) } + XCTAssertTrue(leading.contains("Searching 1 folder for build artifacts can take a few")) + XCTAssertTrue(leading.contains("minutes.")) + } + + func testCenterMessageAlignmentIsTheDefault() { + let dialog = Dialog(title: "T", message: [Span("a"), Span("longer text")]) + XCTAssertEqual(render(dialog, in: area).rows[2], "│ a │") + } + + func testBorderAndTitleStylesApply() { + var dialog = simple() + dialog.borderStyle = TextStyle(color: .yellow) + dialog.titleStyle = TextStyle(color: .yellow, bold: true) + let result = render(dialog, in: area) + XCTAssertEqual(result.buffer[result.frame.x, result.frame.y].style, TextStyle(color: .yellow)) + XCTAssertEqual(result.buffer[result.frame.x + 3, result.frame.y].style, TextStyle(color: .yellow, bold: true)) + XCTAssertEqual(Dialog(title: "T").borderStyle, .plain) + XCTAssertEqual(Dialog(title: "T").titleStyle, TextStyle(bold: true)) + } + + func testRenderClearsBackgroundInsideFrameOnly() { + let result = render(simple(), in: area, fill: "x") + let lines = result.buffer.plainLines() + XCTAssertEqual(lines[1], String(repeating: "x", count: 40)) + XCTAssertEqual(lines[4], String(repeating: "x", count: 14) + "│ Hello │" + String(repeating: "x", count: 14)) + XCTAssertTrue(lines.allSatisfy { Term.displayWidth($0) == 40 }) + XCTAssertTrue(result.buffer.lines(colors: true).allSatisfy { Term.displayWidth($0) == 40 }) + } + + func testBlankBackgroundStyleIsResetInsideFrame() { + var buffer = Buffer(width: 40, height: 12) + buffer.fade() + simple().render(in: buffer.area, buffer: &buffer) + XCTAssertEqual(buffer[16, 4].style, .plain) + XCTAssertEqual(buffer[0, 0].style, TextStyle(dim: true)) + } + + func testStatusDialogWithoutButtonsOrHintsHasBlankRowsAroundMessage() { + let dialog = Dialog(title: "Working", message: [Span("Removing 2 items…")]) + XCTAssertEqual(render(dialog, in: area).rows, [ + "╭─ Working ───────────╮", + "│ │", + "│ Removing 2 items… │", + "│ │", + "╰─────────────────────╯" + ]) + } + + func testContentWidthFitsTitle() { + let shortMessage = Dialog(title: "Cleanup complete", message: [Span("1 KB removed")]) + XCTAssertEqual(render(shortMessage, in: area).rows, [ + "╭─ Cleanup complete ───╮", + "│ │", + "│ 1 KB removed │", + "│ │", + "╰──────────────────────╯" + ]) + let titleOnly = Dialog(title: "Removing 2 items…") + XCTAssertEqual(render(titleOnly, in: area).rows, [ + "╭─ Removing 2 items… ───╮", + "╰───────────────────────╯" + ]) + } + + func testMaxTitleWidthLeavesRoomForTheBorderPadding() { + XCTAssertEqual(Dialog.maxTitleWidth(in: Rect(x: 0, y: 0, width: 80, height: 24)), 68) + XCTAssertEqual(Dialog.maxTitleWidth(in: Rect(x: 0, y: 0, width: 4, height: 24)), 1) + } + + func testButtonsThatDoNotFitSideBySideStackOnePerRowCentered() throws { + let dialog = Dialog( + title: "Hi", + message: [Span("Sure?")], + buttons: [DialogButton("Cancel"), DialogButton("Remove protection", role: .destructive)] + ) + let result = render(dialog, in: area) + let content = (x: 3, width: result.frame.width - 6) + let cancelRow = 4 + for (offset, title) in ["[ Cancel ]", "[ Remove protection ]"].enumerated() { + let row = result.rows[cancelRow + offset] + let start = try XCTUnwrap(row.range(of: title), row).lowerBound + XCTAssertEqual(row.distance(from: row.startIndex, to: start), content.x + (content.width - title.count) / 2, row) + } + } +} diff --git a/cli/Tests/TerminalUITests/GaugeTests.swift b/cli/Tests/TerminalUITests/GaugeTests.swift new file mode 100644 index 0000000..456bb42 --- /dev/null +++ b/cli/Tests/TerminalUITests/GaugeTests.swift @@ -0,0 +1,41 @@ +import XCTest +@testable import TerminalUI + +final class GaugeTests: XCTestCase { + private let dim = TextStyle(dim: true) + + func testFilledAndRestSpans() { + XCTAssertEqual(Gauge(fraction: 0.5).spans(width: 10), [Span("█████"), Span("░░░░░", dim)]) + } + + func testFilledPartTakesGivenStyleAndRestStaysDim() { + let green = TextStyle(color: .green) + XCTAssertEqual(Gauge(fraction: 0.5).spans(width: 10, filled: green), [Span("█████", green), Span("░░░░░", dim)]) + XCTAssertEqual(Gauge(fraction: 1).spans(width: 4, filled: green), [Span("████", green)]) + XCTAssertEqual(Gauge(fraction: 0).spans(width: 4, filled: green), [Span("░░░░", dim)]) + } + + func testEmptyAndFullHaveSingleSpan() { + XCTAssertEqual(Gauge(fraction: 0).spans(width: 4), [Span("░░░░", dim)]) + XCTAssertEqual(Gauge(fraction: 1).spans(width: 4), [Span("████")]) + } + + func testFractionIsClamped() { + XCTAssertEqual(Gauge(fraction: 2).spans(width: 3), [Span("███")]) + XCTAssertEqual(Gauge(fraction: -1).spans(width: 3), [Span("░░░", dim)]) + } + + func testNonPositiveWidthHasNoSpans() { + XCTAssertEqual(Gauge(fraction: 0.5).spans(width: 0), []) + XCTAssertEqual(Gauge(fraction: 0.5).spans(width: -3), []) + } + + func testRoundingOfFilledCells() { + let cases: [(Double, Int, String)] = [ + (0.25, 8, "██░░░░░░"), (0.5, 5, "███░░"), (0.49, 12, "██████░░░░░░"), (0.99, 24, "████████████████████████") + ] + for (fraction, width, expected) in cases { + XCTAssertEqual(Gauge(fraction: fraction).spans(width: width).map(\.text).joined(), expected) + } + } +} diff --git a/cli/Tests/TerminalUITests/KeyHintsTests.swift b/cli/Tests/TerminalUITests/KeyHintsTests.swift new file mode 100644 index 0000000..a9a025f --- /dev/null +++ b/cli/Tests/TerminalUITests/KeyHintsTests.swift @@ -0,0 +1,74 @@ +import XCTest +@testable import TerminalUI + +final class KeyHintsTests: XCTestCase { + private let menu = KeyHints([ + KeyHint("↑↓", "move"), + KeyHint("PgUp/PgDn", "page"), + KeyHint("1-9", "open"), + KeyHint("Enter", "open"), + KeyHint("Esc/q", "cancel") + ]) + + private func text(_ rows: [[Span]]) -> [String] { + rows.map { $0.map(\.text).joined() } + } + + func testNoHintsNoRows() { + XCTAssertEqual(KeyHints([]).rows(width: 40), []) + } + + func testWrapsAtThirtySixColumns() { + XCTAssertEqual(text(menu.rows(width: 36)), [ + "↑↓ move PgUp/PgDn page 1-9 open", + "Enter open Esc/q cancel" + ]) + } + + func testStaysOnOneRowAtSeventySixColumns() { + XCTAssertEqual(text(menu.rows(width: 76)), ["↑↓ move PgUp/PgDn page 1-9 open Enter open Esc/q cancel"]) + } + + func testRowFitsExactly() { + XCTAssertEqual(text(KeyHints([KeyHint("a", "b"), KeyHint("c", "d")]).rows(width: 8)), ["a b c d"]) + XCTAssertEqual(text(KeyHints([KeyHint("a", "b"), KeyHint("c", "d")]).rows(width: 7)), ["a b", "c d"]) + } + + func testHintWiderThanWidthSitsAlone() { + let hints = KeyHints([KeyHint("Enter", "open"), KeyHint("q", "x"), KeyHint("w", "y")]) + XCTAssertEqual(text(hints.rows(width: 5)), ["Enter open", "q x", "w y"]) + } + + func testKeyIsPlainAndActionIsDim() { + let row = KeyHints([KeyHint("a", "b"), KeyHint("c", "d")]).rows(width: 20)[0] + XCTAssertEqual(row, [ + Span("a"), Span(" b", TextStyle(dim: true)), + Span(" "), + Span("c"), Span(" d", TextStyle(dim: true)) + ]) + } + + func testRenderWritesRowsIntoRect() { + var buffer = Buffer(width: 12, height: 5) + KeyHints([KeyHint("a", "b"), KeyHint("c", "d"), KeyHint("e", "f")]) + .render(in: Rect(x: 2, y: 1, width: 8, height: 2), buffer: &buffer) + XCTAssertEqual(buffer.plainLines(), [ + " ", + " a b c d ", + " e f ", + " ", + " " + ]) + XCTAssertEqual(buffer[2, 1].style, .plain) + XCTAssertEqual(buffer[3, 1].style, TextStyle(dim: true)) + } + + func testRenderDropsRowsBeyondRectHeightAndClipsWidth() { + var buffer = Buffer(width: 10, height: 4) + KeyHints([KeyHint("Enter", "open"), KeyHint("q", "x"), KeyHint("w", "y")]) + .render(in: Rect(x: 0, y: 0, width: 5, height: 2), buffer: &buffer) + XCTAssertEqual(buffer.plainLines()[0], "Enter ") + XCTAssertEqual(buffer.plainLines()[1], "q x ") + XCTAssertEqual(buffer.plainLines()[2], " ") + } +} diff --git a/cli/Tests/TerminalUITests/KeyInputTests.swift b/cli/Tests/TerminalUITests/KeyInputTests.swift new file mode 100644 index 0000000..13cdaa2 --- /dev/null +++ b/cli/Tests/TerminalUITests/KeyInputTests.swift @@ -0,0 +1,68 @@ +import Darwin +import XCTest +@testable import TerminalUI + +final class KeyInputTests: XCTestCase { + private func makePipe() -> (read: Int32, write: Int32) { + var descriptors: [Int32] = [0, 0] + XCTAssertEqual(pipe(&descriptors), 0) + return (descriptors[0], descriptors[1]) + } + + func testReadsByteWhileOpen() { + let (read, write) = makePipe() + defer { close(read); close(write) } + _ = Darwin.write(write, "a", 1) + let input = KeyInput(descriptor: read) + + XCTAssertEqual(input.readByte(), 0x61) + XCTAssertFalse(input.isClosed) + } + + func testEndOfFileMarksInputClosed() { + let (read, write) = makePipe() + defer { close(read) } + close(write) + let input = KeyInput(descriptor: read) + + XCTAssertNil(input.readByte()) + XCTAssertTrue(input.isClosed) + } + + func testReadErrorMarksInputClosed() { + let input = KeyInput(descriptor: -1) + + XCTAssertNil(input.readByte()) + XCTAssertTrue(input.isClosed) + } + + private func elapsedMilliseconds(_ body: () -> Void) -> UInt64 { + let start = DispatchTime.now().uptimeNanoseconds + body() + return (DispatchTime.now().uptimeNanoseconds - start) / 1_000_000 + } + + func testClosedInputWaitsOutTheTimeoutAndReportsReady() { + let (read, write) = makePipe() + defer { close(read) } + close(write) + let input = KeyInput(descriptor: read) + _ = input.readByte() + var ready = false + + let elapsed = elapsedMilliseconds { ready = input.wait(milliseconds: 60) } + + XCTAssertTrue(ready) + XCTAssertGreaterThanOrEqual(elapsed, 50) + } + + func testOpenInputWaitReportsPendingByteOnly() { + let (read, write) = makePipe() + defer { close(read); close(write) } + let input = KeyInput(descriptor: read) + + XCTAssertFalse(input.wait(milliseconds: 0)) + _ = Darwin.write(write, "a", 1) + XCTAssertTrue(input.wait(milliseconds: 0)) + } +} diff --git a/cli/Tests/TerminalUITests/ScrollbarTests.swift b/cli/Tests/TerminalUITests/ScrollbarTests.swift new file mode 100644 index 0000000..7e3d633 --- /dev/null +++ b/cli/Tests/TerminalUITests/ScrollbarTests.swift @@ -0,0 +1,47 @@ +import XCTest +@testable import TerminalUI + +final class ScrollbarTests: XCTestCase { + func testCenteredOffsetCentersFocusAndClamps() { + XCTAssertEqual(Scrollbar.centeredOffset(total: 30, visible: 4, focus: 17), 15) + XCTAssertEqual(Scrollbar.centeredOffset(total: 30, visible: 4, focus: 0), 0) + XCTAssertEqual(Scrollbar.centeredOffset(total: 30, visible: 4, focus: 29), 26) + XCTAssertEqual(Scrollbar.centeredOffset(total: 3, visible: 4, focus: 2), 0) + } + + func testNoThumbWhenEverythingFitsOrTrackIsEmpty() { + XCTAssertNil(Scrollbar(total: 5, visible: 5, offset: 0).thumb(trackHeight: 5)) + XCTAssertNil(Scrollbar(total: 3, visible: 5, offset: 0).thumb(trackHeight: 5)) + XCTAssertNil(Scrollbar(total: 10, visible: 5, offset: 0).thumb(trackHeight: 0)) + } + + func testThumbAtTopMiddleAndBottom() { + XCTAssertEqual(Scrollbar(total: 100, visible: 10, offset: 0).thumb(trackHeight: 10), 0..<1) + XCTAssertEqual(Scrollbar(total: 100, visible: 10, offset: 45).thumb(trackHeight: 10), 5..<6) + XCTAssertEqual(Scrollbar(total: 100, visible: 10, offset: 90).thumb(trackHeight: 10), 9..<10) + XCTAssertEqual(Scrollbar(total: 20, visible: 10, offset: 5).thumb(trackHeight: 8), 2..<6) + XCTAssertEqual(Scrollbar(total: 20, visible: 10, offset: 10).thumb(trackHeight: 8), 4..<8) + } + + func testOffsetIsClamped() { + XCTAssertEqual(Scrollbar(total: 20, visible: 10, offset: 99).thumb(trackHeight: 8), 4..<8) + XCTAssertEqual(Scrollbar(total: 20, visible: 10, offset: -4).thumb(trackHeight: 8), 0..<4) + } + + func testThumbIsAtLeastOneRow() { + XCTAssertEqual(Scrollbar(total: 1000, visible: 1, offset: 0).thumb(trackHeight: 5), 0..<1) + } + + func testRenderDrawsThumbOverTrackRows() { + var buffer = Buffer(width: 3, height: 8) + Scrollbar(total: 8, visible: 4, offset: 0).render(column: 2, rows: 2..<6, buffer: &buffer, style: TextStyle(dim: true)) + XCTAssertEqual(buffer.plainLines().map { $0.suffix(1) }, [" ", " ", "┃", "┃", " ", " ", " ", " "]) + XCTAssertEqual(buffer[2, 2].style, TextStyle(dim: true)) + } + + func testRenderDrawsNothingWhenEverythingFits() { + var buffer = Buffer(width: 3, height: 4) + Scrollbar(total: 2, visible: 4, offset: 0).render(column: 2, rows: 0..<4, buffer: &buffer) + XCTAssertEqual(buffer, Buffer(width: 3, height: 4)) + } +} diff --git a/cli/Tests/TerminalUITests/SpinnerTests.swift b/cli/Tests/TerminalUITests/SpinnerTests.swift new file mode 100644 index 0000000..93ccff7 --- /dev/null +++ b/cli/Tests/TerminalUITests/SpinnerTests.swift @@ -0,0 +1,13 @@ +import XCTest +@testable import TerminalUI + +final class SpinnerTests: XCTestCase { + func testFramesCycleThroughBrailleSequence() { + let frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏".map(String.init) + for tick in 0..`", cyan), Span(".", dim)] + XCTAssertEqual(Term.wordWrap(spans, width: 24), [ + [Span("Add one with ", dim), Span("`puremac", cyan)], + [Span("ignore add `", cyan), Span(".", dim)], + ]) + } + + func testSpanWordWrapDropsTheSpaceAtALineBreakWhateverItsStyle() { + let dim = TextStyle(dim: true) + XCTAssertEqual( + Term.wordWrap([Span("one ", dim), Span("two", .plain)], width: 3), + [[Span("one", dim)], [Span("two")]] + ) + } + + func testSpanWordWrapJoinsAWordSplitAcrossSpans() { + let bold = TextStyle(bold: true) + XCTAssertEqual( + Term.wordWrap([Span("ab", bold), Span("cd ef")], width: 5), + [[Span("ab", bold), Span("cd")], [Span("ef")]] + ) + } + + func testSpanWordWrapHardBreaksALongWordKeepingItsStyle() { + let cyan = TextStyle(color: .cyan) + XCTAssertEqual( + Term.wordWrap([Span("ab ", .plain), Span("abcdefg", cyan)], width: 3), + [[Span("ab")], [Span("abc", cyan)], [Span("def", cyan)], [Span("g", cyan)]] + ) + } + + func testSpanWordWrapOfNothingIsOneEmptyRow() { + XCTAssertEqual(Term.wordWrap([Span("")], width: 10), [[]]) + XCTAssertEqual(Term.wordWrap([], width: 10), [[]]) + } + + func testStyledJoinsSpanTextAndAddsCodesOnlyWithColors() { + let dim = TextStyle(dim: true) + let spans = [Span("a", dim), Span("b", dim), Span("c")] + XCTAssertEqual(Term.styled(spans, colors: false), "abc") + XCTAssertEqual(Term.styled(spans, colors: true), "\u{1B}[0m\u{1B}[2mab\u{1B}[0mc") + XCTAssertEqual(Term.styled([], colors: true), "") + } + + func testStyledKeepsZeroWidthCharactersAndSanitizesControls() { + XCTAssertEqual(Term.styled([Span("a\u{200B}b\u{07}")], colors: false), "a\u{200B}b\u{FFFD}") + } +} diff --git a/cli/Tests/TerminalUITests/TerminalSessionTests.swift b/cli/Tests/TerminalUITests/TerminalSessionTests.swift new file mode 100644 index 0000000..6c359e5 --- /dev/null +++ b/cli/Tests/TerminalUITests/TerminalSessionTests.swift @@ -0,0 +1,25 @@ +import XCTest +@testable import TerminalUI + +final class TerminalSessionTests: XCTestCase { + private let esc = "\u{1B}" + + func testFrameClearsBelowOnlyWhenFrameIsShorterThanScreen() { + XCTAssertTrue(TerminalSession.frame(lines: ["a", "b"], height: 3).hasSuffix("\(esc)[J")) + XCTAssertFalse(TerminalSession.frame(lines: ["a", "b", "c"], height: 3).hasSuffix("\(esc)[J")) + XCTAssertTrue(TerminalSession.frame(lines: ["a", "b", "c", "d"], height: 3).contains("c")) + XCTAssertFalse(TerminalSession.frame(lines: ["a", "b", "c", "d"], height: 3).contains("d")) + } + + func testSimpleBytesDecodeToKeys() { + XCTAssertEqual(TerminalSession.key(forSimpleByte: 0x09), .tab) + XCTAssertEqual(TerminalSession.key(forSimpleByte: 0x0D), .enter) + XCTAssertEqual(TerminalSession.key(forSimpleByte: 0x0A), .enter) + XCTAssertEqual(TerminalSession.key(forSimpleByte: 0x20), .space) + XCTAssertEqual(TerminalSession.key(forSimpleByte: 0x7F), .backspace) + XCTAssertEqual(TerminalSession.key(forSimpleByte: 0x08), .backspace) + XCTAssertEqual(TerminalSession.key(forSimpleByte: 0x01), .unknown) + XCTAssertNil(TerminalSession.key(forSimpleByte: 0x1B)) + XCTAssertNil(TerminalSession.key(forSimpleByte: 0x61)) + } +} diff --git a/cli/Tests/TerminalUITests/TextInputTests.swift b/cli/Tests/TerminalUITests/TextInputTests.swift new file mode 100644 index 0000000..c98924d --- /dev/null +++ b/cli/Tests/TerminalUITests/TextInputTests.swift @@ -0,0 +1,72 @@ +import XCTest +@testable import TerminalUI + +final class TextInputTests: XCTestCase { + func testInsertAppendsAtTheEnd() { + var input = TextInput("ab") + input.insert("c") + XCTAssertEqual(input.text, "abc") + } + + func testBackspaceDropsTheLastCharacterAndIgnoresEmptyText() { + var input = TextInput("ab") + input.backspace() + XCTAssertEqual(input.text, "a") + input.backspace() + input.backspace() + XCTAssertEqual(input.text, "") + XCTAssertTrue(input.isEmpty) + } + + func testHandleEditsOnTypingKeysAndReportsWhetherTheTextChanged() { + var input = TextInput() + XCTAssertTrue(input.handle(.character("a"))) + XCTAssertTrue(input.handle(.space)) + XCTAssertTrue(input.handle(.character("é"))) + XCTAssertEqual(input.text, "a é") + XCTAssertTrue(input.handle(.backspace)) + XCTAssertTrue(input.handle(.delete)) + XCTAssertEqual(input.text, "a") + } + + func testHandleIgnoresControlCharactersAndOtherKeys() { + var input = TextInput("a") + XCTAssertFalse(input.handle(.character("\u{7F}"))) + XCTAssertFalse(input.handle(.character("\n"))) + for key in [TerminalKey.up, .down, .left, .right, .enter, .escape, .tab, .home, .unknown] { + XCTAssertFalse(input.handle(key), "\(key)") + } + XCTAssertEqual(input.text, "a") + } + + func testHandleReportsNoChangeForBackspaceOnEmptyText() { + var input = TextInput() + XCTAssertFalse(input.handle(.backspace)) + } + + func testFieldSpansShowLabelTextAndInverseCursor() { + let spans = TextInput("sec").fieldSpans(label: "Search ", width: 40) + XCTAssertEqual(spans, [ + Span("Search ", TextStyle(color: .cyan)), + Span("sec"), + Span(" ", TextStyle(inverse: true)), + ]) + } + + func testFieldSpansTruncateLongTextToLeaveRoomForTheCursor() { + let spans = TextInput(String(repeating: "x", count: 30)).fieldSpans(label: "Add ", width: 12) + XCTAssertEqual(Term.displayWidth(spans), 12) + XCTAssertEqual(spans.last, Span(" ", TextStyle(inverse: true))) + } + + func testFieldSpansKeepTheEndOfLongTextVisibleWithALeadingEllipsis() { + let spans = TextInput("/Users/someone/projects/final-name").fieldSpans(label: "Add ", width: 20) + XCTAssertEqual(Term.displayWidth(spans), 20) + XCTAssertEqual(spans[1], Span("…ts/final-name")) + } + + func testFieldSpansCountWideLabelCharactersAsTwoColumns() { + let spans = TextInput("abcdefghij").fieldSpans(label: "路径 ", width: 12) + XCTAssertEqual(Term.displayWidth(spans), 12) + } +} diff --git a/cli/Tests/TerminalUITests/WindowTests.swift b/cli/Tests/TerminalUITests/WindowTests.swift new file mode 100644 index 0000000..e07ec91 --- /dev/null +++ b/cli/Tests/TerminalUITests/WindowTests.swift @@ -0,0 +1,131 @@ +import XCTest +@testable import TerminalUI + +final class WindowTests: XCTestCase { + private func render(_ window: Window, width: Int, height: Int = 3) -> (lines: [String], buffer: Buffer, content: Rect) { + var buffer = Buffer(width: width, height: height) + let content = window.render(in: buffer.area, buffer: &buffer) + return (buffer.plainLines(), buffer, content) + } + + func testFrameWithoutInfo() { + let result = render(Window(title: "Title"), width: 16) + XCTAssertEqual(result.lines, [ + "╭─ Title ──────╮", + "│ │", + "╰──────────────╯" + ]) + } + + func testFrameWithInfo() { + let result = render(Window(title: "Title", info: "info"), width: 20) + XCTAssertEqual(result.lines[0], "╭─ Title ─── info ─╮") + } + + func testSidesAndBottomBorder() { + let result = render(Window(title: "T"), width: 7) + XCTAssertEqual(result.lines, ["╭─ T ─╮", "│ │", "╰─────╯"]) + } + + func testLongTitleIsTruncatedToSpaceLeftOfInfo() { + let result = render(Window(title: "A very long title indeed", info: "info"), width: 20) + XCTAssertEqual(result.lines[0], "╭─ A very… ─ info ─╮") + } + + func testInfoStaysWholeWhileTitleHasFourColumns() { + let result = render(Window(title: "Long title", info: "info"), width: 17) + XCTAssertEqual(result.lines[0], "╭─ Lon… ─ info ─╮") + } + + func testInfoIsDroppedWhenTitleWouldGetLessThanFourColumns() { + let result = render(Window(title: "Long title here", info: "info"), width: 16) + XCTAssertEqual(result.lines[0], "╭─ Long titl… ─╮") + } + + func testLongTitleWithoutInfoKeepsDashBeforeCorner() { + let result = render(Window(title: "PureMac cleaner"), width: 12) + XCTAssertEqual(result.lines[0], "╭─ PureM… ─╮") + } + + func testTopBorderAlwaysEndsWithDashBeforeCorner() { + XCTAssertEqual(render(Window(title: "Title"), width: 10).lines[0], "╭─ Tit… ─╮") + XCTAssertEqual(render(Window(title: "Title"), width: 11).lines[0], "╭─ Title ─╮") + } + + func testStylesPerPart() { + let window = Window(title: "Title", info: "info") + let buffer = render(window, width: 20).buffer + XCTAssertEqual(buffer[0, 0].style, TextStyle(dim: true)) + XCTAssertEqual(buffer[0, 1].style, TextStyle(dim: true)) + XCTAssertEqual(buffer[3, 0].style, TextStyle(color: .cyan, bold: true)) + XCTAssertEqual(buffer[13, 0].style, TextStyle(bold: true)) + XCTAssertEqual(buffer[19, 2].style, TextStyle(dim: true)) + XCTAssertEqual(buffer[2, 0].style, .plain) + } + + func testCustomStyles() { + let yellow = TextStyle(color: .yellow) + let window = Window(title: "T", borderStyle: yellow, titleStyle: yellow) + let buffer = render(window, width: 7).buffer + XCTAssertEqual(buffer[0, 1].style, yellow) + XCTAssertEqual(buffer[3, 0].style, yellow) + } + + func testContentRectHasOneColumnOfPadding() { + var buffer = Buffer(width: 30, height: 10) + let content = Window(title: "T").render(in: Rect(x: 2, y: 1, width: 20, height: 6), buffer: &buffer) + XCTAssertEqual(content, Rect(x: 4, y: 2, width: 16, height: 4)) + } + + func testContentRectMatchesRenderWithoutDrawing() { + var buffer = Buffer(width: 30, height: 10) + let rect = Rect(x: 2, y: 1, width: 20, height: 6) + let window = Window(title: "T") + XCTAssertEqual(window.contentRect(in: rect), window.render(in: rect, buffer: &buffer)) + XCTAssertEqual(window.contentRect(in: Rect(x: 0, y: 0, width: 2, height: 2)), Rect(x: 2, y: 1, width: 0, height: 0)) + } + + func testContentRectOfTinyRectIsEmptyAtOrigin() { + let window = Window(title: "T") + XCTAssertEqual(window.contentRect(in: Rect(x: 3, y: 4, width: 1, height: 9)), Rect(x: 3, y: 4, width: 0, height: 0)) + XCTAssertEqual(window.contentRect(in: Rect(x: 3, y: 4, width: 9, height: 1)), Rect(x: 3, y: 4, width: 0, height: 0)) + } + + func testFrameIsDrawnAtRectOrigin() { + var buffer = Buffer(width: 11, height: 5) + Window(title: "T").render(in: Rect(x: 2, y: 1, width: 7, height: 3), buffer: &buffer) + XCTAssertEqual(buffer.plainLines(), [ + " ", + " ╭─ T ─╮ ", + " │ │ ", + " ╰─────╯ ", + " " + ]) + } + + func testTinyRectDrawsNothing() { + var buffer = Buffer(width: 6, height: 4) + let blank = buffer + let window = Window(title: "T") + XCTAssertEqual(window.render(in: Rect(x: 1, y: 1, width: 1, height: 3), buffer: &buffer), Rect(x: 1, y: 1, width: 0, height: 0)) + XCTAssertEqual(window.render(in: Rect(x: 0, y: 0, width: 6, height: 1), buffer: &buffer), Rect(x: 0, y: 0, width: 0, height: 0)) + XCTAssertEqual(buffer, blank) + } + + func testSmallestFrameHasNonNegativeContent() { + var buffer = Buffer(width: 2, height: 2) + let content = Window(title: "T").render(in: buffer.area, buffer: &buffer) + XCTAssertEqual(buffer.plainLines(), ["╭╮", "╰╯"]) + XCTAssertEqual(content, Rect(x: 2, y: 1, width: 0, height: 0)) + } + + func testDividerSpansWindowWidth() { + var buffer = Buffer(width: 10, height: 5) + let window = Window(title: "T") + let rect = Rect(x: 2, y: 0, width: 6, height: 5) + window.render(in: rect, buffer: &buffer) + window.renderDivider(atRow: 2, in: rect, buffer: &buffer) + XCTAssertEqual(buffer.plainLines()[2], " ├────┤ ") + XCTAssertEqual(buffer[2, 2].style, TextStyle(dim: true)) + } +} diff --git a/cli/Tests/puremacTests/AnalyzeTests.swift b/cli/Tests/puremacTests/AnalyzeTests.swift index 349feb3..76399dc 100644 --- a/cli/Tests/puremacTests/AnalyzeTests.swift +++ b/cli/Tests/puremacTests/AnalyzeTests.swift @@ -1,6 +1,7 @@ import Darwin import XCTest @testable import puremac +import TerminalUI final class AnalyzeTests: XCTestCase { private var root: URL! @@ -16,6 +17,10 @@ final class AnalyzeTests: XCTestCase { root = nil } + private func emptyIgnore() throws -> IgnoreStore { + try IgnoreStore(fileURL: FileManager.default.temporaryDirectory.appendingPathComponent("analyze-ignore-\(UUID().uuidString)")) + } + func testDepthValidationUsesDocumentedBounds() throws { var analyze = Analyze() analyze.depth = 1 @@ -43,7 +48,7 @@ final class AnalyzeTests: XCTestCase { try Data(repeating: 0x41, count: 8_192).write(to: small) try Data(repeating: 0x42, count: 1_000_000).write(to: contents.appendingPathComponent("payload.bin")) - let snapshot = Analyze().scanFolder(root.path) + let snapshot = Analyze().scanFolder(root.path, ignore: try emptyIgnore()) XCTAssertEqual(snapshot.children.map { URL(fileURLWithPath: $0.path).lastPathComponent }, ["Example.app", "small.bin"]) let packageChild = try XCTUnwrap(snapshot.children.first) @@ -77,86 +82,463 @@ final class AnalyzeTests: XCTestCase { XCTAssertEqual(chmod(locked.path, 0), 0) defer { _ = chmod(locked.path, S_IRWXU) } - let snapshot = Analyze().scanFolder(root.path) + let snapshot = Analyze().scanFolder(root.path, ignore: try emptyIgnore()) XCTAssertTrue(snapshot.isPartial) XCTAssertGreaterThan(snapshot.skippedCount + snapshot.inaccessibleCount, 0) XCTAssertFalse(snapshot.errors.isEmpty) } - func testViewportKeepsSelectionVisibleAndWithinHeight() { - let children = (0..<20).map { - Analyze.Child( - path: "/tmp/item-\($0)", - size: Int64(20 - $0) * 1_024, - isDir: true, - isPackage: false, - inaccessibleCount: 0, - skippedCount: 0 - ) - } - let snapshot = Analyze.FolderSnapshot( - path: "/tmp", + private func child(_ path: String, size: Int64, isDir: Bool = true, isPackage: Bool = false, skipped: Int = 0) -> Analyze.Child { + Analyze.Child(path: path, size: size, isDir: isDir, isPackage: isPackage, inaccessibleCount: 0, skippedCount: skipped) + } + + private func snapshot( + path: String = "/tmp", + children: [Analyze.Child], + inaccessible: Int = 0, + skipped: Int = 0, + errors: [String] = [] + ) -> Analyze.FolderSnapshot { + Analyze.FolderSnapshot( + path: path, children: children, total: children.reduce(Int64(0)) { $0 + $1.size }, - inaccessibleCount: 0, - skippedCount: 0, - errors: [] + inaccessibleCount: inaccessible, + skippedCount: skipped, + errors: errors ) + } + + private func manyChildren(_ count: Int) -> [Analyze.Child] { + (0.. Analyze.MeasureProgress { + Analyze.MeasureProgress( + path: "/tmp", position: position, count: count, name: name, nameIsDirectory: true, + nameIsPackage: isPackage, + measured: measured ?? [child("/tmp/small", size: 1_000), child("/tmp/big", size: 5_000)] ) - let lines = Analyze.browserLines( - level: Analyze.BrowserLevel(snapshot: snapshot), - width: 40, - height: 12 + } + + private func measuringLines( + _ progress: Analyze.MeasureProgress, drillingDown: Bool = false, width: Int = 60, height: Int = 14 + ) -> [String] { + Analyze.renderMeasuringBrowser( + progress: progress, tick: 2, drillingDown: drillingDown, width: width, height: height + ).plainLines() + } + + func testScanReportsEachChildBeforeAndAfterMeasuring() throws { + try Data(repeating: 0x41, count: 4_096).write(to: root.appendingPathComponent("a.bin")) + try FileManager.default.createDirectory(at: root.appendingPathComponent("b-dir"), withIntermediateDirectories: true) + try Data(repeating: 0x42, count: 8_192).write(to: root.appendingPathComponent("b-dir/x.bin")) + + var events: [Analyze.ScanEvent] = [] + let snapshot = Analyze().scanFolder(root.path, ignore: try emptyIgnore(), progress: { events.append($0) }) + + XCTAssertEqual(events.count, 4) + XCTAssertEqual(events[0], .measuring(position: 1, count: 2, name: "a.bin", isDirectory: false, isPackage: false)) + guard case .measured(let first) = events[1] else { return XCTFail("expected measured") } + XCTAssertEqual(URL(fileURLWithPath: first.path).lastPathComponent, "a.bin") + XCTAssertEqual(events[2], .measuring(position: 2, count: 2, name: "b-dir", isDirectory: true, isPackage: false)) + XCTAssertEqual(snapshot, Analyze().scanFolder(root.path, ignore: try emptyIgnore())) + } + + func testScanStopsBetweenChildrenWhenCancelled() throws { + for name in ["a.bin", "b.bin", "c.bin"] { + try Data(repeating: 0x41, count: 4_096).write(to: root.appendingPathComponent(name)) + } + var measured = 0 + let snapshot = Analyze().scanFolder( + root.path, + ignore: try emptyIgnore(), + progress: { if case .measured = $0 { measured += 1 } }, + isCancelled: { measured >= 1 } ) - XCTAssertLessThanOrEqual(lines.count, 12) - XCTAssertTrue(lines.allSatisfy { Term.displayWidth($0) <= 40 }) - XCTAssertTrue(lines.joined(separator: "\n").contains("O reveal")) - XCTAssertTrue(lines.joined(separator: "\n").contains("q/Esc exit")) + XCTAssertEqual(measured, 1) + XCTAssertEqual(snapshot.children.count, 1) + } + + func testScanStopsInsideALargeChildWhenCancelled() throws { + let big = root.appendingPathComponent("big", isDirectory: true) + try FileManager.default.createDirectory(at: big, withIntermediateDirectories: true) + for index in 0..<60 { + try Data(repeating: 0x41, count: 4_096).write(to: big.appendingPathComponent("file-\(index).bin")) + } + var checks = 0 + let snapshot = Analyze().scanFolder(root.path, ignore: try emptyIgnore(), isCancelled: { + checks += 1 + return checks > 3 + }) + + XCTAssertTrue(snapshot.children.isEmpty) + XCTAssertLessThan(checks, 20) + } + + func testMeasuringBrowserStatusReadsCancellingOnceCancelRequested() { + let lines = Analyze.renderMeasuringBrowser( + progress: measuringProgress(), tick: 2, drillingDown: true, isCancelling: true, width: 60, height: 14 + ).plainLines() + XCTAssertEqual(lines[2], "│ ⠹ Cancelling… │") + } - let tooSmall = Analyze.browserLines( - level: Analyze.BrowserLevel(snapshot: snapshot), - width: 39, - height: 11 + func testMeasureProgressCollectsEvents() { + var progress = Analyze.MeasureProgress(path: "/tmp") + progress.apply(.measuring(position: 2, count: 4, name: "Docs", isDirectory: true, isPackage: true)) + XCTAssertEqual(progress.position, 2) + XCTAssertEqual(progress.count, 4) + XCTAssertEqual(progress.name, "Docs") + XCTAssertTrue(progress.nameIsDirectory) + XCTAssertTrue(progress.nameIsPackage) + progress.apply(.measured(child("/tmp/Docs", size: 10))) + XCTAssertEqual(progress.measured.map(\.path), ["/tmp/Docs"]) + } + + func testMeasuringBrowserShowsSpinnerStatusSortedChildrenAndCurrentName() { + let buffer = Analyze.renderMeasuringBrowser(progress: measuringProgress(), tick: 2, drillingDown: false, width: 60, height: 14) + let lines = buffer.plainLines() + + XCTAssertEqual(lines[0], "╭─ PureMac Space Explorer ─────────────────────────────────╮") + XCTAssertEqual(lines[1], "│ /tmp │") + XCTAssertEqual(lines[2], "│ ⠹ Measuring 3 of 5 │") + XCTAssertTrue(lines[3].contains("big/")) + XCTAssertTrue(lines[4].contains("small/")) + XCTAssertEqual(lines[5], "│ ⠹ Library/ │") + XCTAssertTrue(buffer[2, 2].style.dim) + XCTAssertTrue(buffer[2, 5].style.dim) + XCTAssertFalse(lines.joined().contains("No measurable items.")) + XCTAssertFalse(buffer[2, 3].style.inverse) + } + + func testMeasuringBrowserBeforeFirstChildAndForFiles() { + let start = Analyze.renderMeasuringBrowser( + progress: Analyze.MeasureProgress(path: "/tmp"), tick: 0, drillingDown: false, width: 60, height: 14 + ).plainLines() + XCTAssertEqual(start[2], "│ ⠋ Measuring… │") + XCTAssertEqual(start[3], "│ │") + + var file = measuringProgress(name: "notes.txt", measured: []) + file.nameIsDirectory = false + XCTAssertEqual( + Analyze.renderMeasuringBrowser(progress: file, tick: 0, drillingDown: false, width: 60, height: 14).plainLines()[3], + "│ ⠋ notes.txt │" ) - XCTAssertTrue(tooSmall.contains { $0.contains("Terminal too small") }) - XCTAssertTrue(tooSmall.allSatisfy { Term.displayWidth($0) <= 39 }) + } + + func testMeasuringBrowserBelowMinimumUsesTooSmallScreen() { + XCTAssertEqual( + Analyze.renderMeasuringBrowser(progress: measuringProgress(), tick: 0, drillingDown: false, width: 39, height: 11), + TooSmallScreen.render(title: "PureMac Space Explorer", exitHint: "Esc/q exit", width: 39, height: 11) + ) + XCTAssertEqual( + Analyze.renderMeasuringBrowser(progress: measuringProgress(), tick: 0, drillingDown: true, width: 39, height: 11), + TooSmallScreen.render(title: "PureMac Space Explorer", exitHint: "Esc back q exit", width: 39, height: 11) + ) + } + + func testMeasuringBrowserKeepsCurrentRowWhenMoreChildrenThanRows() { + let many = (0..<40).map { child("/tmp/item-\($0)", size: Int64(1_000 - $0)) } + let lines = measuringLines(measuringProgress(measured: many)) + let currentIndex = lines.firstIndex { $0.contains("⠹ Library/") } + + XCTAssertNotNil(currentIndex) + XCTAssertTrue(lines[currentIndex! - 1].contains("item-")) + XCTAssertTrue(lines[currentIndex! + 1].hasPrefix("├")) + XCTAssertTrue(lines.joined().contains("item-0/")) + XCTAssertFalse(lines.joined().contains("item-39/")) + } + + func testMeasuringBrowserScrollbarMatchesDrawnRowsAndPathRowIsHidden() { + let lines0 = measuringLines(measuringProgress(measured: []), width: 40, height: 12) + XCTAssertFalse(lines0.joined().contains("Focused:")) + let divider = lines0.firstIndex { $0.hasPrefix("├") }! + let listRows = divider - 3 + for count in [listRows - 1, listRows, listRows + 4] { + let many = (0.. Void) throws -> String { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("capture-\(UUID().uuidString)") + FileManager.default.createFile(atPath: url.path, contents: nil) + defer { try? FileManager.default.removeItem(at: url) } + let handle = try FileHandle(forWritingTo: url) + fflush(stdout) + let saved = dup(descriptor) + dup2(handle.fileDescriptor, descriptor) + defer { + fflush(stdout) + dup2(saved, descriptor) + close(saved) + } + try body() + fflush(stdout) + try handle.close() + return String(decoding: try Data(contentsOf: url), as: UTF8.self) +} + +func captureStdout(_ body: () throws -> Void) throws -> String { try capture(STDOUT_FILENO, body) } +func captureStderr(_ body: () throws -> Void) throws -> String { try capture(STDERR_FILENO, body) } + +extension InteractiveReviewModel { + static func selected(_ categories: [CategoryScan], selecting indices: [Int], query: String = "") -> InteractiveReviewModel { + var model = InteractiveReviewModel(categories: categories) + if !query.isEmpty { model.updateQuery(query) } + var position = 0 + for index in indices { + model.move(index - position) + position = index + model.toggleFocused() + } + return model + } +} + +func withConfigHome(_ body: (URL) throws -> T) throws -> T { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("puremac-config-home-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let saved = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"] + setenv("XDG_CONFIG_HOME", directory.path, 1) + defer { + if let saved { setenv("XDG_CONFIG_HOME", saved, 1) } else { unsetenv("XDG_CONFIG_HOME") } + try? FileManager.default.removeItem(at: directory) + } + return try body(directory.appendingPathComponent("puremac", isDirectory: true)) +} diff --git a/cli/Tests/puremacTests/CategoryScannerTests.swift b/cli/Tests/puremacTests/CategoryScannerTests.swift new file mode 100644 index 0000000..383192b --- /dev/null +++ b/cli/Tests/puremacTests/CategoryScannerTests.swift @@ -0,0 +1,109 @@ +import Foundation +import XCTest +@testable import puremac + +final class CategoryScannerTests: XCTestCase { + func testCatalogScanReturnsGroupsSizesAndSelection() throws { + let dir = try tempDir() + _ = try makeFile("\(dir)/cache-a/data", bytes: 8192) + try FileManager.default.createDirectory(atPath: "\(dir)/cache-b", withIntermediateDirectories: true) + let targets = [ + Target(tool: "Tool A", paths: ["\(dir)/cache-a"], selectedByDefault: false), + Target(tool: "Tool B", paths: ["\(dir)/cache-b"]), + Target(tool: "Tool C", paths: ["\(dir)/missing"]) + ] + let groups = CategoryScanner.scanCatalog(targets: targets, ignore: ignore(), isCancelled: { false }) + XCTAssertEqual(groups.map(\.tool), ["Tool A"]) + let item = try XCTUnwrap(groups.first?.items.first) + XCTAssertEqual(item.path, "\(dir)/cache-a") + XCTAssertGreaterThanOrEqual(item.sizeBytes, 8192) + XCTAssertFalse(item.selected) + } + + func testCatalogScanOfContentsTargetListsChildren() throws { + let dir = try tempDir() + _ = try makeFile("\(dir)/logs/one/data", bytes: 8192) + _ = try makeFile("\(dir)/logs/two/data", bytes: 8192) + let groups = CategoryScanner.scanCatalog( + targets: [Target(tool: "Logs", paths: ["\(dir)/logs"], contents: true)], ignore: ignore(), isCancelled: { false } + ) + XCTAssertEqual(groups.first?.items.map(\.path).sorted(), ["\(dir)/logs/one", "\(dir)/logs/two"]) + XCTAssertTrue(groups.first?.items.allSatisfy(\.selected) ?? false) + } + + func testCancelledCatalogScanReturnsNoGroups() throws { + let dir = try tempDir() + _ = try makeFile("\(dir)/cache/data", bytes: 8192) + let groups = CategoryScanner.scanCatalog( + targets: [Target(tool: "Tool", paths: ["\(dir)/cache"])], ignore: ignore(), isCancelled: { true } + ) + XCTAssertTrue(groups.isEmpty) + } + + func testTrashScanReturnsOneGroupPerRootWithItems() throws { + let dir = try tempDir() + _ = try makeFile("\(dir)/trash/old/data", bytes: 8192) + let groups = CategoryScanner.scanTrash( + roots: ["\(dir)/trash", "\(dir)/no-trash"], ignore: ignore(), isCancelled: { false } + ) + XCTAssertEqual(groups.map(\.tool), ["Trash"]) + XCTAssertEqual(groups.first?.items.map(\.path), ["\(dir)/trash/old"]) + XCTAssertTrue(groups.first?.items.allSatisfy(\.selected) ?? false) + } + + func testCancelledTrashScanReturnsNoGroups() throws { + let dir = try tempDir() + _ = try makeFile("\(dir)/trash/old/data", bytes: 8192) + XCTAssertTrue(CategoryScanner.scanTrash(roots: ["\(dir)/trash"], ignore: ignore(), isCancelled: { true }).isEmpty) + } + + func testCancelledCatalogMeasurementIsNotReportedAsAGroup() throws { + let dir = try tempDir() + for index in 0..<60 { _ = try makeFile("\(dir)/cache/file-\(index).bin", bytes: 4096) } + var checks = 0 + let groups = CategoryScanner.scanCatalog( + targets: [Target(tool: "Tool", paths: ["\(dir)/cache"])], + ignore: ignore(), + isCancelled: { + checks += 1 + return checks > 1 + } + ) + + XCTAssertTrue(groups.isEmpty) + XCTAssertLessThan(checks, 20) + } + + func testCancelledTrashMeasurementIsNotReportedAsAGroup() throws { + let dir = try tempDir() + for index in 0..<60 { _ = try makeFile("\(dir)/trash/old/file-\(index).bin", bytes: 4096) } + var checks = 0 + let groups = CategoryScanner.scanTrash(roots: ["\(dir)/trash"], ignore: ignore(), isCancelled: { + checks += 1 + return checks > 1 + }) + + XCTAssertTrue(groups.isEmpty) + XCTAssertLessThan(checks, 20) + } + + private func ignore() -> IgnoreStore { + try! IgnoreStore(fileURL: FileManager.default.temporaryDirectory.appendingPathComponent("category-scanner-\(UUID().uuidString)")) + } + + private func tempDir() throws -> String { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("category-scanner-dir-\(UUID().uuidString)").resolvingSymlinksInPath() + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: url) } + return url.path + } + + private func makeFile(_ path: String, bytes: Int) throws -> String { + try FileManager.default.createDirectory( + atPath: (path as NSString).deletingLastPathComponent, withIntermediateDirectories: true + ) + try Data(repeating: 1, count: bytes).write(to: URL(fileURLWithPath: path)) + return path + } +} diff --git a/cli/Tests/puremacTests/CleanScanTests.swift b/cli/Tests/puremacTests/CleanScanTests.swift new file mode 100644 index 0000000..5d10d49 --- /dev/null +++ b/cli/Tests/puremacTests/CleanScanTests.swift @@ -0,0 +1,47 @@ +import XCTest +import TerminalUI +@testable import puremac + +final class CleanScanTests: XCTestCase { + private func ignore() -> IgnoreStore { + try! IgnoreStore(fileURL: FileManager.default.temporaryDirectory.appendingPathComponent("clean-scan-\(UUID().uuidString)")) + } + + func testDefaultScanFiltersEmptyCategories() { + XCTAssertTrue(Clean.scan(ids: ["none"], ignore: ignore(), progress: .silent).isEmpty) + } + + func testStatusNamesEachCategoryWithItsPosition() { + let status = WorkProgress(ScanStatus(headline: "Scanning")) + _ = Clean.scan(ids: ["none", "other"], ignore: ignore(), progress: .silent, status: status) + XCTAssertEqual(status.value.headline, "Scanning Other, 2 of 2") + } + + func testCancelledScanStopsBeforeTheNextCategory() { + let status = WorkProgress(ScanStatus(headline: "Scanning")) + let cancel = CancelFlag() + cancel.cancel() + let result = Clean.scan(ids: ["none", "other"], ignore: ignore(), progress: .silent, status: status, cancel: cancel) + XCTAssertTrue(result.isEmpty) + XCTAssertEqual(status.value.headline, "Scanning") + } + + func testScanCountsFoundItemsAndPassesCancelStateToTheCategoryScan() { + let status = WorkProgress(ScanStatus(headline: "Scanning")) + let cancel = CancelFlag() + var cancelledSeenByScan: Bool? + let item = ScanItem(path: "/x", sizeBytes: 500, modified: nil, identity: nil) + let result = Clean.scan( + ids: ["a"], ignore: ignore(), progress: .silent, status: status, cancel: cancel, + scanCategory: { id, title, _, _, isCancelled in + cancel.cancel() + cancelledSeenByScan = isCancelled() + return CategoryScan(id: id, title: title, groups: [ToolGroup(tool: "T", items: [item])]) + } + ) + XCTAssertEqual(result.count, 1) + XCTAssertEqual(status.value.foundCount, 1) + XCTAssertEqual(status.value.foundBytes, 500) + XCTAssertEqual(cancelledSeenByScan, true) + } +} diff --git a/cli/Tests/puremacTests/CleanerProgressTests.swift b/cli/Tests/puremacTests/CleanerProgressTests.swift new file mode 100644 index 0000000..679e50b --- /dev/null +++ b/cli/Tests/puremacTests/CleanerProgressTests.swift @@ -0,0 +1,50 @@ +import Foundation +import XCTest +@testable import puremac + +final class CleanerProgressTests: XCTestCase { + private var directory: URL! + + override func setUpWithError() throws { + directory = FileManager.default.temporaryDirectory.appendingPathComponent("cleaner-progress-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: directory) + } + + private func ignoreStore() throws -> IgnoreStore { + try IgnoreStore(fileURL: directory.appendingPathComponent("ignore.json")) + } + + private func makeItem(_ name: String) throws -> ScanItem { + let path = directory.appendingPathComponent(name).path + try Data("x".utf8).write(to: URL(fileURLWithPath: path)) + return ScanItem(path: path, sizeBytes: 1, modified: nil, identity: Safety.fileIdentity(at: path)) + } + + func testCallbackRunsBeforeEachItemWithIndexCountAndItem() throws { + let items = try (0..<3).map { try makeItem("item\($0)") } + var calls: [(Int, Int, String, Bool)] = [] + + _ = Cleaner.remove(items, ignore: try ignoreStore(), dryRun: false) { index, count, item in + calls.append((index, count, item.path, FileManager.default.fileExists(atPath: item.path))) + } + + XCTAssertEqual(calls.map { $0.0 }, [0, 1, 2]) + XCTAssertEqual(calls.map { $0.1 }, [3, 3, 3]) + XCTAssertEqual(calls.map { $0.2 }, items.map { $0.path }) + XCTAssertEqual(calls.map { $0.3 }, [true, true, true]) + } + + func testOutcomeIsTheSameWithAndWithoutCallback() throws { + let items = [try makeItem("a"), try makeItem("b")] + let plain = Cleaner.remove(items, ignore: try ignoreStore(), dryRun: true) + let observed = Cleaner.remove(items, ignore: try ignoreStore(), dryRun: true) { _, _, _ in } + XCTAssertEqual(observed.removed, plain.removed) + XCTAssertEqual(observed.freedBytes, plain.freedBytes) + XCTAssertEqual(observed.skipped.map { $0.path }, plain.skipped.map { $0.path }) + XCTAssertEqual(observed.failed.map { $0.path }, plain.failed.map { $0.path }) + } +} diff --git a/cli/Tests/puremacTests/CleanupFlowTests.swift b/cli/Tests/puremacTests/CleanupFlowTests.swift new file mode 100644 index 0000000..3ed7e74 --- /dev/null +++ b/cli/Tests/puremacTests/CleanupFlowTests.swift @@ -0,0 +1,368 @@ +import ArgumentParser +import XCTest +@testable import puremac +import TerminalUI + +final class CleanupFlowTests: XCTestCase { + private let labels = CleanupFlow.Labels( + scanning: "Scanning Developer caches", + empty: "No cleanup candidates found in readable locations.", + reviewTitle: "Choose items to clean", + noun: "item" + ) + + func testScanningScreenUsesPureMacTitleHeadlineWithSpinnerAndCancelHint() { + let status = ScanStatus(headline: "Scanning Developer caches, 2 of 4") + let lines = CleanupFlow.scanningScreen(status: status, tick: 0, width: 60, height: 14).plainLines() + XCTAssertTrue(lines[0].hasPrefix("╭─ PureMac ")) + XCTAssertTrue(lines[1].contains("⠋ Scanning Developer caches, 2 of 4")) + XCTAssertTrue(lines.contains { $0.contains("Esc/q cancel") }) + } + + func testScanningScreenHeadlineReadsCancellingOnceCancelRequested() { + let status = ScanStatus(headline: "Scanning Developer caches, 2 of 4") + let lines = CleanupFlow.scanningScreen(status: status, tick: 0, isCancelling: true, width: 60, height: 14).plainLines() + XCTAssertTrue(lines[1].contains("⠋ Cancelling…"), lines[1]) + XCTAssertFalse(lines[1].contains("Developer")) + } + + func testScanningScreenSpinnerFollowsTheTick() { + let status = ScanStatus(headline: "Scanning") + let lines = CleanupFlow.scanningScreen(status: status, tick: 3, width: 60, height: 14).plainLines() + XCTAssertTrue(lines[1].contains("\(Spinner.frame(tick: 3)) Scanning")) + } + + func testScanningScreenShowsTheFolderBeingWalkedDimAndTheFoundSoFar() { + let status = ScanStatus(headline: "Scanning ~/Projects", folder: "/tmp/a/b", foundCount: 3, foundBytes: 2_000_000) + let buffer = CleanupFlow.scanningScreen(status: status, tick: 0, width: 60, height: 14) + let lines = buffer.plainLines() + XCTAssertTrue(lines[2].contains("/tmp/a/b")) + XCTAssertTrue(buffer[2, 2].style.dim) + XCTAssertTrue(lines[3].contains("Found 3 items · \(ByteCount.human(2_000_000))"), lines[3]) + XCTAssertFalse(buffer[2, 3].style.dim) + } + + func testScanningScreenFoundRowUsesTheSingularAndStartsAtZero() { + let none = CleanupFlow.scanningScreen(status: ScanStatus(headline: "Scanning"), tick: 0, width: 60, height: 14).plainLines() + XCTAssertTrue(none[3].contains("Found 0 items · \(ByteCount.human(0))"), none[3]) + let one = CleanupFlow.scanningScreen( + status: ScanStatus(headline: "Scanning", foundCount: 1, foundBytes: 10), tick: 0, width: 60, height: 14 + ).plainLines() + XCTAssertTrue(one[3].contains("Found 1 item · "), one[3]) + } + + func testScanningScreenLongFolderIsMiddleTruncated() { + let folder = "/Users/example/" + String(repeating: "a", count: 80) + "/src" + let lines = CleanupFlow.scanningScreen( + status: ScanStatus(headline: "Scanning", folder: folder), tick: 0, width: 40, height: 12 + ).plainLines() + XCTAssertTrue(lines.allSatisfy { Term.displayWidth($0) == 40 }) + XCTAssertTrue(lines[2].contains("…")) + XCTAssertTrue(lines[2].contains("/src")) + } + + func testScanningScreenLongHeadlineKeepsTheLastFolderName() { + let headline = "Scanning /Users/example/" + String(repeating: "a", count: 80) + "/ProjectX" + let lines = CleanupFlow.scanningScreen(status: ScanStatus(headline: headline), tick: 0, width: 40, height: 12).plainLines() + XCTAssertTrue(lines.allSatisfy { Term.displayWidth($0) == 40 }) + XCTAssertTrue(lines[1].contains("…")) + XCTAssertTrue(lines[1].contains("ProjectX"), lines[1]) + } + + func testInitialStatusUsesTheScanningLabelAsIs() { + XCTAssertEqual(labels.initialStatus.headline, "Scanning Developer caches") + XCTAssertEqual(labels.initialStatus.foundCount, 0) + } + + func testEscapeAndQCancelTheScanAndNothingElseDoes() { + XCTAssertTrue(CleanupFlow.cancelsScan(.escape)) + XCTAssertTrue(CleanupFlow.cancelsScan(.character("q"))) + XCTAssertTrue(CleanupFlow.cancelsScan(.character("Q"))) + XCTAssertFalse(CleanupFlow.cancelsScan(.enter)) + XCTAssertFalse(CleanupFlow.cancelsScan(.character("n"))) + XCTAssertFalse(CleanupFlow.cancelsScan(.up)) + } + + func testEmptyScreenShowsInfoDialogOverFadedScanningScreen() { + let buffer = CleanupFlow.emptyScreen(labels: labels, width: 80, height: 24) + let lines = buffer.plainLines() + XCTAssertTrue(lines.contains { $0.contains("Nothing to clean") }) + XCTAssertTrue(lines.contains { $0.contains("No cleanup candidates found in readable locations.") }) + XCTAssertTrue(lines.contains { $0.contains("› OK ‹") }) + XCTAssertTrue(lines.contains { $0.contains("⠋ Scanning Developer caches") && !$0.contains("…") }) + XCTAssertEqual(buffer[0, 0].style, TextStyle(dim: true)) + XCTAssertTrue(buffer.lines(colors: true).joined().contains("\u{1B}[7m› OK ‹")) + } + + func testEmptyScreenClosesOnEnterEscapeAndQOnly() { + XCTAssertTrue(CleanupFlow.closesInfoDialog(.enter)) + XCTAssertTrue(CleanupFlow.closesInfoDialog(.escape)) + XCTAssertTrue(CleanupFlow.closesInfoDialog(.character("q"))) + XCTAssertTrue(CleanupFlow.closesInfoDialog(.character("Q"))) + XCTAssertFalse(CleanupFlow.closesInfoDialog(.space)) + XCTAssertFalse(CleanupFlow.closesInfoDialog(.character("y"))) + XCTAssertFalse(CleanupFlow.closesInfoDialog(.up)) + } + + func testRemovingScreenShowsStatusDialogWithoutButtonsOverFadedSelectionScreen() { + let buffer = CleanupFlow.removingScreen( + review: review(), labels: labels, progress: progress(), tick: 0, width: 80, height: 24 + ) + let lines = buffer.plainLines() + XCTAssertTrue(lines.contains { $0.contains("Removing 2 items…") }) + XCTAssertTrue(lines.contains { $0.contains("⠋ /tmp/second") }) + XCTAssertTrue(lines.contains { $0.contains("2 of 2") }) + let dialog = CleanupFlow.removingDialog(noun: "item", progress: progress(), tick: 0, area: buffer.area) + XCTAssertTrue(dialog.buttons.isEmpty) + XCTAssertNil(dialog.focusedButton) + XCTAssertNil(dialog.hints) + XCTAssertTrue(lines.contains { $0.contains("╭─ Choose items to clean ") }) + XCTAssertFalse(lines.contains { $0.contains("Final review") }) + XCTAssertTrue(lines.contains { $0.contains("/tmp/first") }) + XCTAssertTrue(lines.contains { $0.contains("/tmp/third") }) + XCTAssertEqual(buffer[0, 0].style, TextStyle(dim: true)) + let frame = dialog.frame(in: buffer.area) + XCTAssertEqual(buffer[frame.x, frame.y].style, TextStyle(color: .yellow)) + XCTAssertEqual(buffer[frame.x + 3, frame.y].style, TextStyle(color: .yellow, bold: true)) + } + + func testRemovingDialogShowsSpinnerFrameForTheTick() { + let area = Rect(x: 0, y: 0, width: 80, height: 24) + let first = CleanupFlow.removingDialog(noun: "item", progress: progress(), tick: 0, area: area) + let second = CleanupFlow.removingDialog(noun: "item", progress: progress(), tick: 1, area: area) + XCTAssertTrue(first.message[0].text.hasPrefix("\(Spinner.frame(tick: 0)) ")) + XCTAssertTrue(second.message[0].text.hasPrefix("\(Spinner.frame(tick: 1)) ")) + } + + func testRemovingDialogCountRowIsDim() { + let dialog = CleanupFlow.removingDialog( + noun: "item", progress: RemovalProgress(index: 2, count: 5, path: "/tmp/a"), tick: 0, + area: Rect(x: 0, y: 0, width: 80, height: 24) + ) + XCTAssertEqual(dialog.message[1], Span("3 of 5", TextStyle(dim: true))) + XCTAssertEqual(dialog.message.count, 2) + } + + func testRemovingDialogKeepsRowsInTheFirstContentColumn() { + let area = Rect(x: 0, y: 0, width: 80, height: 24) + let dialog = CleanupFlow.removingDialog(noun: "item", progress: progress(), tick: 0, area: area) + var buffer = Buffer(width: 80, height: 24) + dialog.render(in: area, buffer: &buffer) + let frame = dialog.frame(in: area) + let lines = buffer.plainLines() + let spinnerRow = lines.first { $0.contains("⠋ /tmp/second") }! + let countRow = lines.first { $0.contains("2 of 2") }! + let spinnerColumn = spinnerRow.distance(from: spinnerRow.startIndex, to: spinnerRow.range(of: "⠋")!.lowerBound) + let countColumn = countRow.distance(from: countRow.startIndex, to: countRow.range(of: "2 of 2")!.lowerBound) + XCTAssertEqual(spinnerColumn, frame.x + 3) + XCTAssertEqual(countColumn, frame.x + 3) + } + + func testRemovingDialogKeepsFixedWidthAcrossItems() { + let wide = Rect(x: 0, y: 0, width: 120, height: 24) + let short = RemovalProgress(index: 0, count: 2, path: "/tmp/a") + let long = RemovalProgress(index: 1, count: 2, path: "/tmp/" + String(repeating: "deep-name/", count: 20)) + let shortDialog = CleanupFlow.removingDialog(noun: "item", progress: short, tick: 0, area: wide) + let longDialog = CleanupFlow.removingDialog(noun: "item", progress: long, tick: 5, area: wide) + XCTAssertEqual(shortDialog.frame(in: wide), longDialog.frame(in: wide)) + XCTAssertEqual(shortDialog.frame(in: wide).width, 60 + 6) + let narrow = Rect(x: 0, y: 0, width: 40, height: 12) + XCTAssertEqual( + CleanupFlow.removingDialog(noun: "item", progress: short, tick: 0, area: narrow).frame(in: narrow).width, + Dialog.maxContentWidth(in: narrow) + 6 + ) + } + + func testRemovingDialogShortensAndMiddleTruncatesThePath() { + let area = Rect(x: 0, y: 0, width: 80, height: 24) + let home = FileManager.default.homeDirectoryForCurrentUser.path + let path = home + "/Library/Caches/" + String(repeating: "very-long-name/", count: 8) + "end" + let row = CleanupFlow.removingDialog( + noun: "item", progress: RemovalProgress(index: 0, count: 1, path: path), tick: 0, area: area + ).message[0].text + XCTAssertEqual(Term.displayWidth(row), 60) + XCTAssertTrue(row.contains("~/Library")) + XCTAssertTrue(row.contains("…")) + XCTAssertTrue(row.hasSuffix("end")) + } + + func testRemovalProgressIsOneBasedAndSizedFromTheCleanerCallback() { + let progress = RemovalProgress(index: 0, count: 3, path: "/tmp/x") + XCTAssertEqual(progress.position, 1) + XCTAssertEqual(progress.count, 3) + XCTAssertEqual(RemovalProgress(index: 2, count: 3, path: "/tmp/y").position, 3) + } + + func testRemovingTitleUsesSingularForOneItem() { + let area = Rect(x: 0, y: 0, width: 80, height: 24) + func title(_ count: Int, _ noun: String) -> String { + CleanupFlow.removingDialog( + noun: noun, progress: RemovalProgress(index: 0, count: count, path: "/tmp/a"), tick: 0, area: area + ).title + } + XCTAssertEqual(title(1, "item"), "Removing 1 item…") + XCTAssertEqual(title(1, "artifact"), "Removing 1 artifact…") + XCTAssertEqual(title(0, "item"), "Removing 0 items…") + XCTAssertEqual(title(2, "artifact"), "Removing 2 artifacts…") + } + + func testRemovingDialogUsesTheNoun() { + let lines = CleanupFlow.removingScreen( + review: review(), + labels: CleanupFlow.Labels(scanning: "s", empty: "e", reviewTitle: "t", noun: "artifact"), + progress: progress(), tick: 0, width: 80, height: 24 + ).plainLines() + XCTAssertTrue(lines.contains { $0.contains("Removing 2 artifacts…") }) + } + + func testResultScreenShowsSummaryRowsAndFocusedDoneButton() { + let outcome = CleanOutcome(removed: 2, freedBytes: 1_500, skipped: [], failed: []) + let buffer = CleanupFlow.resultScreen(outcome: outcome, review: review(), labels: labels, width: 80, height: 24) + let lines = buffer.plainLines() + XCTAssertTrue(lines.contains { $0.contains("Cleanup complete") }) + XCTAssertTrue(lines.contains { $0.contains("1.5 KB removed across 2 items") }) + XCTAssertFalse(lines.contains { $0.contains("skipped") }) + XCTAssertFalse(lines.contains { $0.contains("failed") }) + XCTAssertTrue(lines.contains { $0.contains("› Done ‹") }) + XCTAssertTrue(lines.contains { $0.contains("/tmp/first") }) + let colored = buffer.lines(colors: true).joined() + XCTAssertTrue(colored.contains("\u{1B}[1;32mCleanup complete")) + XCTAssertTrue(colored.contains("\u{1B}[7m› Done ‹")) + } + + func testResultDialogSingularItemAndSkippedRow() { + let outcome = CleanOutcome(removed: 1, freedBytes: 1_000, skipped: [(path: "/tmp/a", reason: "symlink")], failed: []) + let lines = CleanupFlow.resultScreen(outcome: outcome, review: review(), labels: labels, width: 80, height: 24) + XCTAssertTrue(lines.plainLines().contains { $0.contains("1 KB removed across 1 item ") }) + XCTAssertTrue(lines.plainLines().contains { $0.contains("1 skipped protected or ignored") }) + XCTAssertTrue(lines.lines(colors: true).joined().contains("\u{1B}[33m1 skipped protected or ignored")) + } + + func testResultDialogListsFailureCountAndUpToThreePaths() { + let failures = (1...4).map { (path: "/tmp/fail\($0)", error: "denied") } + let outcome = CleanOutcome(removed: 0, freedBytes: 0, skipped: [], failed: failures) + let buffer = CleanupFlow.resultScreen(outcome: outcome, review: review(), labels: labels, width: 80, height: 24) + let lines = buffer.plainLines() + XCTAssertTrue(lines.contains { $0.contains("4 failed") }) + XCTAssertTrue(lines.contains { $0.contains("! /tmp/fail1") }) + XCTAssertTrue(lines.contains { $0.contains("! /tmp/fail3") }) + XCTAssertFalse(lines.contains { $0.contains("fail4") }) + let colored = buffer.lines(colors: true).joined() + XCTAssertTrue(colored.contains("\u{1B}[31m4 failed")) + XCTAssertTrue(colored.contains("\u{1B}[31m! /tmp/fail1")) + } + + func testResultScreenFitsMinimumSize() { + let failures = (1...4).map { (path: "/tmp/fail\($0)", error: "denied") } + let outcome = CleanOutcome(removed: 3, freedBytes: 5_000, skipped: [(path: "/tmp/a", reason: "x")], failed: failures) + let lines = CleanupFlow.resultScreen(outcome: outcome, review: review(), labels: labels, width: 40, height: 12) + .plainLines() + XCTAssertEqual(lines.count, 12) + XCTAssertTrue(lines.allSatisfy { Term.displayWidth($0) == 40 }) + XCTAssertTrue(lines.contains { $0.contains("› Done ‹") }) + } + + func testOutcomeTextIsSharedWithTheTerminalSummary() { + let outcome = CleanOutcome(removed: 2, freedBytes: 1_500, skipped: [(path: "/a", reason: "x")], failed: [(path: "/b", error: "y")]) + XCTAssertEqual(Render.removedDetail(outcome), " removed across 2 items") + XCTAssertEqual(Render.removedDetail(CleanOutcome(removed: 1)), " removed across 1 item") + XCTAssertEqual(Render.skippedLabel(outcome), "1 skipped") + XCTAssertEqual(Render.skippedNote, " protected or ignored") + XCTAssertEqual(Render.failedLabel(outcome), "1 failed") + } + + func testFinishPrintsTheCopyForEachResultAndKeepsExitStatus() throws { + XCTAssertEqual( + try captureStdout { try CleanupFlow.finish(.nothingFound, labels: labels) }, + "No cleanup candidates found in readable locations.\n" + ) + XCTAssertEqual( + try captureStdout { try CleanupFlow.finish(.cancelled, labels: labels) }, + "Cancelled. Nothing removed.\n" + ) + XCTAssertEqual( + try captureStdout { try CleanupFlow.finish(.finished(CleanOutcome(removed: 1, freedBytes: 10)), labels: labels) }, + "\nCleanup complete\n10 bytes removed across 1 item\n\n" + ) + let failed = CleanOutcome(failed: [(path: "/x", error: "e")]) + var thrown: Error? + let printed = try captureStdout { + do { try CleanupFlow.finish(.finished(failed), labels: labels) } catch { thrown = error } + } + XCTAssertEqual(thrown as? ExitCode, ExitCode.failure) + XCTAssertTrue(printed.contains("1 failed")) + let skipped = CleanOutcome(skipped: [(path: "/x", reason: "r")]) + var skippedError: Error? + let skippedPrinted = try captureStdout { + do { try CleanupFlow.finish(.finished(skipped), labels: labels) } catch { skippedError = error } + } + XCTAssertEqual(skippedError as? ExitCode, ExitCode.failure) + XCTAssertTrue(skippedPrinted.contains("1 skipped protected or ignored")) + } + + func testLongFailurePathsStayOnOneRowEach() { + let long = "/Users/someone/Library/Developer/Xcode/DerivedData/" + String(repeating: "very-long-name/", count: 6) + let failures = (1...3).map { (path: long + "f\($0)", error: "denied") } + let outcome = CleanOutcome(removed: 0, freedBytes: 0, skipped: [], failed: failures) + for (width, height) in [(40, 12), (80, 24)] { + let buffer = CleanupFlow.resultScreen(outcome: outcome, review: review(), labels: labels, width: width, height: height) + let lines = buffer.plainLines() + XCTAssertEqual(lines.filter { $0.contains("! ") }.count, 3, "\(width)x\(height)") + XCTAssertFalse(lines.contains { $0.contains("!") && !$0.contains("! ") }, "\(width)x\(height)") + XCTAssertTrue(lines.contains { $0.contains("› Done ‹") }) + } + } + + func testTooSmallInfoScreensShowTheKeyThatClosesThem() { + let empty = CleanupFlow.emptyScreen(labels: labels, width: 30, height: 8).plainLines() + XCTAssertTrue(empty.contains { $0.contains("Enter close") }) + XCTAssertFalse(empty.contains { $0.contains("Ctrl+C") }) + let outcome = CleanOutcome(removed: 1, freedBytes: 1_000) + let result = CleanupFlow.resultScreen(outcome: outcome, review: review(), labels: labels, width: 30, height: 8).plainLines() + XCTAssertTrue(result.contains { $0.contains("Enter close") }) + XCTAssertFalse(result.contains { $0.contains("cancel") }) + } + + func testTooSmallRemovingScreenMakesNoCancelPromise() { + let lines = CleanupFlow.removingScreen( + review: review(), labels: labels, progress: progress(), tick: 0, width: 30, height: 8 + ).plainLines() + XCTAssertTrue(lines.contains { $0.contains("Removing") }) + XCTAssertFalse(lines.contains { $0.contains("Esc") || $0.contains("cancel") }) + } + + func testBackgroundKeepsTheSelectionFilter() { + let model = InteractiveReviewModel.selected(selection(), selecting: [0], query: "first") + let lines = CleanupFlow.resultScreen( + outcome: CleanOutcome(removed: 1, freedBytes: 1_000), review: model, labels: labels, width: 80, height: 24 + ).plainLines() + XCTAssertTrue(lines.contains { $0.contains("Filter first") }) + } + + private func progress() -> RemovalProgress { + RemovalProgress(index: 1, count: 2, path: "/tmp/second") + } + + private func review() -> InteractiveReviewModel { + .selected(selection(), selecting: [0, 1]) + } + + private func selection() -> [CategoryScan] { + [ + CategoryScan( + id: "dev", + title: "Developer", + groups: [ + ToolGroup( + tool: "Build caches", + items: [ + ScanItem(path: "/tmp/first", sizeBytes: 1_000, modified: nil, selected: true), + ScanItem(path: "/tmp/second", sizeBytes: 2_000, modified: nil, selected: true), + ScanItem(path: "/tmp/third", sizeBytes: 3_000, modified: nil, selected: false) + ] + ) + ] + ) + ] + } +} diff --git a/cli/Tests/puremacTests/CommandReviewTests.swift b/cli/Tests/puremacTests/CommandReviewTests.swift index 627c465..19a028b 100644 --- a/cli/Tests/puremacTests/CommandReviewTests.swift +++ b/cli/Tests/puremacTests/CommandReviewTests.swift @@ -111,4 +111,13 @@ final class CommandReviewTests: XCTestCase { XCTAssertEqual(reviewed.flatMap(\.selectedItems).map(\.path), [recent.path]) XCTAssertEqual(category.selectedItems.map(\.path), [old.path]) } + + func testCleanAndPurgeHelpDescribeTheConfirmationDialogAndPlainReview() throws { + for discussion in [Clean.configuration.discussion, Purge.configuration.discussion] { + XCTAssertTrue(discussion.contains(Render.reviewHelp), discussion) + XCTAssertTrue(discussion.contains("Return on the selection screen opens a confirmation with Cancel focused")) + XCTAssertTrue(discussion.contains("--plain uses a numbered selector with ranges, all, and none, then prints the exact deletion review and asks (default No)")) + XCTAssertFalse(discussion.contains("only opens an exact deletion review")) + } + } } diff --git a/cli/Tests/puremacTests/ConfigSettingsTests.swift b/cli/Tests/puremacTests/ConfigSettingsTests.swift new file mode 100644 index 0000000..f2da5a8 --- /dev/null +++ b/cli/Tests/puremacTests/ConfigSettingsTests.swift @@ -0,0 +1,251 @@ +import Foundation +import XCTest +@testable import puremac +import TerminalUI + +private func write(_ text: String, to file: URL) throws { + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(text.utf8).write(to: file) +} + +final class ConfigSettingsTests: XCTestCase { + private var directory: URL! + private var file: URL { directory.appendingPathComponent("puremac/config.json") } + + override func setUpWithError() throws { + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("puremac-settings-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: directory) + } + + func testMissingFileGivesTheDefaultDays() throws { + XCTAssertEqual(try ConfigStore(fileURL: file).olderThanDays, 7) + } + + func testFileWithTheDaysKeyLoads() throws { + try write(#"{"purge.older_than_days": "14"}"#, to: file) + XCTAssertEqual(try ConfigStore(fileURL: file).olderThanDays, 14) + } + + func testWrongTypesAreInvalidConfigFiles() throws { + let bad = [ + #"{"purge.roots": ["/tmp/a"]}"#, + #"{"purge.older_than_days": 14}"#, + "[]", + ] + for text in bad { + try write(text, to: file) + XCTAssertThrowsError(try ConfigStore(fileURL: file), text) { error in + guard case StoreError.invalidConfigFile = error else { return XCTFail("\(text): \(error)") } + } + } + } + + func testBadDaysValuesAndUnknownKeysKeepTheirValueErrors() throws { + let bad = [ + (#"{"purge.older_than_days": "0"}"#, "invalidConfigValue"), + (#"{"purge.older_than_days": "7", "purge.roots": "/tmp/a"}"#, "unknownConfigKey"), + (#"{"other": "1"}"#, "unknownConfigKey"), + ] + for (text, name) in bad { + try write(text, to: file) + XCTAssertThrowsError(try ConfigStore(fileURL: file), text) { error in + XCTAssertTrue("\(error)".hasPrefix(name), "\(text): \(error)") + } + } + } + + func testDaysWithSurroundingWhitespaceAreStoredNormalized() throws { + try write(#"{"purge.older_than_days": " 14\n"}"#, to: file) + XCTAssertEqual(try ConfigStore(fileURL: file).olderThanDays, 14) + } + + func testSavedFileHasOnlyTheDaysKeyPrettyPrinted() throws { + var store = try ConfigStore(fileURL: file) + try store.set("purge.older_than_days", "14") + XCTAssertEqual(try String(contentsOf: file, encoding: .utf8), "{\n \"purge.older_than_days\" : \"14\"\n}") + } + + func testRootsAreNotSettableThroughConfigSet() { + XCTAssertThrowsError(try Config.parse(["set", "purge.roots", "/tmp/a"])) + } + + func testEnsureDefaultsCreatesAllThreeFilesWithDefaults() throws { + let config = directory.appendingPathComponent("puremac/config.json") + let ignore = directory.appendingPathComponent("puremac/ignore") + let projects = directory.appendingPathComponent("puremac/projects") + try AppPaths.ensureDefaults(configFile: config, ignoreFile: ignore, projectsFile: projects) + + XCTAssertEqual( + try String(contentsOf: config, encoding: .utf8), + "{\n \"purge.older_than_days\" : \"7\"\n}" + ) + XCTAssertEqual(try String(contentsOf: ignore, encoding: .utf8), "") + XCTAssertEqual( + try String(contentsOf: projects, encoding: .utf8), + Purger.defaultRoots.joined(separator: "\n") + "\n" + ) + XCTAssertEqual(try ProjectFoldersStore(fileURL: projects).roots, Purger.defaultRoots) + } + + func testDefaultConfigFilePassesTheRulesOfPureMac111() throws { + let config = directory.appendingPathComponent("puremac/config.json") + let ignore = directory.appendingPathComponent("puremac/ignore") + let projects = directory.appendingPathComponent("puremac/projects") + try AppPaths.ensureDefaults(configFile: config, ignoreFile: ignore, projectsFile: projects) + var store = try ConfigStore(fileURL: config) + try store.set("purge.older_than_days", "30") + + let object = try JSONSerialization.jsonObject(with: Data(contentsOf: config)) + let values = try XCTUnwrap(object as? [String: String]) + XCTAssertEqual(Set(values.keys), ["purge.older_than_days"]) + XCTAssertEqual(values["purge.older_than_days"], "30") + } + + func testEnsureDefaultsNeverOverwritesExistingFiles() throws { + let config = directory.appendingPathComponent("puremac/config.json") + let ignore = directory.appendingPathComponent("puremac/ignore") + let projects = directory.appendingPathComponent("puremac/projects") + try write(#"{"purge.older_than_days": "14"}"#, to: config) + try write("/tmp/keep\n", to: ignore) + try write("/tmp/mine\n", to: projects) + + try AppPaths.ensureDefaults(configFile: config, ignoreFile: ignore, projectsFile: projects) + try AppPaths.ensureDefaults(configFile: config, ignoreFile: ignore, projectsFile: projects) + + XCTAssertEqual(try String(contentsOf: config, encoding: .utf8), #"{"purge.older_than_days": "14"}"#) + XCTAssertEqual(try String(contentsOf: ignore, encoding: .utf8), "/tmp/keep\n") + XCTAssertEqual(try String(contentsOf: projects, encoding: .utf8), "/tmp/mine\n") + } + + func testEnsureDefaultsFillsOnlyTheMissingFile() throws { + let config = directory.appendingPathComponent("puremac/config.json") + let ignore = directory.appendingPathComponent("puremac/ignore") + let projects = directory.appendingPathComponent("puremac/projects") + try write("/tmp/keep\n", to: ignore) + try AppPaths.ensureDefaults(configFile: config, ignoreFile: ignore, projectsFile: projects) + XCTAssertTrue(FileManager.default.fileExists(atPath: config.path)) + XCTAssertEqual(try String(contentsOf: ignore, encoding: .utf8), "/tmp/keep\n") + XCTAssertTrue(FileManager.default.fileExists(atPath: projects.path)) + } + + func testEnsureDefaultsLeavesAnInvalidConfigFileAlone() throws { + let config = directory.appendingPathComponent("puremac/config.json") + let ignore = directory.appendingPathComponent("puremac/ignore") + let projects = directory.appendingPathComponent("puremac/projects") + try write("[]", to: config) + try AppPaths.ensureDefaults(configFile: config, ignoreFile: ignore, projectsFile: projects) + XCTAssertEqual(try String(contentsOf: config, encoding: .utf8), "[]") + } + + func testEnsureDefaultsCreatesTheIgnoreFileEvenWhenTheConfigFileFails() throws { + let blocker = directory.appendingPathComponent("blocker") + try write("x", to: blocker) + let config = blocker.appendingPathComponent("config.json") + let ignore = directory.appendingPathComponent("puremac/ignore") + let projects = directory.appendingPathComponent("puremac/projects") + XCTAssertThrowsError(try AppPaths.ensureDefaults(configFile: config, ignoreFile: ignore, projectsFile: projects)) + XCTAssertTrue(FileManager.default.fileExists(atPath: ignore.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: projects.path)) + } + + func testEnsureDefaultsCreatesTheConfigFileEvenWhenTheIgnoreFileFails() throws { + let blocker = directory.appendingPathComponent("blocker") + try write("x", to: blocker) + let config = directory.appendingPathComponent("puremac/config.json") + let ignore = blocker.appendingPathComponent("ignore") + let projects = directory.appendingPathComponent("puremac/projects") + XCTAssertThrowsError(try AppPaths.ensureDefaults(configFile: config, ignoreFile: ignore, projectsFile: projects)) + XCTAssertTrue(FileManager.default.fileExists(atPath: config.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: projects.path)) + } + + func testTheWorkspaceEntryPointCreatesNothingWithoutATerminal() throws { + try withConfigHome { dir in + _ = try captureStdout { try PureMac.parse([]).run() } + XCTAssertFalse(FileManager.default.fileExists(atPath: dir.path)) + } + } + + func testPurgeWithAnEmptyFolderListSaysSoAndDoesNotScan() throws { + try withConfigHome { _ in + try write("", to: AppPaths.projectsFile) + let output = try captureStdout { try Purge.parse(["--dry-run"]).run() } + XCTAssertEqual(output, Term.dim(Purge.noFoldersMessage) + "\n") + } + } + + func testOtherCommandsNeverCreateTheDefaultFiles() throws { + let scanned = directory.appendingPathComponent("scanned") + try FileManager.default.createDirectory(at: scanned, withIntermediateDirectories: true) + try withConfigHome { dir in + _ = try captureStdout { try Config.parse([]).run() } + _ = try captureStdout { try Ignore.List.parse([]).run() } + _ = try captureStdout { try Purge.parse([scanned.path, "--json"]).run() } + _ = try captureStdout { try Purge.parse([scanned.path, "--dry-run"]).run() } + XCTAssertFalse(FileManager.default.fileExists(atPath: dir.path)) + } + } + + func testPurgeWithoutPathsScansTheConfiguredRoots() throws { + try withConfigHome { _ in + try write("/tmp/one\n/tmp/two\n", to: AppPaths.projectsFile) + XCTAssertEqual(try Purge.parse([]).resolvedRoots(), ["/tmp/one", "/tmp/two"]) + } + } + + func testPurgeWithoutPathsFallsBackToTheDefaultsWithoutAFile() throws { + try withConfigHome { _ in + XCTAssertEqual(try Purge.parse([]).resolvedRoots(), Purger.defaultRoots) + } + } + + func testPurgeWithExplicitPathsIgnoresTheProjectsFile() throws { + try withConfigHome { _ in + try write("[]", to: AppPaths.projectsFile) + let explicit = directory.appendingPathComponent("explicit") + try FileManager.default.createDirectory(at: explicit, withIntermediateDirectories: true) + let roots = try Purge.parse([explicit.path + "/../explicit"]).resolvedRoots() + XCTAssertEqual(roots, [Safety.absolutePath(explicit.path)]) + } + } + + func testConfigPlainOutputListsProjectFoldersAfterTheFiles() throws { + try withConfigHome { dir in + try write("\(Safety.home)/Work\n/tmp/other\n", to: AppPaths.projectsFile) + let output = try captureStdout { try Config.parse([]).run() } + XCTAssertEqual(output, """ + PureMac CLI settings + Values used when a command-line option is omitted + + purge.older_than_days 7 days default + + Files + Settings \(dir.path)/config.json + Protected \(dir.path)/ignore + Projects \(dir.path)/projects + + Project folders + ~/Work + /tmp/other + + """) + } + } + + func testAFileHoldingTheDefaultDaysShowsDefault() throws { + try withConfigHome { _ in + var store = try ConfigStore(fileURL: AppPaths.configFile) + try store.set("purge.older_than_days", "7") + let output = try captureStdout { try Config.parse([]).run() } + XCTAssertTrue(output.contains("\n purge.older_than_days 7 days default\n"), output) + try store.set("purge.older_than_days", "8") + let changed = try captureStdout { try Config.parse([]).run() } + XCTAssertTrue(changed.contains("8 days saved"), changed) + } + } +} diff --git a/cli/Tests/puremacTests/CoreSafetyTests.swift b/cli/Tests/puremacTests/CoreSafetyTests.swift index e1a59aa..20e80f4 100644 --- a/cli/Tests/puremacTests/CoreSafetyTests.swift +++ b/cli/Tests/puremacTests/CoreSafetyTests.swift @@ -78,6 +78,41 @@ final class CoreSafetyTests: XCTestCase { XCTAssertTrue(fileManager.fileExists(atPath: artifact.appendingPathComponent("replacement.bin").path)) } + private func makeFolder(files: Int) throws -> URL { + let folder = root.appendingPathComponent("folder-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: folder, withIntermediateDirectories: true) + for index in 0.. 5 + }) + + XCTAssertEqual(measured.status, .partial) + XCTAssertGreaterThan(measured.skippedEntries, 0) + XCTAssertLessThan(measured.bytes, 60 * 4096) + XCTAssertLessThan(checks, 20) + } + func testCleanerAccountsFreshMeasuredBytes() throws { let file = root.appendingPathComponent("cache.bin") try Data(repeating: 5, count: 8192).write(to: file) diff --git a/cli/Tests/puremacTests/InfoSheetTests.swift b/cli/Tests/puremacTests/InfoSheetTests.swift new file mode 100644 index 0000000..25cf8cf --- /dev/null +++ b/cli/Tests/puremacTests/InfoSheetTests.swift @@ -0,0 +1,133 @@ +import XCTest +@testable import puremac +import TerminalUI + +final class InfoSheetTests: XCTestCase { + private let dim = TextStyle(dim: true) + + private func sample(extraRows: Int = 0) -> InfoSheet { + InfoSheet( + title: "Mac status", + subtitle: "Read-only measurements", + sections: [ + InfoSection(title: "Memory", rows: [ + .gauge(fraction: 0.5, color: .green, text: "8 GB in use of 16 GB"), + .metric(label: "Pressure", value: [Span("Normal", TextStyle(color: .green))], note: nil), + .metric(label: "Installed", value: [Span("16 GB")], note: Span("saved", dim)), + ] + (0.. [String] { lines.map { $0.map(\.text).joined() } } + + func testMetricMovesValueAndNoteToASecondRowWhenTheRowIsTooWide() { + let row = InfoRow.metric(label: "purge.older_than_days", value: [Span("7 days")], note: Span("default", dim)) + let narrow = plain(InfoSheet.lines(row, labelWidth: 26, wrappingTo: 36)) + XCTAssertEqual(narrow, [" purge.older_than_days", " 7 days default"]) + let wide = plain(InfoSheet.lines(row, labelWidth: 26, wrappingTo: 76)) + XCTAssertEqual(wide.count, 1) + XCTAssertTrue(wide[0].contains("7 days default")) + let short = InfoRow.metric(label: "Short", value: [Span("x")], note: nil) + XCTAssertEqual(InfoSheet.lines(short, labelWidth: 26, wrappingTo: 36).count, 1) + } + + func testMetricMiddleTruncatesALongValueOnItsSecondRow() { + let path = "/Users/someone/.config/puremac/deep/config.json" + let rows = plain(InfoSheet.lines(.metric(label: "Settings", value: [Span(path)], note: nil), labelWidth: 12, wrappingTo: 36)) + XCTAssertEqual(rows.count, 2) + XCTAssertTrue(rows[1].hasPrefix(" /Users") && rows[1].contains("…") && rows[1].hasSuffix("config.json"), rows[1]) + XCTAssertLessThanOrEqual(rows[1].count, 36) + } + + func testGaugeShrinksItsBarSoTheWholeTextFits() { + let text = "107.4 GB free of 536.9 GB" + let row = InfoRow.gauge(fraction: 0.8, color: .green, text: text) + let narrow = plain(InfoSheet.lines(row, labelWidth: 17, wrappingTo: 36))[0] + XCTAssertTrue(narrow.hasSuffix(text), narrow) + XCTAssertLessThanOrEqual(narrow.count, 36) + XCTAssertEqual(plain(InfoSheet.lines(row, labelWidth: 17, wrappingTo: 76))[0].filter { "█░".contains($0) }.count, 20) + } + + func testGaugeWithoutRoomForABarShowsOnlyItsText() { + let text = String(repeating: "y", count: 33) + let row = plain(InfoSheet.lines(.gauge(fraction: 0.5, color: .green, text: text), labelWidth: 17, wrappingTo: 36)) + XCTAssertEqual(row, [" " + text]) + } + + func testMessageWrapsKeepingSpanStyles() { + let cyan = TextStyle(color: .cyan) + let spans = [Span("No protected paths. Add one with ", dim), Span("`puremac ignore add `", cyan), Span(".", dim)] + let rows = InfoSheet.lines(.message(spans), labelWidth: 17, wrappingTo: 36) + XCTAssertGreaterThan(rows.count, 1) + XCTAssertTrue(plain(rows).allSatisfy { $0.hasPrefix(" ") && $0.count <= 36 }) + let styled = rows.flatMap { $0 }.filter { $0.style == cyan }.map(\.text).joined(separator: " ") + XCTAssertEqual(styled, "`puremac ignore add `") + XCTAssertFalse(plain(rows).joined().contains("…")) + } +} diff --git a/cli/Tests/puremacTests/InteractiveScreensTests.swift b/cli/Tests/puremacTests/InteractiveScreensTests.swift new file mode 100644 index 0000000..3a3f177 --- /dev/null +++ b/cli/Tests/puremacTests/InteractiveScreensTests.swift @@ -0,0 +1,804 @@ +import XCTest +@testable import puremac +import TerminalUI + +final class InteractiveScreensTests: XCTestCase { + func testReviewStartsWithNothingSelected() { + let model = InteractiveReviewModel(categories: fixtureCategories()) + XCTAssertEqual(model.selectedCount, 0) + XCTAssertEqual(model.selectedBytes, 0) + XCTAssertEqual(model.matchingAddresses.count, 3) + } + + func testEditQueryTypesFiltersAndDeletes() { + var model = InteractiveReviewModel(categories: fixtureCategories()) + for key in [TerminalKey.character("s"), .character("e"), .character("c")] { model.editQuery(key) } + XCTAssertEqual(model.query, "sec") + XCTAssertEqual(model.matchingAddresses.count, 1) + model.editQuery(.backspace) + model.editQuery(.delete) + XCTAssertEqual(model.query, "s") + model.editQuery(.space) + XCTAssertEqual(model.query, "s ") + } + + func testEditQueryIgnoresControlKeysAndKeepsFocusWhenNothingChanged() { + var model = InteractiveReviewModel(categories: fixtureCategories()) + model.move(2) + model.status = "note" + model.editQuery(.backspace) + model.editQuery(.up) + model.editQuery(.character("\u{7F}")) + XCTAssertEqual(model.query, "") + XCTAssertEqual(model.focusedAddress, model.matchingAddresses[2]) + XCTAssertEqual(model.status, "note") + } + + func testReviewSelectionAndSearchPreserveExactItems() { + var model = InteractiveReviewModel(categories: fixtureCategories()) + model.toggleFocused() + model.move(1) + model.toggleFocused() + XCTAssertEqual(model.selectedCount, 2) + XCTAssertEqual(model.selectedBytes, 3_000) + + model.updateQuery("second") + XCTAssertEqual(model.matchingAddresses.count, 1) + XCTAssertEqual(model.item(at: model.matchingAddresses[0]).path, "/tmp/second") + + model.selectNone() + model.selectAllMatching() + XCTAssertEqual(model.selectedCount, 1) + XCTAssertEqual(model.selectedResult()[0].selectedItems.map(\.path), ["/tmp/second"]) + } + + func testReviewRenderIsBoundedAndKeepsControlsVisible() { + var model = InteractiveReviewModel(categories: fixtureCategories()) + model.selectAllMatching() + for (width, height) in [(40, 12), (58, 14), (80, 24)] { + let lines = renderReview(model, width: width, height: height).plainLines() + + XCTAssertEqual(lines.count, height) + XCTAssertTrue(lines.contains { $0.contains("Space toggle") }) + XCTAssertTrue(lines.contains { $0.contains("Enter remove") }) + XCTAssertTrue(lines.allSatisfy { Term.displayWidth($0) == width }) + } + } + + func testReviewWindowShowsTitleAndSelectionSummaryInTopBorder() { + var model = InteractiveReviewModel(categories: fixtureCategories()) + model.toggleFocused() + let lines = renderReview(model, width: 80, height: 24).plainLines() + XCTAssertTrue(lines[0].hasPrefix("╭─ Review removable files ")) + XCTAssertTrue(lines[0].hasSuffix(" 1 selected · 1 KB ─╮")) + XCTAssertFalse(lines.contains { $0.contains("PUREMAC") }) + } + + func testReviewHeaderFitsFortyColumnsWithDifferentCountsAndSizes() { + let categories = [ + CategoryScan( + id: "cache", + title: "Caches", + groups: [ + ToolGroup( + tool: "Applications", + items: [ + ScanItem(path: "/tmp/one", sizeBytes: 138_300, modified: nil), + ScanItem(path: "/tmp/two", sizeBytes: 1_000, modified: nil) + ] + ) + ] + ) + ] + let lines = renderReview(InteractiveReviewModel(categories: categories), width: 40, height: 12).plainLines() + + XCTAssertTrue(lines[0].contains("0 selected")) + XCTAssertTrue(lines[1].contains("2 shown")) + XCTAssertTrue(lines.allSatisfy { Term.displayWidth($0) == 40 }) + } + + func testReviewFirstRowShowsFoundSummaryDimWhenIdle() { + let buffer = renderReview(InteractiveReviewModel(categories: fixtureCategories()), width: 80, height: 24) + XCTAssertTrue(buffer.plainLines()[1].hasPrefix("│ 3 shown · 6 KB")) + XCTAssertEqual(buffer[2, 1].style, TextStyle(dim: true)) + } + + func testReviewFirstRowShowsSearchFieldWithCursorAndRightAlignedSummary() { + var model = InteractiveReviewModel(categories: fixtureCategories()) + model.isSearching = true + model.updateQuery("sec") + let buffer = renderReview(model, width: 80, height: 24) + let line = buffer.plainLines()[1] + XCTAssertTrue(line.hasPrefix("│ Search sec ")) + XCTAssertTrue(line.hasSuffix("1 shown · 6 KB │")) + XCTAssertEqual(buffer[2, 1].style, TextStyle(color: .cyan)) + XCTAssertEqual(buffer[13, 1].style, TextStyle(inverse: true)) + XCTAssertEqual(buffer[line.count - 4, 1].style, TextStyle(dim: true)) + } + + func testReviewFirstRowShowsStatusInYellowAndFilterDim() { + var model = InteractiveReviewModel(categories: fixtureCategories()) + model.selectAllMatching() + var buffer = renderReview(model, width: 80, height: 24) + XCTAssertTrue(buffer.plainLines()[1].hasPrefix("│ Selected visible items")) + XCTAssertEqual(buffer[2, 1].style, TextStyle(color: .yellow)) + + model.updateQuery("first") + buffer = renderReview(model, width: 80, height: 24) + XCTAssertTrue(buffer.plainLines()[1].hasPrefix("│ Filter first")) + XCTAssertEqual(buffer[2, 1].style, TextStyle(dim: true)) + XCTAssertEqual(buffer[10, 1].style, TextStyle(dim: true)) + } + + func testReviewFirstRowOmitsSummaryWhenItDoesNotFit() { + var model = InteractiveReviewModel(categories: fixtureCategories()) + model.isSearching = true + model.updateQuery(String(repeating: "x", count: 30)) + let line = renderReview(model, width: 40, height: 12).plainLines()[1] + XCTAssertFalse(line.contains("shown")) + } + + func testReviewSearchFieldKeepsTheEndOfALongQueryNextToTheCursor() { + var model = InteractiveReviewModel(categories: fixtureCategories()) + model.isSearching = true + model.updateQuery("abcdefghijklmnopqrstuvwxyz0123456789") + let buffer = renderReview(model, width: 40, height: 12) + XCTAssertEqual(buffer.plainLines()[1], "│ Search …klmnopqrstuvwxyz0123456789 │") + XCTAssertEqual(buffer[37, 1].style, TextStyle(inverse: true)) + } + + func testReviewRowsShowCategoryGroupAndItemsWithFocus() { + var model = InteractiveReviewModel(categories: fixtureCategories()) + model.toggleFocused() + let lines = renderReview(model, width: 60, height: 24).plainLines() + XCTAssertEqual(lines[2], "│ Developer" + String(repeating: " ", count: 48) + "│") + XCTAssertTrue(lines[3].hasPrefix("│ Build caches")) + XCTAssertTrue(lines[3].hasSuffix("2 items │")) + XCTAssertTrue(lines[4].hasPrefix("│ › ● 1 KB /tmp/first")) + XCTAssertTrue(lines[5].hasPrefix("│ ○ 2 KB /tmp/second")) + XCTAssertTrue(lines[6].hasSuffix("1 item │")) + } + + func testReviewRowsStyleCategoryGroupAndFocusedItem() { + var model = InteractiveReviewModel(categories: fixtureCategories()) + model.toggleFocused() + let buffer = renderReview(model, width: 60, height: 24) + XCTAssertEqual(buffer[2, 2].style, TextStyle(bold: true)) + XCTAssertEqual(buffer[2, 3].style, TextStyle(dim: true)) + XCTAssertEqual(buffer[56, 3].style, TextStyle(dim: true)) + XCTAssertEqual(buffer[2, 4].style, TextStyle(color: .cyan, inverse: true)) + XCTAssertEqual(buffer[4, 4].style, TextStyle(color: .cyan, inverse: true)) + XCTAssertEqual(buffer[16, 4].style, TextStyle(inverse: true)) + XCTAssertEqual(buffer[55, 4].style, TextStyle(inverse: true)) + XCTAssertEqual(buffer[4, 5].style, TextStyle(dim: true)) + XCTAssertEqual(buffer[16, 5].style, .plain) + } + + func testReviewItemSizeIsColoredByMagnitudeExceptOnFocusedRow() { + let sizes: [Int64] = [5, 150_000_000, 2_000_000_000] + let items = sizes.enumerated().map { ScanItem(path: "/tmp/\($0.offset)", sizeBytes: $0.element, modified: nil, selected: false) } + var model = InteractiveReviewModel(categories: [ + CategoryScan(id: "c", title: "C", groups: [ToolGroup(tool: "T", items: items)]) + ]) + model.selectAllMatching() + model.move(2) + let buffer = renderReview(model, width: 60, height: 24) + XCTAssertEqual(buffer[7, 4].style, TextStyle(color: .green)) + XCTAssertEqual(buffer[7, 5].style, TextStyle(color: .yellow)) + XCTAssertEqual(buffer[7, 6].style, TextStyle(inverse: true)) + } + + func testReviewFocusedPathLineSitsBelowDividerAndHintsFollow() { + let lines = renderReview(InteractiveReviewModel(categories: fixtureCategories()), width: 80, height: 24).plainLines() + let divider = lines.firstIndex { $0.hasPrefix("├") }! + XCTAssertTrue(lines[divider].hasSuffix("┤")) + XCTAssertTrue(lines[divider + 1].hasPrefix("│ Path /tmp/first")) + XCTAssertTrue(lines[divider + 2].hasPrefix("│ ↑↓ move ←→ path Space toggle A all N none / search Enter remove")) + XCTAssertTrue(lines[divider + 3].hasPrefix("│ Esc/q cancel")) + XCTAssertTrue(lines[23].hasPrefix("╰")) + } + + func testReviewShowsNoMatchingPathWhenFilterFindsNothing() { + var model = InteractiveReviewModel(categories: fixtureCategories()) + model.updateQuery("zzz") + let lines = renderReview(model, width: 80, height: 24).plainLines() + XCTAssertTrue(lines.contains { $0.hasPrefix("│ Path No matching item") }) + } + + func testReviewScrollbarStaysInListRowsAndAppearsOnlyWhenRowsOverflow() { + let fitting = renderReview(InteractiveReviewModel(categories: fixtureCategories()), width: 40, height: 24) + XCTAssertFalse(fitting.plainLines().contains { $0.contains("┃") }) + + var model = InteractiveReviewModel(categories: manyItemsCategories(30)) + model.moveToEnd() + let lines = renderReview(model, width: 40, height: 12).plainLines() + let rowsWithThumb = lines.indices.filter { lines[$0].hasSuffix("┃") } + XCTAssertFalse(rowsWithThumb.isEmpty) + XCTAssertTrue(rowsWithThumb.allSatisfy { (2...5).contains($0) }) + XCTAssertTrue(rowsWithThumb.contains(5), "thumb is at the bottom when focus is on the last row") + } + + func testReviewKeepsFocusedRowVisibleAndCentered() { + var model = InteractiveReviewModel(categories: manyItemsCategories(30)) + model.move(15) + let lines = renderReview(model, width: 40, height: 12).plainLines() + XCTAssertTrue(lines[4].hasPrefix("│ › ") && lines[4].contains("/tmp/item15")) + } + + func testReviewGroupRowKeepsGapBeforeCountWhenToolNameIsLong() { + let categories = [ + CategoryScan( + id: "c", + title: "Cat", + groups: [ + ToolGroup( + tool: String(repeating: "T", count: 60), + items: (0..<12).map { ScanItem(path: "/tmp/i\($0)", sizeBytes: 1, modified: nil) } + ) + ] + ) + ] + let lines = renderReview(InteractiveReviewModel(categories: categories), width: 40, height: 12).plainLines() + let group = lines[3] + XCTAssertTrue(group.contains("…")) + XCTAssertTrue(group.hasSuffix(" 12 items │")) + XCTAssertEqual(Term.displayWidth(group), 40) + } + + func testReviewItemRowTruncatesLongPathInTheMiddle() { + let path = "/tmp/" + String(repeating: "a", count: 40) + "/" + String(repeating: "b", count: 40) + "/end" + let categories = [ + CategoryScan(id: "c", title: "Cat", groups: [ + ToolGroup(tool: "Tool", items: [ScanItem(path: path, sizeBytes: 1, modified: nil)]) + ]) + ] + let line = renderReview(InteractiveReviewModel(categories: categories), width: 40, height: 12).plainLines()[4] + XCTAssertEqual(Term.displayWidth(line), 40) + XCTAssertTrue(line.contains("a…")) + XCTAssertTrue(line.contains("…b") || line.contains("/end")) + XCTAssertFalse(line.hasSuffix("…│")) + } + + func testSelectionScreenKeepsTheCyanWindowAndListsEveryItem() { + var model = InteractiveReviewModel(categories: fixtureCategories()) + model.toggleFocused() + let buffer = renderReview(model, width: 80, height: 24) + let lines = buffer.plainLines() + XCTAssertTrue(lines[0].hasPrefix("╭─ Review removable files ")) + XCTAssertEqual(buffer[0, 0].style, TextStyle(dim: true)) + XCTAssertEqual(buffer[3, 0].style, TextStyle(color: .cyan, bold: true)) + XCTAssertTrue(lines.contains { $0.contains("/tmp/second") }) + XCTAssertTrue(lines.contains { $0.contains("/tmp/third") }) + XCTAssertTrue(lines.contains { $0.contains("Enter remove") }) + XCTAssertFalse(lines.contains { $0.contains("Enter review") || $0.contains("Final review") }) + } + + func testReviewBelowMinimumUsesTooSmallScreen() { + let model = InteractiveReviewModel(categories: fixtureCategories()) + let buffer = renderReview(model, width: 18, height: 4) + XCTAssertEqual(buffer, TooSmallScreen.render(title: "PureMac", exitHint: "Esc/q cancel", width: 18, height: 4)) + } + + func testFocusedPathWindowCanRevealEntirePath() { + let path = "/Users/example/Library/Caches/application/data" + let first = InteractiveReview.horizontalWindow(path, offset: 0, width: 20) + let last = InteractiveReview.horizontalWindow(path, offset: 30, width: 20) + + XCTAssertTrue(first.hasSuffix("›")) + XCTAssertTrue(last.hasPrefix("‹")) + XCTAssertLessThanOrEqual(Term.displayWidth(first), 20) + XCTAssertLessThanOrEqual(Term.displayWidth(last), 20) + } + + func testMenuSelectionClamps() { + var model = TerminalMenuModel(optionCount: 8) + model.move(99) + XCTAssertEqual(model.selection, 7) + model.move(-99) + XCTAssertEqual(model.selection, 0) + XCTAssertEqual(model.select(number: 7), 6) + XCTAssertEqual(model.selection, 6) + XCTAssertNil(model.select(number: 9)) + XCTAssertNil(model.select(number: 0)) + } + + func testMenuRenderPaginatesAndKeepsHintsVisible() { + let options = menuOptions(8) + for (width, height) in [(40, 12), (80, 24)] { + let lines = renderMenu(options: options, selection: 6, width: width, height: height).plainLines() + XCTAssertEqual(lines.count, height) + XCTAssertTrue(lines.contains { $0.contains("Tool 7") }) + XCTAssertTrue(lines.contains { $0.contains("Enter open") }) + XCTAssertTrue(lines.contains { $0.contains("1-9 open") }) + XCTAssertTrue(lines.allSatisfy { Term.displayWidth($0) == width }) + } + } + + func testMenuRenderCentersFocusedOptionOnPage() { + let lines = renderMenu(options: menuOptions(8), selection: 6, width: 40, height: 12).plainLines() + XCTAssertTrue(lines.contains { $0.contains("Tool 6") }) + XCTAssertTrue(lines.contains { $0.contains("Tool 7") }) + XCTAssertFalse(lines.contains { $0.contains("Tool 8") }) + XCTAssertFalse(lines.contains { $0.contains("Tool 5") }) + } + + func testMenuScrollbarTrackStopsAtLastOptionRow() { + let lines = renderMenu(options: menuOptions(8), selection: 7, width: 40, height: 12).plainLines() + XCTAssertTrue(lines[6].hasSuffix("┃"), "thumb sits beside the last option detail row") + XCTAssertFalse(lines[7].hasSuffix("┃"), "spare blank row is not part of the track") + } + + func testMenuRenderDrawsWindowWithTitleSubtitleAndFocus() { + let lines = renderMenu(options: menuOptions(2), selection: 0, width: 40, height: 12).plainLines() + XCTAssertTrue(lines[0].hasPrefix("╭─ PureMac ")) + XCTAssertTrue(lines[0].hasSuffix("─╮")) + XCTAssertEqual(lines[1], "│ Choose a task" + String(repeating: " ", count: 24) + "│") + XCTAssertTrue(lines[3].hasPrefix("│ › 1. Tool 1")) + XCTAssertTrue(lines[4].hasPrefix("│ Description 1")) + XCTAssertTrue(lines[5].hasPrefix("│ 2. Tool 2")) + XCTAssertTrue(lines[8].hasPrefix("├")) + XCTAssertTrue(lines[8].hasSuffix("┤")) + XCTAssertTrue(lines[11].hasPrefix("╰")) + XCTAssertFalse(lines.contains { $0.contains("PUREMAC") }) + } + + func testMenuRenderStylesFocusedRowAndDimsOthers() { + let colored = renderMenu(options: menuOptions(2), selection: 0, width: 40, height: 12) + .lines(colors: true) + XCTAssertTrue(colored[3].contains("\u{1B}[7m")) + XCTAssertTrue(colored[3].contains("\u{1B}[7;36m›")) + XCTAssertFalse(colored[5].contains("\u{1B}[7")) + XCTAssertTrue(colored[6].contains("\u{1B}[2mDescription 2")) + XCTAssertFalse(colored[4].contains("\u{1B}[2mDescription 1")) + } + + func testMenuRenderShowsScrollbarOnlyWhenOptionsDoNotFit() { + let overflowing = renderMenu(options: menuOptions(8), selection: 0, width: 40, height: 12).plainLines() + XCTAssertTrue(overflowing.contains { $0.hasSuffix("┃") }) + let fitting = renderMenu(options: menuOptions(2), selection: 0, width: 40, height: 12).plainLines() + XCTAssertFalse(fitting.contains { $0.contains("┃") }) + } + + func testMenuRenderTruncatesLongSubtitleWithEllipsis() { + let buffer = TerminalMenu.render( + title: "PureMac", + subtitle: "Your Mac, with room to work. / 1.1.1", + options: menuOptions(2), + selection: 0, + width: 40, + height: 12 + ) + XCTAssertTrue(buffer.plainLines()[1].hasSuffix("…" + " │")) + } + + func testMenuRenderFocusedRowInverseCoversContentWidthAndDetailIsPlain() { + let buffer = renderMenu(options: menuOptions(2), selection: 0, width: 40, height: 12) + XCTAssertTrue(buffer[37, 3].style.inverse) + XCTAssertFalse(buffer[1, 3].style.inverse) + XCTAssertEqual(buffer[8, 4].style, .plain) + XCTAssertEqual(buffer[2, 1].style, TextStyle(dim: true)) + } + + func testMenuNumbersAreCyanExceptOnTheFocusedRow() { + let buffer = renderMenu(options: menuOptions(2), selection: 0, width: 40, height: 12) + XCTAssertEqual(buffer[4, 3].symbol, "1") + XCTAssertEqual(buffer[4, 3].style, TextStyle(inverse: true)) + XCTAssertEqual(buffer[4, 5].symbol, "2") + XCTAssertEqual(buffer[5, 5].symbol, ".") + XCTAssertEqual(buffer[4, 5].style, TextStyle(color: .cyan)) + XCTAssertEqual(buffer[5, 5].style, TextStyle(color: .cyan)) + XCTAssertEqual(buffer[8, 5].style, .plain) + XCTAssertEqual(buffer.plainLines()[5], renderMenu(options: menuOptions(2), selection: 1, width: 40, height: 12).plainLines()[5].replacingOccurrences(of: "›", with: " ")) + } + + func testMenuModelStartsAtTheInitialSelectionClamped() { + XCTAssertEqual(TerminalMenuModel(optionCount: 6, selection: 4).selection, 4) + XCTAssertEqual(TerminalMenuModel(optionCount: 6, selection: 99).selection, 5) + XCTAssertEqual(TerminalMenuModel(optionCount: 6, selection: -3).selection, 0) + XCTAssertEqual(TerminalMenuModel(optionCount: 0, selection: 2).selection, 0) + XCTAssertEqual(TerminalMenuModel(optionCount: 6).selection, 0) + } + + func testMenuRenderBelowMinimumUsesTooSmallScreen() { + let buffer = renderMenu(options: menuOptions(2), selection: 0, width: 30, height: 8) + XCTAssertEqual(buffer, TooSmallScreen.render(title: "PureMac", exitHint: "Esc/q cancel", width: 30, height: 8)) + } + + func testTooSmallScreenFitsAtMinimumOnly() { + XCTAssertTrue(TooSmallScreen.fits(width: 40, height: 12)) + XCTAssertFalse(TooSmallScreen.fits(width: 39, height: 12)) + XCTAssertFalse(TooSmallScreen.fits(width: 40, height: 11)) + } + + func testTooSmallScreenRendersCopyTruncatedAndLimited() { + let lines = TooSmallScreen.render(title: "PureMac", exitHint: "Esc/q cancel", width: 18, height: 4).plainLines() + XCTAssertEqual(lines.count, 4) + XCTAssertTrue(lines.allSatisfy { Term.displayWidth($0) == 18 }) + XCTAssertTrue(lines[0].hasPrefix("PureMac")) + XCTAssertTrue(lines[1].hasPrefix("Terminal too small")) + XCTAssertTrue(lines[2].hasPrefix("Resize to at lea")) + XCTAssertEqual(lines[3].trimmingCharacters(in: .whitespaces), "") + + let tall = TooSmallScreen.render(title: "PureMac", exitHint: "Esc/q cancel", width: 30, height: 6).plainLines() + XCTAssertTrue(tall[2].hasPrefix("Resize to at least 40 x 12")) + XCTAssertTrue(tall[4].hasPrefix("Esc/q cancel")) + + let colored = TooSmallScreen.render(title: "PureMac", exitHint: "Esc/q cancel", width: 30, height: 6) + .lines(colors: true) + XCTAssertTrue(colored[0].contains("\u{1B}[1;36mPureMac")) + XCTAssertTrue(colored[2].contains("\u{1B}[2mResize")) + } + + func testFocusPointerShowsCyanInverseArrowOnlyWhenFocused() { + XCTAssertEqual(FocusPointer.span(focused: true), Span("›", TextStyle(color: .cyan, inverse: true))) + XCTAssertEqual(FocusPointer.span(focused: false), Span(" ")) + } + + func testSizeColumnWidthIsSharedWithPlainOutput() { + XCTAssertEqual(Term.displayWidth(Render.sizeCol(1_024)), Render.sizeColumnWidth) + } + + func testConfirmationOpensOnlyWithASelectionAndCancelFocused() { + var empty = InteractiveReviewModel(categories: fixtureCategories()) + XCTAssertFalse(empty.beginConfirmation()) + XCTAssertFalse(empty.isConfirming) + XCTAssertEqual(empty.status, "Select at least one item to continue") + + var model = selectedModel() + XCTAssertFalse(model.isConfirming) + XCTAssertTrue(model.beginConfirmation()) + XCTAssertTrue(model.isConfirming) + XCTAssertEqual(model.confirmation, .cancel) + XCTAssertEqual(model.status, "") + } + + func testConfirmationFocusMovesWithClamping() { + var model = selectedModel() + XCTAssertTrue(model.beginConfirmation()) + _ = model.handleConfirmationKey(.left) + XCTAssertEqual(model.confirmation, .cancel) + _ = model.handleConfirmationKey(.right) + XCTAssertEqual(model.confirmation, .remove) + _ = model.handleConfirmationKey(.right) + XCTAssertEqual(model.confirmation, .remove) + _ = model.handleConfirmationKey(.left) + XCTAssertEqual(model.confirmation, .cancel) + } + + func testCancelConfirmationReturnsToSelectionAndReopensOnCancel() { + var model = selectedModel() + XCTAssertTrue(model.beginConfirmation()) + _ = model.handleConfirmationKey(.right) + model.cancelConfirmation() + XCTAssertFalse(model.isConfirming) + XCTAssertTrue(model.beginConfirmation()) + XCTAssertEqual(model.confirmation, .cancel) + } + + func testEnterRightAfterOpeningOnlyClosesTheDialog() { + var model = selectedModel() + XCTAssertTrue(model.beginConfirmation()) + XCTAssertEqual(model.handleConfirmationKey(.enter), .handled) + XCTAssertFalse(model.isConfirming) + } + + func testEnterOnRemoveConfirms() { + var model = selectedModel() + XCTAssertTrue(model.beginConfirmation()) + XCTAssertEqual(model.handleConfirmationKey(.right), .handled) + XCTAssertEqual(model.handleConfirmationKey(.enter), .remove) + } + + func testTabAndArrowsMoveConfirmationFocus() { + var model = selectedModel() + XCTAssertTrue(model.beginConfirmation()) + XCTAssertEqual(model.handleConfirmationKey(.tab), .handled) + XCTAssertEqual(model.confirmation, .remove) + XCTAssertEqual(model.handleConfirmationKey(.left), .handled) + XCTAssertEqual(model.confirmation, .cancel) + XCTAssertEqual(model.handleConfirmationKey(.right), .handled) + XCTAssertEqual(model.confirmation, .remove) + } + + func testYConfirmsInOpenDialog() { + for key in [TerminalKey.character("y"), .character("Y")] { + var model = selectedModel() + XCTAssertTrue(model.beginConfirmation()) + XCTAssertEqual(model.handleConfirmationKey(key), .remove) + } + } + + func testNAndEscapeCloseTheDialogOnly() { + for key in [TerminalKey.character("n"), .character("N"), .escape] { + var model = selectedModel() + XCTAssertTrue(model.beginConfirmation()) + XCTAssertEqual(model.handleConfirmationKey(key), .handled) + XCTAssertFalse(model.isConfirming) + } + } + + func testQCancelsTheWholeFlowFromTheDialog() { + var model = selectedModel() + XCTAssertTrue(model.beginConfirmation()) + XCTAssertEqual(model.handleConfirmationKey(.character("q")), .quit) + } + + func testOtherKeysDoNothingWhileDialogIsOpen() { + var model = selectedModel() + XCTAssertTrue(model.beginConfirmation()) + let before = model.selectedAddresses.count + let keys: [TerminalKey] = [ + .up, .down, .pageUp, .pageDown, .home, .end, .space, .backspace, .delete, .unknown, + .character("a"), .character("/"), .character("x") + ] + for key in keys { + XCTAssertEqual(model.handleConfirmationKey(key), .handled) + XCTAssertTrue(model.isConfirming) + XCTAssertEqual(model.confirmation, .cancel) + } + XCTAssertEqual(model.selectedAddresses.count, before) + } + + func testConfirmationKeysAreIgnoredWhenDialogIsClosed() { + var model = selectedModel() + XCTAssertEqual(model.handleConfirmationKey(.character("y")), .passthrough) + XCTAssertEqual(model.handleConfirmationKey(.enter), .passthrough) + XCTAssertEqual(model.handleConfirmationKey(.character("q")), .passthrough) + } + + func testTabCyclesBetweenButtons() { + var model = selectedModel() + XCTAssertTrue(model.beginConfirmation()) + XCTAssertEqual(model.handleConfirmationKey(.tab), .handled) + XCTAssertEqual(model.confirmation, .remove) + XCTAssertEqual(model.handleConfirmationKey(.tab), .handled) + XCTAssertEqual(model.confirmation, .cancel) + } + + func testConfirmationTitleUsesSingularForOneItem() { + for (count, noun, title) in [(1, "item", "Remove 1 item?"), (1, "artifact", "Remove 1 artifact?"), + (0, "item", "Remove 0 items?"), (2, "artifact", "Remove 2 artifacts?")] { + XCTAssertEqual( + InteractiveReview.confirmationDialog(count: count, total: 1_000, hidden: 0, noun: noun, focus: .cancel).title, + title + ) + } + } + + func testConfirmationDialogIsDrawnOverFadedSelectionScreen() { + var model = selectedModel() + XCTAssertTrue(model.beginConfirmation()) + let buffer = renderReview(model, width: 80, height: 24) + let lines = buffer.plainLines() + XCTAssertTrue(lines.contains { $0.contains("Remove 2 items?") }) + XCTAssertTrue(lines.contains { $0.contains("3 KB will be deleted") }) + XCTAssertFalse(lines.contains { $0.contains("permanently") || $0.contains("cannot be undone") }) + XCTAssertFalse(lines.contains { $0.contains("hidden by search") }) + XCTAssertTrue(lines.contains { $0.contains("› Cancel ‹ [ Remove ]") }) + XCTAssertTrue(lines.contains { $0.contains("Enter select Esc back") }) + XCTAssertFalse(lines.contains { $0.contains("Y remove") || $0.contains("choose") }) + XCTAssertTrue(lines.contains { $0.contains("/tmp/first") }) + XCTAssertTrue(lines.contains { $0.contains("/tmp/second") }) + XCTAssertTrue(lines.contains { $0.contains("/tmp/thir") }) + XCTAssertTrue(lines.allSatisfy { Term.displayWidth($0) == 80 }) + XCTAssertEqual(buffer[0, 0].style, TextStyle(dim: true)) + XCTAssertEqual(buffer[3, 0].style, TextStyle(dim: true)) + } + + func testConfirmationDialogUsesYellowTitleAndBorder() { + var model = selectedModel() + XCTAssertTrue(model.beginConfirmation()) + let buffer = renderReview(model, width: 80, height: 24) + let frame = InteractiveReview.confirmationDialog(count: 2, total: 3_000, hidden: 0, noun: "item", focus: .cancel) + .frame(in: buffer.area) + XCTAssertEqual(buffer[frame.x, frame.y].style, TextStyle(color: .yellow)) + XCTAssertEqual(buffer[frame.x + 3, frame.y].style, TextStyle(color: .yellow, bold: true)) + } + + func testConfirmationDialogFocusStyles() { + var model = selectedModel() + XCTAssertTrue(model.beginConfirmation()) + let cancelFocused = renderReview(model, width: 80, height: 24).lines(colors: true).joined() + XCTAssertTrue(cancelFocused.contains("\u{1B}[7m› Cancel ‹")) + XCTAssertTrue(cancelFocused.contains("\u{1B}[31m[ Remove ]")) + + _ = model.handleConfirmationKey(.right) + let removeFocused = renderReview(model, width: 80, height: 24).lines(colors: true).joined() + XCTAssertTrue(removeFocused.contains("\u{1B}[7;31m› Remove ‹")) + XCTAssertFalse(removeFocused.contains("\u{1B}[7m› Cancel ‹")) + } + + func testConfirmationDialogUsesTheGivenNoun() { + var model = selectedModel() + XCTAssertTrue(model.beginConfirmation()) + let lines = InteractiveReview.render( + model: model, title: "Choose project artifacts", noun: "artifact", width: 80, height: 24 + ).plainLines() + XCTAssertTrue(lines.contains { $0.contains("Remove 2 artifacts?") }) + } + + func testConfirmationDialogFitsMinimumSize() { + var model = selectedModel() + XCTAssertTrue(model.beginConfirmation()) + let lines = renderReview(model, width: 40, height: 12).plainLines() + XCTAssertEqual(lines.count, 12) + XCTAssertTrue(lines.allSatisfy { Term.displayWidth($0) == 40 }) + XCTAssertTrue(lines.contains { $0.contains("› Cancel ‹ [ Remove ]") }) + XCTAssertTrue(lines.contains { $0.contains("Enter select Esc back") }) + } + + func testHiddenSelectedCountIsTheSelectedItemsTheSearchFiltersOut() { + var model = selectedModel() + XCTAssertEqual(model.hiddenSelectedCount, 0) + model.updateQuery("second") + XCTAssertEqual(model.hiddenSelectedCount, 1) + XCTAssertEqual(model.selectedCount, 2) + model.updateQuery("zzz") + XCTAssertEqual(model.hiddenSelectedCount, 2) + model.updateQuery("/tmp") + XCTAssertEqual(model.hiddenSelectedCount, 0) + } + + func testSearchDoesNotNarrowWhatTheConfirmationCountsAndRemoves() { + var model = selectedModel() + model.updateQuery("second") + XCTAssertTrue(model.beginConfirmation()) + XCTAssertEqual(model.selectedResult().flatMap { $0.selectedItems }.map(\.path), ["/tmp/first", "/tmp/second"]) + let lines = renderReview(model, width: 80, height: 24).plainLines() + XCTAssertTrue(lines.contains { $0.contains("Remove 2 items?") }) + XCTAssertTrue(lines.contains { $0.contains("3 KB will be deleted") }) + } + + func testHiddenBySearchRowAppearsDimOnlyWhenSelectedItemsAreFilteredOut() { + var model = selectedModel() + model.updateQuery("second") + XCTAssertTrue(model.beginConfirmation()) + let buffer = renderReview(model, width: 80, height: 24) + let lines = buffer.plainLines() + let row = lines.firstIndex { $0.contains("1 hidden by search") }! + let column = lines[row].distance(from: lines[row].startIndex, to: lines[row].range(of: "1 hidden")!.lowerBound) + XCTAssertEqual(buffer[column, row].style, TextStyle(dim: true)) + XCTAssertTrue(lines[row - 1].contains("3 KB will be deleted")) + + model.cancelConfirmation() + model.updateQuery("/tmp") + XCTAssertTrue(model.beginConfirmation()) + XCTAssertFalse(renderReview(model, width: 80, height: 24).plainLines().contains { $0.contains("hidden by search") }) + } + + func testConfirmationDialogFitsMinimumSizeWithHiddenRow() { + var model = selectedModel() + model.updateQuery("second") + XCTAssertTrue(model.beginConfirmation()) + let lines = renderReview(model, width: 40, height: 12).plainLines() + XCTAssertEqual(lines.count, 12) + XCTAssertTrue(lines.allSatisfy { Term.displayWidth($0) == 40 }) + XCTAssertTrue(lines.contains { $0.contains("1 hidden by search") }) + XCTAssertTrue(lines.contains { $0.contains("› Cancel ‹ [ Remove ]") }) + } + + func testSelectionWithoutDialogIsNotFaded() { + let buffer = renderReview(selectedModel(), width: 80, height: 24) + XCTAssertEqual(buffer[3, 0].style, TextStyle(color: .cyan, bold: true)) + XCTAssertFalse(buffer.plainLines().contains { $0.contains("Cancel") }) + } + + func testEnterOnTheSelectionOpensDialogAndNeverReturnsTheSelection() { + var model = selectedModel() + XCTAssertEqual(model.routeKey(.enter), .handled) + XCTAssertTrue(model.isConfirming) + XCTAssertEqual(model.confirmation, .cancel) + XCTAssertEqual(model.routeKey(.enter), .handled) + XCTAssertFalse(model.isConfirming) + } + + func testEnterWithNothingSelectedIsHandledWithoutOpeningTheDialog() { + var model = InteractiveReviewModel(categories: fixtureCategories()) + XCTAssertEqual(model.routeKey(.enter), .handled) + XCTAssertFalse(model.isConfirming) + XCTAssertEqual(model.status, "Select at least one item to continue") + } + + func testRouteKeyReturnsRemoveOnlyFromTheOpenDialog() { + var model = selectedModel() + XCTAssertEqual(model.routeKey(.character("y")), .passthrough) + XCTAssertTrue(model.beginConfirmation()) + XCTAssertEqual(model.routeKey(.character("y")), .remove) + XCTAssertEqual(model.routeKey(.character("q")), .quit) + XCTAssertEqual(model.routeKey(.space), .handled) + } + + func testEnterWhileSearchingPassesThroughAndOpensNoDialog() { + var model = selectedModel() + model.isSearching = true + XCTAssertEqual(model.routeKey(.enter), .passthrough) + XCTAssertFalse(model.isConfirming) + } + + func testTooSmallScreenLetsOnlyCloseAndCancelKeysReachTheDialog() { + var model = selectedModel() + XCTAssertTrue(model.beginConfirmation()) + for key in [TerminalKey.escape, .character("n"), .character("N"), .character("q"), .enter] { + XCTAssertTrue(model.acceptsKey(key, tooSmall: true), "\(key)") + } + for key in [TerminalKey.character("y"), .character("Y"), .tab, .right, .space, .left] { + XCTAssertFalse(model.acceptsKey(key, tooSmall: true), "\(key)") + } + _ = model.handleConfirmationKey(.right) + XCTAssertFalse(model.acceptsKey(.enter, tooSmall: true)) + XCTAssertTrue(model.acceptsKey(.character("y"), tooSmall: false)) + } + + func testTooSmallRuleDoesNotAffectOtherStates() { + let model = selectedModel() + XCTAssertTrue(model.acceptsKey(.enter, tooSmall: true)) + XCTAssertTrue(model.acceptsKey(.down, tooSmall: true)) + } + + private func selectedModel() -> InteractiveReviewModel { + .selected(fixtureCategories(), selecting: [0, 1]) + } + + private func menuOptions(_ count: Int) -> [(title: String, detail: String)] { + (1...count).map { (title: "Tool \($0)", detail: "Description \($0)") } + } + + private func renderMenu( + options: [(title: String, detail: String)], + selection: Int, + width: Int, + height: Int + ) -> Buffer { + TerminalMenu.render( + title: "PureMac", + subtitle: "Choose a task", + options: options, + selection: selection, + width: width, + height: height + ) + } + + private func renderReview(_ model: InteractiveReviewModel, width: Int, height: Int) -> Buffer { + InteractiveReview.render(model: model, title: "Review removable files", noun: "item", width: width, height: height) + } + + private func manyItemsCategories(_ count: Int) -> [CategoryScan] { + [ + CategoryScan( + id: "many", + title: "Many", + groups: [ + ToolGroup( + tool: "Items", + items: (0.. [CategoryScan] { + [ + CategoryScan( + id: "dev", + title: "Developer", + groups: [ + ToolGroup( + tool: "Build caches", + items: [ + ScanItem(path: "/tmp/first", sizeBytes: 1_000, modified: nil, selected: true), + ScanItem(path: "/tmp/second", sizeBytes: 2_000, modified: nil, selected: true) + ] + ), + ToolGroup( + tool: "Package caches", + items: [ + ScanItem(path: "/tmp/third", sizeBytes: 3_000, modified: nil, selected: false) + ] + ) + ] + ) + ] + } +} diff --git a/cli/Tests/puremacTests/OptimizeConfigTests.swift b/cli/Tests/puremacTests/OptimizeConfigTests.swift index 6dcdd03..52d1a57 100644 --- a/cli/Tests/puremacTests/OptimizeConfigTests.swift +++ b/cli/Tests/puremacTests/OptimizeConfigTests.swift @@ -1,6 +1,7 @@ import Foundation import XCTest @testable import puremac +import TerminalUI final class OptimizeConfigTests: XCTestCase { func testSnapshotParserAcceptsOnlyExactTimeMachineSnapshots() { @@ -36,6 +37,49 @@ final class OptimizeConfigTests: XCTestCase { XCTAssertEqual(Optimize.formatUptime(176_461), "2d 1h 1m") } + func testStatusSheetPlainLinesMatchTodaysLayoutExactly() { + let memory = MemoryStatus(total: 17_179_869_184, free: 4_294_967_296, inactive: 1_073_741_824, + active: 2_147_483_648, wired: 3_221_225_472, compressed: 536_870_912) + let volume = VolumeStatus(availableNow: 107_374_182_400, availableForImportantUsage: 161_061_273_600, total: 536_870_912_000) + let sheet = Optimize.statusSheet(memory: memory, pressure: .normal, volume: volume, machine: testMachine) + XCTAssertEqual(sheet.plainLines(colors: false), goldenStatus) + XCTAssertEqual( + Optimize.statusSheet(memory: memory, pressure: nil, volume: volume, machine: testMachine).plainLines(colors: false), + goldenStatus.filter { !$0.hasPrefix(" Pressure") } + ) + } + + func testRamAndPurgeableSheetsPlainLinesMatchTodaysLayoutExactly() { + let memory = MemoryStatus(total: 17_179_869_184, free: 4_294_967_296, inactive: 1_073_741_824, + active: 2_147_483_648, wired: 3_221_225_472, compressed: 536_870_912) + let volume = VolumeStatus(availableNow: 107_374_182_400, availableForImportantUsage: 161_061_273_600, total: 536_870_912_000) + let ram = Optimize.memorySheet(memory, pressure: .normal) + XCTAssertEqual(ram.plainLines(withHeader: false, colors: false), goldenRam) + let purgeable = Optimize.storageSheet(volume) + XCTAssertEqual(purgeable.plainLines(withHeader: false, colors: false), goldenPurgeable) + } + + func testStatusViewPrintsTheSheetLinesWithoutEscapeCodes() throws { + let output = try captureStdout { try Optimize.parse([]).run() } + let lines = output.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + XCTAssertEqual(lines.prefix(4), ["Mac status", "Read-only measurements from macOS", "", "This Mac"]) + XCTAssertEqual(lines.filter { $0 == "This Mac" || $0 == "Memory" || $0 == "Storage" }, ["This Mac", "Memory", "Storage"]) + XCTAssertEqual(lines.filter { $0.hasPrefix(" Uptime ") }.count, 1) + XCTAssertEqual(lines.last(where: { !$0.isEmpty }), "No maintenance ran. Use `puremac clean --dry-run` to review removable files.") + XCTAssertTrue(lines.allSatisfy { !$0.contains("\u{1B}") }) + } + + func testRamAndPurgeableViewsKeepTheirClosingLines() throws { + let ram = try captureStdout { try Optimize.parse(["ram"]).run() } + XCTAssertTrue(ram.hasPrefix("Memory\n ")) + XCTAssertTrue(ram.contains("\n Installed ")) + XCTAssertTrue(ram.hasSuffix("\n\nNo memory was purged. macOS reclaims inactive memory when applications need it.\n")) + let purgeable = try captureStdout { try Optimize.parse(["purgeable"]).run() } + XCTAssertTrue(purgeable.hasPrefix("Storage\n ")) + XCTAssertTrue(purgeable.contains("\n Free now ")) + XCTAssertTrue(purgeable.hasSuffix("\n\nNo storage was changed. APFS managed capacity is an estimate controlled by macOS.\n")) + } + func testManagedCapacityNeverBecomesNegative() { XCTAssertEqual( VolumeStatus(availableNow: 50, availableForImportantUsage: 80, total: 100) @@ -148,3 +192,154 @@ final class OptimizeConfigTests: XCTestCase { return directory } } + +final class SystemStatusTests: XCTestCase { + private let memory = MemoryStatus( + total: 16_000_000_000, free: 4_000_000_000, inactive: 1_000_000_000, + active: 2_000_000_000, wired: 3_000_000_000, compressed: 500_000_000 + ) + + func testPressureLevelMapping() { + XCTAssertEqual(MemoryPressure(level: 1), .normal) + XCTAssertEqual(MemoryPressure(level: 2), .warning) + XCTAssertEqual(MemoryPressure(level: 4), .critical) + XCTAssertNil(MemoryPressure(level: 0)) + XCTAssertNil(MemoryPressure(level: 3)) + XCTAssertEqual(MemoryPressure.normal.label, "Normal") + XCTAssertEqual(MemoryPressure.warning.color, .yellow) + XCTAssertEqual(MemoryPressure.critical.color, .red) + XCTAssertEqual(MemoryPressure.normal.color, .green) + } + + func testMemoryUsedFraction() { + XCTAssertEqual(memory.usedFraction, 0.75, accuracy: 0.0001) + XCTAssertEqual(MemoryStatus(total: 0, free: 0, inactive: 0, active: 0, wired: 0, compressed: 0).usedFraction, 0) + } + + func testVolumeFreeColorFollowsTheFreeShare() { + func color(free: Int64) -> TerminalColor { + VolumeStatus(availableNow: free, availableForImportantUsage: free, total: 100).freeColor + } + XCTAssertEqual(color(free: 9), .red) + XCTAssertEqual(color(free: 10), .yellow) + XCTAssertEqual(color(free: 19), .yellow) + XCTAssertEqual(color(free: 20), .green) + let empty = VolumeStatus(availableNow: 0, availableForImportantUsage: 0, total: 0) + XCTAssertEqual(empty.usedFraction, 0) + XCTAssertEqual(empty.freeFraction, 1) + XCTAssertEqual(empty.freeColor, .green) + XCTAssertEqual(VolumeStatus(availableNow: 25, availableForImportantUsage: 25, total: 100).freeFraction, 0.25, accuracy: 0.0001) + XCTAssertEqual(VolumeStatus(availableNow: 25, availableForImportantUsage: 25, total: 100).usedFraction, 0.75, accuracy: 0.0001) + } + + func testMemorySectionHasGaugeColoredByPressureThenPressureAndTodaysRows() { + let rows = Optimize.memorySection(memory, pressure: .warning).rows + guard case .gauge(let fraction, let color, let text) = rows[0] else { return XCTFail("gauge first") } + XCTAssertEqual(fraction, 0.75, accuracy: 0.0001) + XCTAssertEqual(color, .yellow) + XCTAssertEqual(text, "11.2 GB in use of 14.9 GB") + guard case .metric("Pressure", let value, nil) = rows[1] else { return XCTFail("pressure second") } + XCTAssertEqual(value, [Span("Warning", TextStyle(color: .yellow))]) + XCTAssertEqual(rows.dropFirst(2).compactMap { row -> String? in + if case .metric(let label, _, _) = row { return label } + return nil + }, ["Installed", "Free now", "Active", "Inactive", "Compressed", "Wired"]) + } + + func testMemorySectionWithoutPressureHasPlainGaugeAndNoPressureRow() { + let rows = Optimize.memorySection(memory, pressure: nil).rows + guard case .gauge(_, let color, _) = rows[0] else { return XCTFail("gauge first") } + XCTAssertNil(color) + XCTAssertEqual(rows.count, 7) + } + + func testStorageSectionColorsGaugeAndFreeValueByFreeShare() { + let volume = VolumeStatus(availableNow: 5, availableForImportantUsage: 25, total: 100) + let rows = Optimize.storageSection(volume).rows + guard case .gauge(let fraction, let color, let text) = rows[0] else { return XCTFail("gauge first") } + XCTAssertEqual(fraction, 0.95, accuracy: 0.0001) + XCTAssertEqual(color, .red) + XCTAssertEqual(text, "\(ByteCount.human(5)) free of \(ByteCount.human(100))") + guard case .metric("Free now", let value, nil) = rows[1] else { return XCTFail("free second") } + XCTAssertEqual(value, [Span(ByteCount.human(5), TextStyle(color: .red))]) + XCTAssertEqual(rows.dropFirst(2).count, 3) + guard case .message(let note) = rows[4] else { return XCTFail("note last") } + XCTAssertEqual(note, [Span("Managed capacity may include purgeable files and is not guaranteed free space.", TextStyle(dim: true))]) + } + + func testStorageSectionOmitsTheNoteWithoutManagedCapacity() { + let rows = Optimize.storageSection(VolumeStatus(availableNow: 50, availableForImportantUsage: 50, total: 100)).rows + XCTAssertEqual(rows.count, 4) + } + + func testStatusSheetHasTitleSubtitleSectionsAndFootnote() { + let volume = VolumeStatus(availableNow: 50, availableForImportantUsage: 50, total: 100) + let sheet = Optimize.statusSheet(memory: memory, pressure: .normal, volume: volume, machine: testMachine) + XCTAssertEqual(sheet.title, "Mac status") + XCTAssertEqual(sheet.subtitle, "Read-only measurements from macOS") + XCTAssertEqual(sheet.sections.map(\.title), ["This Mac", "Memory", "Storage"]) + XCTAssertEqual(sheet.footnote, "No maintenance ran. Use `puremac clean --dry-run` to review removable files.") + } +} + +private let goldenStatus: [String] = [ + "Mac status", + "Read-only measurements from macOS", + "", + "This Mac", + " Chip Apple M4 Pro", + " Cores 14 cores (10 performance, 4 efficiency)", + " Memory 24 GB", + " macOS 26.0.1 (25A354)", + " Model Mac16,7", + " Uptime 2d 1h 1m", + "", + "Memory", + " ███████████████░░░░░ 12 GB in use of 16 GB", + " Pressure Normal", + " Installed 16 GB", + " Free now 4 GB", + " Active 2 GB", + " Inactive 1 GB", + " Compressed 512 MB", + " Wired 3 GB", + "", + "Storage", + " ████████████████░░░░ 107.4 GB free of 536.9 GB", + " Free now 107.4 GB", + " Total 536.9 GB", + " Managed by macOS 53.7 GB", + " Managed capacity may include purgeable files and is not guaranteed free space.", + "", + "No maintenance ran. Use `puremac clean --dry-run` to review removable files.", +] + +private let goldenRam: [String] = [ + "Memory", + " ███████████████░░░░░ 12 GB in use of 16 GB", + " Pressure Normal", + " Installed 16 GB", + " Free now 4 GB", + " Active 2 GB", + " Inactive 1 GB", + " Compressed 512 MB", + " Wired 3 GB", + "", + "No memory was purged. macOS reclaims inactive memory when applications need it.", +] + +private let goldenPurgeable: [String] = [ + "Storage", + " ████████████████░░░░ 107.4 GB free of 536.9 GB", + " Free now 107.4 GB", + " Total 536.9 GB", + " Managed by macOS 53.7 GB", + " Managed capacity may include purgeable files and is not guaranteed free space.", + "", + "No storage was changed. APFS managed capacity is an estimate controlled by macOS.", +] + +private let testMachine = MachineInfo( + chip: "Apple M4 Pro", cores: "14 cores (10 performance, 4 efficiency)", memory: 25_769_803_776, + macOS: "26.0.1 (25A354)", model: "Mac16,7", uptime: 176_461 +) diff --git a/cli/Tests/puremacTests/PathListEditorTests.swift b/cli/Tests/puremacTests/PathListEditorTests.swift new file mode 100644 index 0000000..af05dc9 --- /dev/null +++ b/cli/Tests/puremacTests/PathListEditorTests.swift @@ -0,0 +1,631 @@ +import XCTest +@testable import puremac +import TerminalUI + +private struct Rejected: LocalizedError { + var errorDescription: String? { "that path is not allowed" } +} + +private final class FakeList { + var paths: [String] + var added: [String] = [] + var removed: [String] = [] + var addError: Error? + var loadError: Error? + var sorted = false + + init(_ paths: [String]) { self.paths = paths } + + func config(confirmsRemoval: Bool = false) -> PathListConfig { + PathListConfig( + title: "Project folders", + emptyText: "Nothing here yet. Press A to add one.", + load: { + if let error = self.loadError { throw error } + return self.paths + }, + add: { path in + if let error = self.addError { throw error } + self.added.append(path) + self.paths.append(path) + if self.sorted { self.paths.sort() } + }, + remove: { path in + self.removed.append(path) + self.paths.removeAll { $0 == path } + }, + removalPrompt: confirmsRemoval + ? RemovalPrompt(title: { "Stop protecting \($0)?" }, button: "Remove protection") + : nil + ) + } +} + +final class PathListEditorTests: XCTestCase { + private let existing = FileManager.default.temporaryDirectory.resolvingSymlinksInPath().path + + private func editor(_ list: FakeList, confirmsRemoval: Bool = false) -> PathListEditor { + PathListEditor(list.config(confirmsRemoval: confirmsRemoval)) + } + + private func type(_ text: String, into editor: inout PathListEditor) { + for character in text { _ = editor.handle(character == " " ? .space : .character(character)) } + } + + private func lines(_ editor: PathListEditor, width: Int = 80, height: Int = 24) -> [String] { + editor.render(width: width, height: height).plainLines() + } + + func testStartsWithLoadedPathsAndFirstFocused() { + let editor = editor(FakeList([existing, "/tmp/puremac-b"])) + XCTAssertEqual(editor.paths, [existing, "/tmp/puremac-b"]) + XCTAssertEqual(editor.focus, 0) + } + + func testMovingStaysInsideTheList() { + var editor = editor(FakeList(["/a", "/b"])) + _ = editor.handle(.up) + XCTAssertEqual(editor.focus, 0) + _ = editor.handle(.down) + _ = editor.handle(.down) + XCTAssertEqual(editor.focus, 1) + } + + func testEscapeLeavesAndOtherKeysDoNot() { + var editor = editor(FakeList(["/a"])) + XCTAssertFalse(editor.handle(.down)) + XCTAssertFalse(editor.handle(.character("x"))) + XCTAssertTrue(editor.handle(.escape)) + } + + func testProjectFoldersLabelTheInputAddFolderAndProtectedPathsAddPath() throws { + XCTAssertEqual(PathListConfig.projectFolders.inputLabel, "Add folder") + XCTAssertEqual(PathListConfig.protectedPaths.inputLabel, "Add path") + var config = FakeList([]).config() + config.inputLabel = "Add path" + var editor = PathListEditor(config) + _ = editor.handle(.character("a")) + XCTAssertTrue(lines(editor)[21].hasPrefix("│ Add path ~/ "), lines(editor)[21]) + } + + func testFocusMovesToTheNewEntryAfterAnAdd() { + let list = FakeList(["/b", "/d"]) + list.sorted = true + var editor = editor(list) + _ = editor.handle(.character("a")) + for _ in 0..<2 { _ = editor.handle(.backspace) } + type("/c", into: &editor) + _ = editor.handle(.enter) + XCTAssertEqual(editor.paths, ["/b", "/c", "/d"]) + XCTAssertEqual(editor.focus, 1) + } + + func testFocusMovesToTheEndWhenTheNewEntryIsAppended() { + var editor = editor(FakeList(["/a", "/b"])) + _ = editor.handle(.character("a")) + type("x", into: &editor) + _ = editor.handle(.enter) + XCTAssertEqual(editor.focus, 2) + } + + func testFocusStaysWhenAnAddFails() { + let list = FakeList(["/a", "/b"]) + list.addError = Rejected() + var editor = editor(list) + _ = editor.handle(.down) + _ = editor.handle(.character("a")) + _ = editor.handle(.enter) + XCTAssertEqual(editor.focus, 1) + } + + func testProtectedPathsFocusFollowsTheSortedPosition() throws { + try withConfigHome { _ in + var store = try IgnoreStore(fileURL: AppPaths.ignoreFile) + _ = try store.add("/tmp/puremac-a") + _ = try store.add("/tmp/puremac-c") + var editor = PathListEditor(.protectedPaths) + _ = editor.handle(.character("a")) + for _ in 0..<2 { _ = editor.handle(.backspace) } + type("/tmp/puremac-b", into: &editor) + _ = editor.handle(.enter) + XCTAssertEqual(editor.paths, ["/tmp/puremac-a", "/tmp/puremac-b", "/tmp/puremac-c"]) + XCTAssertEqual(editor.focus, 1) + } + } + + func testAOpensTheInputPrefilledWithTilde() { + var editor = editor(FakeList([])) + _ = editor.handle(.character("a")) + XCTAssertEqual(editor.input?.text, "~/") + var other = self.editor(FakeList([])) + _ = other.handle(.character("A")) + XCTAssertEqual(other.input?.text, "~/") + } + + func testInputRowShowsLabelTextAndInverseCursorBelowTheStatusRow() { + var editor = editor(FakeList(["/a"])) + _ = editor.handle(.character("a")) + type("pr", into: &editor) + let buffer = editor.render(width: 80, height: 24) + XCTAssertTrue(buffer.plainLines()[21].hasPrefix("│ Add folder ~/pr ")) + XCTAssertEqual(buffer[2, 21].style, TextStyle(color: .cyan)) + XCTAssertEqual(buffer[18, 21].style, TextStyle(inverse: true)) + } + + func testTypingBackspaceAndEnterAddsThePathAndClosesTheInput() { + let list = FakeList([]) + var editor = editor(list) + _ = editor.handle(.character("a")) + type("xy", into: &editor) + _ = editor.handle(.backspace) + type("z", into: &editor) + XCTAssertFalse(editor.handle(.enter)) + XCTAssertEqual(list.added, ["~/xz"]) + XCTAssertNil(editor.input) + XCTAssertNil(editor.error) + XCTAssertEqual(editor.paths, ["~/xz"]) + } + + func testRejectedPathShowsYellowErrorAndKeepsTheInputOpen() { + let list = FakeList(["/a"]) + list.addError = Rejected() + var editor = editor(list) + _ = editor.handle(.character("a")) + _ = editor.handle(.enter) + XCTAssertEqual(editor.input?.text, "~/") + XCTAssertEqual(editor.error, "that path is not allowed") + let buffer = editor.render(width: 80, height: 24) + XCTAssertTrue(buffer.plainLines()[20].hasPrefix("│ that path is not allowed")) + XCTAssertEqual(buffer[2, 20].style, TextStyle(color: .yellow)) + XCTAssertTrue(buffer.plainLines()[21].hasPrefix("│ Add folder ~/ ")) + XCTAssertEqual(list.paths, ["/a"]) + } + + func testLongErrorWrapsOverTwoRowsAboveTheInput() { + let list = FakeList([]) + list.addError = PathListError("Protected paths cannot be empty, the filesystem root, or contain only a very short folder, pick another folder instead") + var editor = editor(list) + _ = editor.handle(.character("a")) + _ = editor.handle(.enter) + let rows = lines(editor) + XCTAssertTrue(rows[19].hasPrefix("│ Protected paths cannot be empty"), rows[19]) + XCTAssertTrue(rows[20].contains("instead"), rows[20]) + XCTAssertTrue(rows[21].hasPrefix("│ Add folder ~/"), rows[21]) + } + + func testEditingClearsTheErrorAndKeepsTheInput() { + let list = FakeList([]) + list.addError = Rejected() + var editor = editor(list) + _ = editor.handle(.character("a")) + _ = editor.handle(.enter) + _ = editor.handle(.character("x")) + XCTAssertNil(editor.error) + XCTAssertEqual(editor.input?.text, "~/x") + } + + func testEscapeInTheInputCancelsItWithoutLeaving() { + var editor = editor(FakeList([])) + _ = editor.handle(.character("a")) + XCTAssertFalse(editor.handle(.escape)) + XCTAssertNil(editor.input) + XCTAssertTrue(editor.handle(.escape)) + } + + func testListKeysTypeIntoTheInputInsteadOfActing() { + var editor = editor(FakeList(["/a", "/b"])) + _ = editor.handle(.character("a")) + type("dd", into: &editor) + XCTAssertEqual(editor.input?.text, "~/dd") + XCTAssertEqual(editor.paths, ["/a", "/b"]) + } + + func testDRemovesTheFocusedPathWithoutAskingAndKeepsFocusInRange() { + let list = FakeList(["/a", "/b"]) + var editor = editor(list) + _ = editor.handle(.down) + _ = editor.handle(.character("d")) + XCTAssertEqual(list.removed, ["/b"]) + XCTAssertEqual(editor.paths, ["/a"]) + XCTAssertEqual(editor.focus, 0) + } + + func testRemoveOnEmptyListDoesNothing() { + let list = FakeList([]) + var editor = editor(list, confirmsRemoval: true) + _ = editor.handle(.character("d")) + XCTAssertTrue(list.removed.isEmpty) + XCTAssertNil(editor.confirmation) + } + + func testRemovalPromptFocusesCancelAndShowsTheDialog() { + var editor = editor(FakeList(["\(Safety.home)/secret"]), confirmsRemoval: true) + _ = editor.handle(.character("d")) + XCTAssertEqual(editor.confirmation?.index, 0) + let text = lines(editor).joined(separator: "\n") + XCTAssertTrue(text.contains("Stop protecting ~/secret?"), text) + XCTAssertTrue(text.contains("› Cancel ‹"), text) + XCTAssertTrue(text.contains("[ Remove protection ]"), text) + XCTAssertTrue(text.contains("Enter select"), text) + XCTAssertTrue(text.contains("Esc back"), text) + } + + func testRemovalPromptEnterOnCancelAndEscapeKeepThePath() { + let list = FakeList(["/a"]) + var editor = editor(list, confirmsRemoval: true) + _ = editor.handle(.character("d")) + XCTAssertFalse(editor.handle(.enter)) + XCTAssertNil(editor.confirmation) + _ = editor.handle(.character("d")) + XCTAssertFalse(editor.handle(.escape)) + XCTAssertNil(editor.confirmation) + XCTAssertTrue(list.removed.isEmpty) + } + + func testRemovalPromptNAndQCancelWithoutRemovingOrLeaving() { + for key in [TerminalKey.character("n"), .character("N"), .character("q"), .character("Q")] { + let list = FakeList(["/a"]) + var editor = editor(list, confirmsRemoval: true) + _ = editor.handle(.character("d")) + XCTAssertFalse(editor.handle(key), "\(key)") + XCTAssertNil(editor.confirmation, "\(key)") + XCTAssertTrue(list.removed.isEmpty, "\(key)") + } + } + + func testRemovalPromptRemoveButtonIsNotTriggeredByForwardDelete() { + let list = FakeList(["/a"]) + var editor = editor(list, confirmsRemoval: true) + _ = editor.handle(.character("d")) + XCTAssertFalse(editor.handle(.delete)) + XCTAssertNotNil(editor.confirmation) + XCTAssertTrue(list.removed.isEmpty) + } + + func testRemovalPromptEnterOnRemoveDeletesThePath() { + let list = FakeList(["/a", "/b"]) + var editor = editor(list, confirmsRemoval: true) + _ = editor.handle(.character("d")) + _ = editor.handle(.right) + XCTAssertEqual(editor.confirmation?.index, 1) + _ = editor.handle(.enter) + XCTAssertEqual(list.removed, ["/a"]) + XCTAssertNil(editor.confirmation) + XCTAssertEqual(editor.paths, ["/b"]) + } + + func testRemovalPromptSwallowsListKeys() { + let list = FakeList(["/a", "/b"]) + var editor = editor(list, confirmsRemoval: true) + _ = editor.handle(.character("d")) + _ = editor.handle(.down) + _ = editor.handle(.character("a")) + XCTAssertEqual(editor.focus, 0) + XCTAssertNil(editor.input) + } + + func testFailedRemovalShowsTheError() { + struct Failing: Error, LocalizedError { var errorDescription: String? { "disk is read-only" } } + var config = FakeList(["/a"]).config() + config.remove = { _ in throw Failing() } + var editor = PathListEditor(config) + _ = editor.handle(.character("d")) + XCTAssertEqual(editor.error, "disk is read-only") + XCTAssertEqual(editor.paths, ["/a"]) + let rows = lines(editor) + XCTAssertTrue(rows[20].hasPrefix("├"), rows[20]) + XCTAssertTrue(rows[21].hasPrefix("│ disk is read-only"), rows[21]) + } + + func testBrowseModeWithoutAnErrorPutsTheHintsRightBelowTheDivider() { + let rows = lines(editor(FakeList(["/a"]))) + XCTAssertTrue(rows[21].hasPrefix("├"), rows[21]) + XCTAssertTrue(rows[22].contains("↑↓ move"), rows[22]) + } + + func testLoadFailureShowsAnEmptyListWithTheError() { + let list = FakeList([]) + list.loadError = Rejected() + let editor = editor(list) + XCTAssertEqual(editor.paths, []) + XCTAssertEqual(editor.loadError, "that path is not allowed") + XCTAssertNil(editor.error) + } + + func testLoadFailureStaysInPlaceOfTheEmptyStateAfterAKey() { + let list = FakeList([]) + list.loadError = Rejected() + var editor = editor(list) + _ = editor.handle(.down) + let buffer = editor.render(width: 80, height: 24) + let rows = buffer.plainLines() + XCTAssertTrue(rows[1].hasPrefix("│ that path is not allowed"), rows[1]) + XCTAssertEqual(buffer[2, 1].style, TextStyle(color: .red)) + XCTAssertFalse(rows.joined().contains("Nothing here yet")) + } + + func testLongLoadFailureWrapsInsideTheList() { + let list = FakeList([]) + list.loadError = PathListError("Configuration file is not a valid PureMac settings file: /Users/someone/.config/puremac/config.json") + let rows = lines(editor(list)) + XCTAssertTrue(rows[1].hasPrefix("│ Configuration file is not a valid PureMac settings file: "), rows[1]) + XCTAssertTrue(rows[2].hasPrefix("│ /Users/someone/.config/puremac/config.json "), rows[2]) + } + + func testLoadFailureGoesAwayOnceALoadSucceeds() { + let list = FakeList([]) + list.loadError = Rejected() + var editor = editor(list) + list.loadError = nil + _ = editor.handle(.character("a")) + _ = editor.handle(.enter) + XCTAssertNil(editor.loadError) + XCTAssertEqual(editor.paths, ["~/"]) + } + + func testReloadFailureAfterAnAddClearsTheStaleList() { + let list = FakeList(["/a"]) + var editor = editor(list) + list.loadError = Rejected() + _ = editor.handle(.character("a")) + _ = editor.handle(.enter) + XCTAssertNil(editor.input) + XCTAssertEqual(editor.paths, []) + XCTAssertEqual(editor.loadError, "that path is not allowed") + } + + func testRowsAreShortenedWithFocusPointerAndMissingNote() { + let missing = "\(Safety.home)/puremac-missing-\(UUID().uuidString)" + let shown = Render.shorten(missing) + let editor = editor(FakeList([missing, existing])) + let buffer = editor.render(width: 80, height: 24) + let rows = buffer.plainLines() + XCTAssertTrue(rows[1].hasPrefix("│ › \(shown) not found "), rows[1]) + XCTAssertTrue(rows[1].hasPrefix("│ › ~/puremac-missing-"), rows[1]) + XCTAssertTrue(rows[1].hasSuffix(" │"), rows[1]) + XCTAssertTrue(rows[2].hasPrefix("│ \(existing)"), rows[2]) + XCTAssertFalse(rows[2].contains("not found")) + XCTAssertEqual(buffer[2, 1].style, TextStyle(color: .cyan, inverse: true)) + XCTAssertEqual(buffer[4, 1].style, TextStyle(inverse: true)) + XCTAssertEqual(buffer[3 + shown.count, 1].style, TextStyle(inverse: true)) + XCTAssertEqual(buffer[4 + shown.count, 1].style, TextStyle(dim: true, inverse: true)) + XCTAssertEqual(buffer[13 + shown.count, 1].style, TextStyle(dim: true, inverse: true)) + XCTAssertEqual(buffer[14 + shown.count, 1].style, TextStyle(inverse: true)) + XCTAssertEqual(buffer[77, 1].style, TextStyle(inverse: true)) + XCTAssertEqual(buffer[4, 2].style, .plain) + } + + func testTitleHintsAndEmptyState() { + let rows = lines(editor(FakeList([]))) + XCTAssertTrue(rows[0].contains("Project folders"), rows[0]) + XCTAssertTrue(rows[1].hasPrefix("│ Nothing here yet. Press A to add one."), rows[1]) + XCTAssertTrue(rows[22].contains("↑↓ move A add D remove Esc back"), rows[22]) + } + + func testInputModeShowsItsOwnHints() { + var editor = editor(FakeList([])) + _ = editor.handle(.character("a")) + let row = lines(editor)[22] + XCTAssertTrue(row.contains("Enter add Esc cancel"), row) + XCTAssertFalse(row.contains("remove"), row) + } + + func testScrollbarAppearsOnlyWhenTheListDoesNotFit() { + let many = FakeList((0..<40).map { "/p\($0)" }) + XCTAssertTrue(lines(editor(many), height: 12)[1].hasSuffix("┃")) + XCTAssertFalse(lines(editor(FakeList(["/a"])), height: 12)[1].hasSuffix("┃")) + } + + func testFocusedRowScrollsIntoView() { + var editor = editor(FakeList((0..<40).map { "/p\($0)" })) + for _ in 0..<30 { _ = editor.handle(.down) } + let text = lines(editor, height: 12).joined(separator: "\n") + XCTAssertTrue(text.contains("› /p30"), text) + } + + func testTooSmallTerminalShowsTheTooSmallScreen() { + let text = lines(editor(FakeList(["/a"])), width: 39, height: 24).joined(separator: "\n") + XCTAssertTrue(text.contains("Terminal too small"), text) + XCTAssertTrue(text.contains("Esc back"), text) + XCTAssertFalse(text.contains("/a"), text) + } + + func testTooSmallListAcceptsOnlyEscape() { + let editor = editor(FakeList(["/a"])) + XCTAssertTrue(editor.acceptsKey(.escape, tooSmall: true)) + for key in [TerminalKey.character("d"), .character("a"), .enter, .down, .character("q")] { + XCTAssertFalse(editor.acceptsKey(key, tooSmall: true), "\(key)") + XCTAssertTrue(editor.acceptsKey(key, tooSmall: false), "\(key)") + } + } + + func testTooSmallInputAcceptsOnlyEscape() { + var editor = editor(FakeList([])) + _ = editor.handle(.character("a")) + XCTAssertTrue(editor.acceptsKey(.escape, tooSmall: true)) + XCTAssertFalse(editor.acceptsKey(.character("x"), tooSmall: true)) + XCTAssertFalse(editor.acceptsKey(.enter, tooSmall: true)) + } + + func testTooSmallDialogAcceptsEscapeAndEnterOnCancelOnly() { + var editor = editor(FakeList(["/a"]), confirmsRemoval: true) + _ = editor.handle(.character("d")) + XCTAssertTrue(editor.acceptsKey(.escape, tooSmall: true)) + XCTAssertTrue(editor.acceptsKey(.enter, tooSmall: true)) + XCTAssertFalse(editor.acceptsKey(.right, tooSmall: true)) + for key in ["n", "N", "q", "Q"] { XCTAssertTrue(editor.acceptsKey(.character(Character(key)), tooSmall: true), key) } + XCTAssertFalse(editor.acceptsKey(.character("y"), tooSmall: true)) + _ = editor.handle(.right) + XCTAssertFalse(editor.acceptsKey(.enter, tooSmall: true)) + XCTAssertTrue(editor.acceptsKey(.enter, tooSmall: false)) + } + + func testForwardDeleteDoesNotLeaveTheList() { + var editor = editor(FakeList(["/a"])) + XCTAssertFalse(editor.handle(.delete)) + } + + func testLeavesOnQLeftAndBackspaceToo() { + for key in [TerminalKey.character("q"), .character("Q"), .left, .backspace] { + var editor = editor(FakeList(["/a"])) + XCTAssertTrue(editor.handle(key), "\(key)") + } + } + + func testQTypesIntoTheInputInsteadOfLeaving() { + var editor = editor(FakeList([])) + _ = editor.handle(.character("a")) + XCTAssertFalse(editor.handle(.character("q"))) + XCTAssertEqual(editor.input?.text, "~/q") + } + + func testRemovalDialogKeepsTheEndOfALongPathAndTheQuestionMark() { + let long = "/Users/someone/projects/client-work/deeply/nested/folder/final-name" + var editor = editor(FakeList([long]), confirmsRemoval: true) + _ = editor.handle(.character("d")) + for width in [40, 80] { + let text = lines(editor, width: width).joined(separator: "\n") + XCTAssertTrue(text.contains("Stop protecting "), "\(width)\n\(text)") + XCTAssertTrue(text.contains("…"), "\(width)\n\(text)") + XCTAssertTrue(text.contains("name?"), "\(width)\n\(text)") + } + } + + func testRemovalDialogShowsTheWholeDestructiveButtonAt40Columns() { + var editor = editor(FakeList(["/Users/someone/projects/final-name"]), confirmsRemoval: true) + _ = editor.handle(.character("d")) + let text = lines(editor, width: 40, height: 12).joined(separator: "\n") + XCTAssertTrue(text.contains("› Cancel ‹"), text) + XCTAssertTrue(text.contains("[ Remove protection ]"), text) + } +} + +final class ProjectFoldersTests: XCTestCase { + private var root: URL! + private var folder: URL { root.appendingPathComponent("work", isDirectory: true) } + + override func setUpWithError() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("puremac-folders-\(UUID().uuidString)", isDirectory: true) + .resolvingSymlinksInPath() + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: root) + } + + func testAddSavesTheStandardizedFolderThroughTheProjectsFile() throws { + try withConfigHome { _ in + let config = PathListConfig.projectFolders + try config.add(folder.path + "/") + XCTAssertEqual(try ProjectFoldersStore(fileURL: AppPaths.projectsFile).roots, Purger.defaultRoots + [folder.path]) + XCTAssertEqual(try config.load(), Purger.defaultRoots + [folder.path]) + } + } + + func testAddRejectsADuplicate() throws { + try withConfigHome { _ in + let config = PathListConfig.projectFolders + try config.add(folder.path) + XCTAssertThrowsError(try config.add(folder.path + "/")) { error in + XCTAssertEqual(error.localizedDescription, "Already in the list: \(Render.shorten(folder.path))") + } + XCTAssertEqual(try config.load().filter { $0 == folder.path }.count, 1) + } + } + + func testAddRejectsWhatSafetyRejectsWithItsReason() throws { + try withConfigHome { _ in + let config = PathListConfig.projectFolders + for path in ["/", Safety.home, "/Library"] { + let reason = try XCTUnwrap(Safety.isValidScanRoot(path).reason) + XCTAssertThrowsError(try config.add(path), path) { error in + XCTAssertEqual(error.localizedDescription, reason) + } + } + XCTAssertFalse(FileManager.default.fileExists(atPath: AppPaths.configFile.path)) + } + } + + func testAddRejectsMissingFoldersAndFiles() throws { + try withConfigHome { _ in + let file = root.appendingPathComponent("file.txt") + try Data().write(to: file) + for path in [root.appendingPathComponent("nope").path, file.path] { + let reason = try XCTUnwrap(Safety.isValidScanRoot(path).reason) + XCTAssertThrowsError(try PathListConfig.projectFolders.add(path), path) { error in + XCTAssertEqual(error.localizedDescription, reason) + } + } + } + } + + func testAddRejectsEmptyInput() throws { + try withConfigHome { _ in + XCTAssertThrowsError(try PathListConfig.projectFolders.add(" ")) { error in + XCTAssertEqual(error.localizedDescription, "Enter a folder path") + } + } + } + + func testAddRejectsTheHomeFolder() throws { + try withConfigHome { _ in + XCTAssertThrowsError(try PathListConfig.projectFolders.add("~/")) { error in + XCTAssertEqual(error.localizedDescription, Safety.isValidScanRoot("~/").reason) + } + } + } + + func testRemoveSavesTheShorterList() throws { + try withConfigHome { _ in + let config = PathListConfig.projectFolders + try config.add(folder.path) + try config.remove(Purger.defaultRoots[0]) + XCTAssertEqual(try config.load(), Array(Purger.defaultRoots.dropFirst()) + [folder.path]) + } + } + + func testProjectFoldersHaveNoRemovalPrompt() { + XCTAssertNil(PathListConfig.projectFolders.removalPrompt) + } +} + +final class ProtectedPathsListTests: XCTestCase { + func testAddAndRemoveGoThroughTheIgnoreFileAndLoadIsSorted() throws { + try withConfigHome { _ in + let config = PathListConfig.protectedPaths + try config.add("/tmp/puremac-b") + try config.add("/tmp/puremac-a") + XCTAssertEqual(try config.load(), ["/tmp/puremac-a", "/tmp/puremac-b"]) + try config.remove("/tmp/puremac-a") + XCTAssertEqual(try config.load(), ["/tmp/puremac-b"]) + XCTAssertEqual(try IgnoreStore(fileURL: AppPaths.ignoreFile).roots, ["/tmp/puremac-b"]) + } + } + + func testAddKeepsTheStoreValidationError() throws { + try withConfigHome { _ in + XCTAssertThrowsError(try PathListConfig.protectedPaths.add("/")) { error in + XCTAssertEqual(error.localizedDescription, StoreError.invalidIgnorePath.localizedDescription) + } + } + } + + func testAddingAPathThatIsAlreadyListedThrowsAndKeepsOneCopy() throws { + try withConfigHome { _ in + let config = PathListConfig.protectedPaths + try config.add("/tmp/puremac-a") + XCTAssertThrowsError(try config.add("/tmp/puremac-a/")) { error in + XCTAssertEqual(error.localizedDescription, "Already in the list: /tmp/puremac-a") + } + XCTAssertEqual(try config.load(), ["/tmp/puremac-a"]) + } + } + + func testRemovalAsksWithTheShortenedPathAndTheDestructiveLabel() throws { + let prompt = try XCTUnwrap(PathListConfig.protectedPaths.removalPrompt) + XCTAssertEqual(prompt.title("~/secret"), "Stop protecting ~/secret?") + XCTAssertEqual(prompt.button, "Remove protection") + } +} diff --git a/cli/Tests/puremacTests/PlainCommandOutputTests.swift b/cli/Tests/puremacTests/PlainCommandOutputTests.swift new file mode 100644 index 0000000..b4089f0 --- /dev/null +++ b/cli/Tests/puremacTests/PlainCommandOutputTests.swift @@ -0,0 +1,95 @@ +import Foundation +import XCTest +@testable import puremac +import TerminalUI + +final class PlainCommandOutputTests: XCTestCase { + func testConfigPrintsTodaysTextWithDefaultValue() throws { + try withConfigHome { dir in + let output = try captureStdout { try Config.parse([]).run() } + XCTAssertEqual(output, """ + PureMac CLI settings + Values used when a command-line option is omitted + + purge.older_than_days 7 days default + + Files + Settings \(dir.path)/config.json + Protected \(dir.path)/ignore + Projects \(dir.path)/projects + + Project folders + \(Purger.defaultRoots.map { " " + Render.shorten($0) + "\n" }.joined()) + """) + } + } + + func testConfigPrintsSavedValue() throws { + try withConfigHome { dir in + var store = try ConfigStore(fileURL: AppPaths.configFile) + try store.set("purge.older_than_days", "14") + let output = try captureStdout { try Config.parse([]).run() } + XCTAssertTrue(output.contains("\n purge.older_than_days 14 days saved\n"), output) + } + } + + func testIgnoreListPrintsTodaysTextWhenEmpty() throws { + try withConfigHome { _ in + let output = try captureStdout { try Ignore.List.parse([]).run() } + XCTAssertEqual(output, "No protected paths. Add one with `puremac ignore add `.\n") + } + } + + func testIgnoreListHintHasACyanCommandWhenColorsAreOn() { + let styled = Term.styled(Ignore.List.emptyHint(), colors: true) + XCTAssertTrue(styled.contains("\u{1B}[0m\u{1B}[36mpuremac ignore add "), styled) + } + + func testIgnoreListKeepsZeroWidthCharactersInPaths() throws { + try withConfigHome { _ in + var store = try IgnoreStore(fileURL: AppPaths.ignoreFile) + _ = try store.add("/tmp/puremac-a\u{200B}b") + let output = try captureStdout { try Ignore.List.parse([]).run() } + XCTAssertEqual(output, "Protected paths:\n /tmp/puremac-a\u{200B}b\n") + } + } + + func testIgnoreListPrintsSortedPaths() throws { + try withConfigHome { _ in + var store = try IgnoreStore(fileURL: AppPaths.ignoreFile) + _ = try store.add("/tmp/puremac-b") + _ = try store.add("/tmp/puremac-a") + let output = try captureStdout { try Ignore.List.parse([]).run() } + XCTAssertEqual(output, "Protected paths:\n /tmp/puremac-a\n /tmp/puremac-b\n") + } + } +} + +final class PreferencesSheetTests: XCTestCase { + private let dim = TextStyle(dim: true) + + func testPreferencesSheetListsKeysWithValueAndNote() throws { + try withConfigHome { dir in + var store = try ConfigStore(fileURL: AppPaths.configFile) + var sheet = Config.sheet(store: store, projectFolders: Purger.defaultRoots) + XCTAssertEqual(sheet.title, "PureMac CLI settings") + XCTAssertEqual(sheet.subtitle, "Values used when a command-line option is omitted") + XCTAssertEqual(sheet.sections.map(\.title), [nil, "Files", "Project folders"]) + guard case .metric("purge.older_than_days", let value, let note) = sheet.sections[0].rows[0] else { return XCTFail("key row") } + XCTAssertEqual(value, [Span("7 days")]) + XCTAssertEqual(note, Span("default", dim)) + + try store.set("purge.older_than_days", "14") + sheet = Config.sheet(store: store, projectFolders: Purger.defaultRoots) + guard case .metric(_, let saved, let savedNote) = sheet.sections[0].rows[0] else { return XCTFail("key row") } + XCTAssertEqual(saved, [Span("14 days")]) + XCTAssertEqual(savedNote, Span("saved", TextStyle(color: .cyan))) + + XCTAssertEqual(sheet.sections[1].rows.count, 3) + guard case .metric("Settings", let settings, nil) = sheet.sections[1].rows[0] else { return XCTFail("settings row") } + XCTAssertEqual(settings, [Span("\(dir.path)/config.json")]) + guard case .metric("Projects", let projects, nil) = sheet.sections[1].rows[2] else { return XCTFail("projects row") } + XCTAssertEqual(projects, [Span("\(dir.path)/projects")]) + } + } +} diff --git a/cli/Tests/puremacTests/PlainReportColorTests.swift b/cli/Tests/puremacTests/PlainReportColorTests.swift new file mode 100644 index 0000000..14966fc --- /dev/null +++ b/cli/Tests/puremacTests/PlainReportColorTests.swift @@ -0,0 +1,120 @@ +import XCTest +@testable import puremac +import TerminalUI + +final class PlainReportColorTests: XCTestCase { + private let small = ScanItem(path: "/tmp/a/small", sizeBytes: 50_000_000, modified: nil) + private let medium = ScanItem(path: "/tmp/a/medium", sizeBytes: 150_000_000, modified: nil, selected: false) + private let large = ScanItem(path: "/tmp/a/large", sizeBytes: 2_000_000_000, modified: nil) + + private var categories: [CategoryScan] { + [CategoryScan(id: "c", title: "Caches", groups: [ToolGroup(tool: "Xcode", items: [small, medium, large])])] + } + + private func colored(_ bytes: Int64, _ code: Int) -> String { + "\u{1B}[0m\u{1B}[\(code)m\(Render.sizeCol(bytes))\u{1B}[0m" + } + + private func stripped(_ colored: String) -> String { + colored.replacingOccurrences(of: "\u{1B}\\[[0-9;]*m", with: "", options: .regularExpression) + } + + private func assertSameTextWithColors(_ plain: String, _ output: String, file: StaticString = #filePath, line: UInt = #line) { + XCTAssertNotEqual(output, plain, file: file, line: line) + XCTAssertEqual(stripped(output), plain, file: file, line: line) + } + + func testScanResultsPlainTextIsPinned() throws { + let output = try captureStdout { Render.scanResults(categories, colors: false) } + XCTAssertEqual(output, """ + + Scan complete + 2.2 GB across 3 items + + Caches 2.2 GB + Xcode 3 items + ● 50 MB /tmp/a/small + ○ 150 MB /tmp/a/medium + ● 2 GB /tmp/a/large + + """) + } + + func testDeletionReviewPlainTextIsPinned() throws { + let output = try captureStdout { Render.deletionReview(categories, colors: false) } + XCTAssertEqual(output, """ + + Permanent deletion review + ──────────────────────────────────────────────────────────────────────── + Caches 2 GB + 50 MB Xcode + /tmp/a/small + 2 GB Xcode + /tmp/a/large + ──────────────────────────────────────────────────────────────────────── + 2 GB 2 items + + """) + } + + func testPurgeRenderScanPlainTextIsPinned() throws { + let output = try captureStdout { Purge().renderScan(categories[0], days: 7, colors: false) } + XCTAssertEqual(output, """ + + Found 2.2 GB across 1 project + + Xcode 2.2 GB + ✓ 50 MB /tmp/a/small + · 150 MB /tmp/a/medium recent + ✓ 2 GB /tmp/a/large + + Artifacts newer than 7 days are shown but left unchecked. + + """) + } + + func testAnalyzePrintLevelPlainTextIsPinned() throws { + let children = [ + Analyze.Child(path: "/tmp/a/big", size: 300_000_000, isDir: false, isPackage: false, inaccessibleCount: 0, skippedCount: 0), + Analyze.Child(path: "/tmp/a/dir", size: 100_000_000, isDir: false, isPackage: false, inaccessibleCount: 1, skippedCount: 0) + ] + let output = try captureStdout { Analyze().printLevel(children, total: 400_000_000, indent: " ", depthLeft: 0, colors: false) } + XCTAssertEqual(output, """ + 300 MB 75.0% ███████████████░░░░░ big + 100 MB 25.0% █████░░░░░░░░░░░░░░░ dir ! + + """) + } + + func testScanResultsColorsSizesAndStylesWithTheFlag() throws { + let plain = try captureStdout { Render.scanResults(categories, colors: false) } + let output = try captureStdout { Render.scanResults(categories, colors: true) } + assertSameTextWithColors(plain, output) + XCTAssertTrue(output.contains(colored(2_000_000_000, 31))) + XCTAssertTrue(output.contains("\u{1B}[0m\u{1B}[1mScan complete\u{1B}[0m")) + } + + func testDeletionReviewColorsSizesAndStylesWithTheFlag() throws { + let plain = try captureStdout { Render.deletionReview(categories, colors: false) } + let output = try captureStdout { Render.deletionReview(categories, colors: true) } + assertSameTextWithColors(plain, output) + XCTAssertTrue(output.contains(colored(50_000_000, 32))) + XCTAssertFalse(output.contains(colored(150_000_000, 33))) + } + + func testPurgeRenderScanColorsSizesAndStylesWithTheFlag() throws { + let plain = try captureStdout { Purge().renderScan(categories[0], days: 7, colors: false) } + let output = try captureStdout { Purge().renderScan(categories[0], days: 7, colors: true) } + assertSameTextWithColors(plain, output) + XCTAssertTrue(output.contains(colored(150_000_000, 33))) + } + + func testPrintLevelColorsSizeAndFilledBar() throws { + let child = Analyze.Child(path: "/tmp/a/big", size: 300_000_000, isDir: false, isPackage: false, inaccessibleCount: 0, skippedCount: 0) + let output = try captureStdout { Analyze().printLevel([child], total: 400_000_000, indent: " ", depthLeft: 0, colors: true) } + let size = "\u{1B}[0m\u{1B}[33m300 MB \u{1B}[0m" + let bar = "\u{1B}[0m\u{1B}[33m" + String(repeating: "█", count: 15) + "\u{1B}[0m\u{1B}[2m" + String(repeating: "░", count: 5) + "\u{1B}[0m" + let percentage = "\u{1B}[0m\u{1B}[2m 75.0%\u{1B}[0m" + XCTAssertEqual(output, " \(size) \(percentage) \(bar) big\n") + } +} diff --git a/cli/Tests/puremacTests/PreferencesScreenTests.swift b/cli/Tests/puremacTests/PreferencesScreenTests.swift new file mode 100644 index 0000000..03cbd9e --- /dev/null +++ b/cli/Tests/puremacTests/PreferencesScreenTests.swift @@ -0,0 +1,344 @@ +import Foundation +import XCTest +@testable import puremac +import TerminalUI + +private struct Broken: LocalizedError { + var errorDescription: String? { "disk is full" } +} + +final class PreferencesScreenTests: XCTestCase { + private func savedDays() throws -> Int { + try ConfigStore(fileURL: AppPaths.configFile).olderThanDays + } + + private func setDays(_ days: Int) throws { + var store = try ConfigStore(fileURL: AppPaths.configFile) + try store.set(ConfigStore.daysKey, String(days)) + } + + private func lines(_ screen: PreferencesScreen, width: Int = 160, height: Int = 24) -> [String] { + screen.render(width: width, height: height).plainLines() + } + + func testStartsWithTheStoredValuesAndTheAgeRowFocused() throws { + try withConfigHome { _ in + let screen = PreferencesScreen() + XCTAssertEqual(screen.days, 7) + XCTAssertEqual(screen.folderCount, Purger.defaultRoots.count) + XCTAssertEqual(screen.pathCount, 0) + XCTAssertEqual(screen.focus, .age) + } + } + + func testRightAndLeftStepThroughThePresetsAndSaveImmediately() throws { + try withConfigHome { _ in + var screen = PreferencesScreen() + XCTAssertNil(screen.handle(.right)) + XCTAssertEqual(screen.days, 14) + XCTAssertEqual(try savedDays(), 14) + _ = screen.handle(.left) + _ = screen.handle(.left) + XCTAssertEqual(screen.days, 3) + XCTAssertEqual(try savedDays(), 3) + } + } + + func testPresetsStopAtBothEnds() throws { + try withConfigHome { _ in + var screen = PreferencesScreen() + for _ in 0..<12 { _ = screen.handle(.left) } + XCTAssertEqual(screen.days, 1) + for _ in 0..<12 { _ = screen.handle(.right) } + XCTAssertEqual(screen.days, 365) + XCTAssertEqual(try savedDays(), 365) + } + } + + func testAValueBetweenPresetsMovesToTheNearestPresetInThatDirection() throws { + try withConfigHome { _ in + try setDays(10) + var right = PreferencesScreen() + _ = right.handle(.right) + XCTAssertEqual(right.days, 14) + var left = PreferencesScreen() + _ = left.handle(.left) + XCTAssertEqual(left.days, 7) + try setDays(1000) + var past = PreferencesScreen() + _ = past.handle(.right) + XCTAssertEqual(past.days, 1000) + _ = past.handle(.left) + XCTAssertEqual(past.days, 365) + } + } + + func testUpAndDownMoveBetweenTheThreeRowsAndStopAtTheEnds() throws { + try withConfigHome { _ in + var screen = PreferencesScreen() + _ = screen.handle(.up) + XCTAssertEqual(screen.focus, .age) + _ = screen.handle(.down) + XCTAssertEqual(screen.focus, .projectFolders) + _ = screen.handle(.down) + _ = screen.handle(.down) + XCTAssertEqual(screen.focus, .protectedPaths) + _ = screen.handle(.up) + XCTAssertEqual(screen.focus, .projectFolders) + } + } + + func testLeftAndRightChangeNothingOnTheListRows() throws { + try withConfigHome { _ in + var screen = PreferencesScreen() + _ = screen.handle(.down) + XCTAssertNil(screen.handle(.right)) + XCTAssertNil(screen.handle(.left)) + XCTAssertEqual(screen.days, 7) + XCTAssertEqual(screen.focus, .projectFolders) + } + } + + func testEnterOpensTheEditorsAndDoesNothingOnTheAgeRow() throws { + try withConfigHome { _ in + var screen = PreferencesScreen() + XCTAssertNil(screen.handle(.enter)) + _ = screen.handle(.down) + XCTAssertEqual(screen.handle(.enter), .openProjectFolders) + _ = screen.handle(.down) + XCTAssertEqual(screen.handle(.enter), .openProtectedPaths) + } + } + + func testEscapeAndQGoBack() throws { + try withConfigHome { _ in + for key in [TerminalKey.escape, .character("q"), .character("Q")] { + var screen = PreferencesScreen() + XCTAssertEqual(screen.handle(key), .back, "\(key)") + } + var screen = PreferencesScreen() + XCTAssertNil(screen.handle(.character("x"))) + } + } + + func testReloadPicksUpChangesMadeInTheEditors() throws { + try withConfigHome { _ in + var screen = PreferencesScreen() + var ignore = try IgnoreStore(fileURL: AppPaths.ignoreFile) + _ = try ignore.add("/tmp/puremac-a") + try Data("/tmp/puremac-x\n".utf8).write(to: AppPaths.projectsFile) + screen.reload() + XCTAssertEqual(screen.pathCount, 1) + XCTAssertEqual(screen.folderCount, 1) + } + } + + func testRendersTitleSectionsRowsAndHints() throws { + try withConfigHome { dir in + let rows = lines(PreferencesScreen()) + let text = rows.joined(separator: "\n") + XCTAssertTrue(rows[0].contains("Preferences"), rows[0]) + XCTAssertTrue(rows[1].hasPrefix("│ Project cleanup"), rows[1]) + XCTAssertTrue(rows[2].hasPrefix("│ › Preselect artifacts older than ‹ 7 days ›"), rows[2]) + XCTAssertTrue(rows[3].hasPrefix("│ Project folders"), rows[3]) + XCTAssertTrue(rows[3].contains("\(Purger.defaultRoots.count) folders ›"), rows[3]) + XCTAssertTrue(rows[5].hasPrefix("│ Protection"), rows[5]) + XCTAssertTrue(rows[6].hasPrefix("│ Protected paths"), rows[6]) + XCTAssertTrue(rows[6].contains("0 paths ›"), rows[6]) + XCTAssertTrue(text.contains("Saved in \(Render.shorten(dir.path))"), text) + XCTAssertTrue(rows[22].contains("↑↓ move ←→ change Enter open Esc back"), rows[22]) + } + } + + func testSingularCounts() throws { + try withConfigHome { _ in + var ignore = try IgnoreStore(fileURL: AppPaths.ignoreFile) + _ = try ignore.add("/tmp/puremac-a") + var store = try ConfigStore(fileURL: AppPaths.configFile) + try Data("/tmp/puremac-x\n".utf8).write(to: AppPaths.projectsFile) + try store.set(ConfigStore.daysKey, "1") + let text = lines(PreferencesScreen()).joined(separator: "\n") + XCTAssertTrue(text.contains("‹ 1 day ›"), text) + XCTAssertTrue(text.contains("1 folder ›"), text) + XCTAssertTrue(text.contains("1 path ›"), text) + } + } + + func testFocusedRowIsInverseWithACyanPointer() throws { + try withConfigHome { _ in + let buffer = PreferencesScreen().render(width: 80, height: 24) + XCTAssertEqual(buffer[2, 2].style, TextStyle(color: .cyan, inverse: true)) + XCTAssertEqual(buffer[4, 2].style, TextStyle(inverse: true)) + XCTAssertEqual(buffer[4, 3].style, .plain) + } + } + + func testSavedInRowIsDimAboveTheDivider() throws { + try withConfigHome { _ in + let buffer = PreferencesScreen().render(width: 160, height: 24) + let rows = buffer.plainLines() + XCTAssertTrue(rows[20].hasPrefix("│ Saved in "), rows[20]) + XCTAssertTrue(rows[21].hasPrefix("├"), rows[21]) + XCTAssertEqual(buffer[2, 20].style, TextStyle(dim: true)) + } + } + + func testSetupErrorShowsAsAYellowRow() throws { + try withConfigHome { _ in + let buffer = PreferencesScreen(setupError: Broken()).render(width: 160, height: 24) + let rows = buffer.plainLines() + let index = try XCTUnwrap(rows.firstIndex { $0.hasPrefix("│ Could not create settings files: disk is full") }) + XCTAssertEqual(buffer[2, index].style, TextStyle(color: .yellow)) + XCTAssertFalse(lines(PreferencesScreen()).joined().contains("Could not create")) + } + } + + func testSetupErrorStaysAfterKeysAndReload() throws { + try withConfigHome { _ in + var screen = PreferencesScreen(setupError: Broken()) + _ = screen.handle(.right) + _ = screen.handle(.down) + screen.reload() + XCTAssertTrue(lines(screen).joined().contains("Could not create settings files: disk is full")) + } + } + + func testUnreadableConfigShowsARedRowInsteadOfFailing() throws { + try withConfigHome { dir in + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try Data("[]".utf8).write(to: AppPaths.configFile) + let screen = PreferencesScreen() + let buffer = screen.render(width: 160, height: 24) + let rows = buffer.plainLines() + let index = try XCTUnwrap(rows.firstIndex { $0.contains("not a valid") }) + XCTAssertEqual(buffer[2, index].style, TextStyle(color: .red)) + XCTAssertEqual(screen.days, ConfigStore.defaultDays) + } + } + + func testUnreadableProjectsFileShowsARedRowAndKeepsTheDefaultCount() throws { + try withConfigHome { dir in + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try Data("relative\n".utf8).write(to: AppPaths.projectsFile) + let screen = PreferencesScreen() + let buffer = screen.render(width: 160, height: 24) + let rows = buffer.plainLines() + let index = try XCTUnwrap(rows.firstIndex { $0.contains("Project folders file contains an invalid or relative path") }) + XCTAssertEqual(buffer[2, index].style, TextStyle(color: .red)) + XCTAssertEqual(screen.folderCount, Purger.defaultRoots.count) + XCTAssertEqual(screen.days, 7) + } + } + + func testFailedSaveShowsAYellowRowUntilTheNextKey() throws { + try withConfigHome { dir in + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try Data("{}".utf8).write(to: AppPaths.configFile) + try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: dir.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: dir.path) } + var screen = PreferencesScreen() + _ = screen.handle(.right) + XCTAssertEqual(screen.days, 7) + let buffer = screen.render(width: 160, height: 24) + let rows = buffer.plainLines() + let index = try XCTUnwrap(rows.firstIndex { $0.contains("permission") }) + XCTAssertEqual(buffer[2, index].style, TextStyle(color: .yellow)) + _ = screen.handle(.down) + XCTAssertFalse(lines(screen).joined().contains("permission")) + } + } + + func testSetupErrorNeverHidesTheListRowsInSmallWindows() throws { + try withConfigHome { _ in + for size in [(80, 12), (40, 12)] { + var screen = PreferencesScreen(setupError: Broken()) + _ = screen.handle(.down) + _ = screen.handle(.down) + XCTAssertEqual(screen.focus, .protectedPaths) + let rows = lines(screen, width: size.0, height: size.1) + for label in ["Project cleanup", "Preselect", "Project folders", "Protection", "Protected paths"] { + XCTAssertTrue(rows.contains { $0.contains(label) }, "\(label) at \(size): \(rows)") + } + } + } + } + + func testErrorTakesTheSavedInRowWhenThereIsNoRoomBelowTheList() throws { + try withConfigHome { _ in + let buffer = PreferencesScreen(setupError: Broken()).render(width: 40, height: 12) + let rows = buffer.plainLines() + let index = try XCTUnwrap(rows.firstIndex { $0.contains("Could not create") }, "\(rows)") + XCTAssertEqual(buffer[2, index].style, TextStyle(color: .yellow)) + XCTAssertFalse(rows.joined().contains("Saved in")) + } + } + + func testValuesStayInOneColumnWhenTheWindowIsNarrow() throws { + try withConfigHome { _ in + func start(of value: String, in rows: [String]) -> Int? { + rows.compactMap { row in + row.range(of: value).map { row.distance(from: row.startIndex, to: $0.lowerBound) } + }.first + } + var columns: [[Int?]] = [] + var labels: [String] = [] + for days in [7, 365] { + try setDays(days) + let rows = lines(PreferencesScreen(), width: 40, height: 12) + columns.append([ + start(of: "‹ \(days) days ›", in: rows), + start(of: "\(Purger.defaultRoots.count) folders ›", in: rows), + start(of: "0 paths ›", in: rows), + ]) + labels.append(String(rows[2].prefix { $0 != "‹" })) + } + for set in columns { XCTAssertEqual(Set(set.map { $0 ?? -1 }).count, 1, "\(columns)") } + XCTAssertNotNil(columns[0][0]) + XCTAssertEqual(columns[0], columns[1]) + XCTAssertEqual(labels[0], labels[1]) + } + } + + func testUnreadableConfigShowsTheErrorOnceWhenChangingDays() throws { + try withConfigHome { dir in + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try Data("[]".utf8).write(to: AppPaths.configFile) + var screen = PreferencesScreen() + _ = screen.handle(.right) + let text = lines(screen).joined(separator: "\n") + XCTAssertEqual(text.components(separatedBy: "not a valid").count - 1, 1, text) + } + } + + func testLongConfigPathIsShortenedWithAMiddleEllipsis() throws { + try withConfigHome { _ in + let rows = lines(PreferencesScreen(), width: 50) + XCTAssertTrue(rows[20].contains("…"), rows[20]) + XCTAssertTrue(rows[20].hasSuffix(" │"), rows[20]) + } + } + + func testNarrowWindowKeepsRowsInsideTheFrame() throws { + try withConfigHome { _ in + for row in lines(PreferencesScreen(), width: 40, height: 12) { + XCTAssertEqual(Term.displayWidth(row), 40, row) + } + let rows = lines(PreferencesScreen(), width: 40, height: 12) + XCTAssertTrue(rows[2].contains("‹ 7 days ›"), rows[2]) + } + } + + func testTooSmallTerminalShowsTheTooSmallScreenAndAcceptsOnlyEscape() throws { + try withConfigHome { _ in + let screen = PreferencesScreen() + let text = lines(screen, width: 39, height: 24).joined(separator: "\n") + XCTAssertTrue(text.contains("Terminal too small"), text) + XCTAssertTrue(text.contains("Esc back"), text) + XCTAssertTrue(screen.acceptsKey(.escape, tooSmall: true)) + for key in [TerminalKey.right, .enter, .down, .character("q")] { + XCTAssertFalse(screen.acceptsKey(key, tooSmall: true), "\(key)") + XCTAssertTrue(screen.acceptsKey(key, tooSmall: false), "\(key)") + } + } + } +} diff --git a/cli/Tests/puremacTests/ProjectFoldersStoreTests.swift b/cli/Tests/puremacTests/ProjectFoldersStoreTests.swift new file mode 100644 index 0000000..b08df90 --- /dev/null +++ b/cli/Tests/puremacTests/ProjectFoldersStoreTests.swift @@ -0,0 +1,114 @@ +import Foundation +import XCTest +@testable import puremac + +final class ProjectFoldersStoreTests: XCTestCase { + private var directory: URL! + private var file: URL { directory.appendingPathComponent("puremac/projects") } + + override func setUpWithError() throws { + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("puremac-projects-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: directory) + } + + private func write(_ text: String) throws { + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(text.utf8).write(to: file) + } + + func testMissingFileMeansTheDefaultFolders() throws { + XCTAssertEqual(try ProjectFoldersStore(fileURL: file).roots, Purger.defaultRoots) + } + + func testEmptyFileMeansNoFolders() throws { + try write("") + XCTAssertEqual(try ProjectFoldersStore(fileURL: file).roots, []) + } + + func testLoadKeepsFileOrderAndDropsDuplicates() throws { + try write("/tmp/b\n/tmp/a/../a\n/tmp/b/\n") + XCTAssertEqual(try ProjectFoldersStore(fileURL: file).roots, ["/tmp/b", "/tmp/a"]) + } + + func testInvalidLinesAreAnInvalidProjectsFile() throws { + for text in ["relative/path\n", "/\n", "/tmp/a\n\u{0}\n"] { + try write(text) + XCTAssertThrowsError(try ProjectFoldersStore(fileURL: file), text) { error in + guard case StoreError.invalidProjectsFile(file.path) = error else { return XCTFail("\(text): \(error)") } + } + } + } + + func testInvalidFileMessageNamesTheProjectsFile() { + XCTAssertEqual( + StoreError.invalidProjectsFile("/x/projects").errorDescription, + "Project folders file contains an invalid or relative path: /x/projects" + ) + } + + func testAddAppendsInOrderAndRoundTrips() throws { + try write("/tmp/z\n") + var store = try ProjectFoldersStore(fileURL: file) + XCTAssertTrue(try store.add("/tmp/m/")) + XCTAssertEqual(store.roots, ["/tmp/z", "/tmp/m"]) + XCTAssertEqual(try String(contentsOf: file, encoding: .utf8), "/tmp/z\n/tmp/m\n") + XCTAssertEqual(try ProjectFoldersStore(fileURL: file).roots, ["/tmp/z", "/tmp/m"]) + } + + func testAddToAMissingFileKeepsTheDefaultsAndWritesThem() throws { + var store = try ProjectFoldersStore(fileURL: file) + XCTAssertTrue(try store.add("/tmp/new")) + XCTAssertEqual(try ProjectFoldersStore(fileURL: file).roots, Purger.defaultRoots + ["/tmp/new"]) + } + + func testAddingADuplicateChangesNothing() throws { + try write("/tmp/a\n") + var store = try ProjectFoldersStore(fileURL: file) + XCTAssertFalse(try store.add("/tmp/b/../a")) + XCTAssertEqual(try String(contentsOf: file, encoding: .utf8), "/tmp/a\n") + } + + func testRelativeFoldersAreRejectedAndNothingIsSaved() throws { + var store = try ProjectFoldersStore(fileURL: file) + XCTAssertThrowsError(try store.add("relative")) { error in + guard case StoreError.invalidProjectRoot("relative") = error else { return XCTFail("\(error)") } + } + XCTAssertEqual(store.roots, Purger.defaultRoots) + XCTAssertFalse(FileManager.default.fileExists(atPath: file.path)) + } + + func testInvalidProjectRootMessageNamesTheFolder() { + XCTAssertEqual( + StoreError.invalidProjectRoot("relative").errorDescription, + "Project folders must be absolute paths: relative" + ) + } + + func testRemoveDropsTheFolderAndTheLastRemovalLeavesAnEmptyFile() throws { + try write("/tmp/a\n/tmp/b\n") + var store = try ProjectFoldersStore(fileURL: file) + XCTAssertTrue(try store.remove("/tmp/a")) + XCTAssertFalse(try store.remove("/tmp/a")) + XCTAssertTrue(try store.remove("/tmp/b")) + XCTAssertEqual(try String(contentsOf: file, encoding: .utf8), "") + XCTAssertEqual(try ProjectFoldersStore(fileURL: file).roots, []) + } + + func testFailedSaveKeepsTheFoldersInMemory() throws { + let blocker = directory.appendingPathComponent("blocker") + try Data("x".utf8).write(to: blocker) + var store = try ProjectFoldersStore(fileURL: blocker.appendingPathComponent("projects")) + XCTAssertThrowsError(try store.add("/tmp/a")) + XCTAssertEqual(store.roots, Purger.defaultRoots) + } + + func testProjectsFileLivesNextToConfigJson() { + XCTAssertEqual(AppPaths.projectsFile.lastPathComponent, "projects") + XCTAssertEqual(AppPaths.projectsFile.deletingLastPathComponent(), AppPaths.configDir) + } +} diff --git a/cli/Tests/puremacTests/PurgerScanTests.swift b/cli/Tests/puremacTests/PurgerScanTests.swift new file mode 100644 index 0000000..f46b023 --- /dev/null +++ b/cli/Tests/puremacTests/PurgerScanTests.swift @@ -0,0 +1,133 @@ +import XCTest +@testable import puremac + +final class PurgerScanTests: XCTestCase { + private var root: URL! + private let fileManager = FileManager.default + + override func setUpWithError() throws { + try super.setUpWithError() + let provisional = fileManager.temporaryDirectory + .appendingPathComponent("puremac-purger-tests-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: provisional, withIntermediateDirectories: true) + root = provisional.resolvingSymlinksInPath() + } + + override func tearDownWithError() throws { + if let root { try? fileManager.removeItem(at: root) } + try super.tearDownWithError() + } + + func testDefaultScanGroupsByProjectAndSortsBySize() throws { + let large = try artifact("alpha/node_modules", bytes: 16_384) + let medium = try artifact("beta/target", bytes: 8192) + let small = try artifact("beta/src/.build", bytes: 4096) + try fileManager.createDirectory(at: root.appendingPathComponent("beta/plain"), withIntermediateDirectories: true) + + let scan = Purger.scan(roots: [root.path], olderThanDays: 0, ignore: try ignoreStore()) + + XCTAssertEqual(scan.id, "purge") + XCTAssertEqual(scan.title, "Project Artifacts") + XCTAssertEqual(scan.groups.map(\.tool), ["alpha", "beta"]) + XCTAssertEqual(scan.groups.map { $0.items.map { canonical($0.path) } }, [[canonical(large.path)], [canonical(medium.path), canonical(small.path)]]) + XCTAssertEqual(scan.groups.flatMap(\.items).map(\.sizeBytes), [16_384, 8192, 4096]) + XCTAssertTrue(scan.groups.flatMap(\.items).allSatisfy(\.selected)) + } + + func testCancelledArtifactMeasurementIsNotReportedAsAGroup() throws { + let modules = try artifact("alpha/node_modules", bytes: 4096) + for index in 0..<60 { try Data(repeating: 1, count: 4096).write(to: modules.appendingPathComponent("file-\(index).bin")) } + var checks = 0 + + let scan = Purger.scan(roots: [root.path], olderThanDays: 0, ignore: try ignoreStore(), isCancelled: { + checks += 1 + return checks > 2 + }) + + XCTAssertTrue(scan.groups.isEmpty) + XCTAssertLessThan(checks, 20) + } + + func testRecentArtifactsStayUnselectedAndEmptyOnesAreSkipped() throws { + try artifact("alpha/node_modules", bytes: 8192) + try fileManager.createDirectory(at: root.appendingPathComponent("alpha/venv"), withIntermediateDirectories: true) + + let scan = Purger.scan(roots: [root.path], olderThanDays: 7, ignore: try ignoreStore()) + + XCTAssertEqual(scan.groups.flatMap(\.items).map(\.selected), [false]) + } + + func testProgressReportsRootsWalkedFoldersAndFoundArtifacts() throws { + try artifact("alpha/node_modules", bytes: 8192) + var events: [PurgeScanEvent] = [] + + _ = Purger.scan(roots: [root.path], olderThanDays: 0, ignore: try ignoreStore(), progress: { events.append($0) }) + + XCTAssertEqual(events.first, .root(canonical(root.path))) + let canonicalEvents = events.map { event -> PurgeScanEvent in + switch event { + case .root(let path): .root(canonical(path)) + case .walking(let path): .walking(canonical(path)) + case .found(let bytes): .found(bytes: bytes) + } + } + XCTAssertTrue(canonicalEvents.contains(.walking(canonical(root.appendingPathComponent("alpha").path)))) + XCTAssertTrue(canonicalEvents.contains(.found(bytes: 8192))) + } + + func testProgressDoesNotChangeTheResult() throws { + try artifact("alpha/node_modules", bytes: 16_384) + try artifact("beta/target", bytes: 8192) + let ignore = try ignoreStore() + + let plain = Purger.scan(roots: [root.path], olderThanDays: 3, ignore: ignore) + let reported = Purger.scan(roots: [root.path], olderThanDays: 3, ignore: ignore, progress: { _ in }, isCancelled: { false }) + + XCTAssertEqual(plain.groups.map(\.tool), reported.groups.map(\.tool)) + XCTAssertEqual(plain.groups.flatMap(\.items).map(\.path), reported.groups.flatMap(\.items).map(\.path)) + XCTAssertEqual(plain.groups.flatMap(\.items).map(\.selected), reported.groups.flatMap(\.items).map(\.selected)) + } + + func testCancelledBeforeStartingFindsNothing() throws { + try artifact("alpha/node_modules", bytes: 8192) + var events: [PurgeScanEvent] = [] + + let scan = Purger.scan( + roots: [root.path], olderThanDays: 0, ignore: try ignoreStore(), + progress: { events.append($0) }, isCancelled: { true } + ) + + XCTAssertTrue(scan.groups.isEmpty) + XCTAssertFalse(events.contains { if case .found = $0 { true } else { false } }) + } + + func testCancelStopsTheWalkAfterTheFirstFind() throws { + try artifact("alpha/node_modules", bytes: 8192) + try artifact("beta/target", bytes: 8192) + var found = 0 + + let scan = Purger.scan( + roots: [root.path], olderThanDays: 0, ignore: try ignoreStore(), + progress: { if case .found = $0 { found += 1 } }, isCancelled: { found >= 1 } + ) + + XCTAssertEqual(found, 1) + XCTAssertEqual(scan.groups.flatMap(\.items).count, 1) + } + + private func canonical(_ path: String) -> String { + URL(fileURLWithPath: path).resolvingSymlinksInPath().path + } + + @discardableResult + private func artifact(_ relative: String, bytes: Int) throws -> URL { + let url = root.appendingPathComponent(relative, isDirectory: true) + try fileManager.createDirectory(at: url, withIntermediateDirectories: true) + try Data(repeating: 1, count: bytes).write(to: url.appendingPathComponent("file.bin")) + return url + } + + private func ignoreStore() throws -> IgnoreStore { + try IgnoreStore(fileURL: root.appendingPathComponent("config/ignore")) + } +} diff --git a/cli/Tests/puremacTests/ScanProgressTests.swift b/cli/Tests/puremacTests/ScanProgressTests.swift new file mode 100644 index 0000000..d84ac52 --- /dev/null +++ b/cli/Tests/puremacTests/ScanProgressTests.swift @@ -0,0 +1,30 @@ +import Foundation +import XCTest +@testable import puremac + +final class ScanProgressTests: XCTestCase { + func testFullscreenCleanupWritesNoScanProgress() { + XCTAssertEqual(ScanProgress.forMode(.fullscreen), .silent) + for mode in [CleanupCommandMode.plain, .forced, .reportOnly] { + XCTAssertEqual(ScanProgress.forMode(mode), .stderr) + } + } + + func testSilentScanWritesNothingToStderrAndDefaultKeepsTheProgressLine() throws { + let silent = try captureStderr { _ = CategoryScanner.scan(categoryID: "none", title: "None", ignore: ignore(), progress: .silent) } + XCTAssertEqual(silent, "") + let verbose = try captureStderr { _ = CategoryScanner.scan(categoryID: "none", title: "None", ignore: ignore()) } + XCTAssertTrue(verbose.hasPrefix(" scanning None…\r")) + } + + func testDefaultScanOfAnUnknownCategoryIsEmpty() { + let scan = CategoryScanner.scan(categoryID: "none", title: "None", ignore: ignore(), progress: .silent) + XCTAssertEqual(scan.id, "none") + XCTAssertEqual(scan.title, "None") + XCTAssertTrue(scan.groups.isEmpty) + } + + private func ignore() -> IgnoreStore { + try! IgnoreStore(fileURL: FileManager.default.temporaryDirectory.appendingPathComponent("scan-progress-\(UUID().uuidString)")) + } +} diff --git a/cli/Tests/puremacTests/ScanStatusTests.swift b/cli/Tests/puremacTests/ScanStatusTests.swift new file mode 100644 index 0000000..896a161 --- /dev/null +++ b/cli/Tests/puremacTests/ScanStatusTests.swift @@ -0,0 +1,48 @@ +import XCTest +import TerminalUI +@testable import puremac + +final class ScanStatusTests: XCTestCase { + func testStartsWithTheHeadlineAndNothingFound() { + let status = ScanStatus(headline: "Scanning ~/Projects") + XCTAssertEqual(status.headline, "Scanning ~/Projects") + XCTAssertNil(status.folder) + XCTAssertEqual(status.foundCount, 0) + XCTAssertEqual(status.foundBytes, 0) + } + + func testRootEventSetsTheHeadlineAndClearsTheFolder() { + var status = ScanStatus(headline: "Scanning …", folder: "/old") + status.apply(.root("/Users/example/Projects")) + XCTAssertEqual(status.headline, "Scanning " + Render.shorten("/Users/example/Projects")) + XCTAssertNil(status.folder) + } + + func testWalkingEventSetsTheFolder() { + var status = ScanStatus(headline: "Scanning") + status.apply(.walking("/tmp/a/b")) + XCTAssertEqual(status.folder, "/tmp/a/b") + } + + func testFoundEventsAddUp() { + var status = ScanStatus(headline: "Scanning") + status.apply(.found(bytes: 100)) + status.apply(.found(bytes: 50)) + XCTAssertEqual(status.foundCount, 2) + XCTAssertEqual(status.foundBytes, 150) + } + + func testBeginCategoryNamesTheCategoryAndItsPosition() { + var status = ScanStatus(headline: "Scanning", folder: "/old") + status.beginCategory(title: "Developer caches", position: 2, count: 4) + XCTAssertEqual(status.headline, "Scanning Developer caches, 2 of 4") + } + + func testFinishedCategoryAddsItsItemsAndSize() { + var status = ScanStatus(headline: "Scanning") + let items = [ScanItem(path: "/tmp/a", sizeBytes: 10, modified: nil), ScanItem(path: "/tmp/b", sizeBytes: 5, modified: nil)] + status.addFound(in: CategoryScan(id: "dev", title: "Dev", groups: [ToolGroup(tool: "npm", items: items)])) + XCTAssertEqual(status.foundCount, 2) + XCTAssertEqual(status.foundBytes, 15) + } +} diff --git a/cli/Tests/puremacTests/SizeLevelTests.swift b/cli/Tests/puremacTests/SizeLevelTests.swift new file mode 100644 index 0000000..ddcb698 --- /dev/null +++ b/cli/Tests/puremacTests/SizeLevelTests.swift @@ -0,0 +1,18 @@ +import XCTest +@testable import puremac +import TerminalUI + +final class SizeLevelTests: XCTestCase { + func testThresholdsUseDecimalUnits() { + XCTAssertEqual(SizeLevel(bytes: 0).color, .green) + XCTAssertEqual(SizeLevel(bytes: 99_999_999).color, .green) + XCTAssertEqual(SizeLevel(bytes: 100_000_000).color, .yellow) + XCTAssertEqual(SizeLevel(bytes: 999_999_999).color, .yellow) + XCTAssertEqual(SizeLevel(bytes: 1_000_000_000).color, .red) + } + + func testStyleIsColoredWhenUnfocusedAndInverseWhenFocused() { + XCTAssertEqual(SizeLevel.style(bytes: 150_000_000, focused: false), TextStyle(color: .yellow)) + XCTAssertEqual(SizeLevel.style(bytes: 150_000_000, focused: true), TextStyle(inverse: true)) + } +} diff --git a/cli/Tests/puremacTests/StartConfirmationTests.swift b/cli/Tests/puremacTests/StartConfirmationTests.swift new file mode 100644 index 0000000..8a4880c --- /dev/null +++ b/cli/Tests/puremacTests/StartConfirmationTests.swift @@ -0,0 +1,176 @@ +import XCTest +import TerminalUI +@testable import puremac + +final class StartConfirmationTests: XCTestCase { + func testEnterActivatesTheFocusedButton() { + var model = StartConfirmation.explore + XCTAssertEqual(model.handle(.enter), .start) + XCTAssertNil(model.handle(.left)) + XCTAssertEqual(model.handle(.enter), .back) + } + + func testLeftRightClampAndTabCycles() { + var model = StartConfirmation.explore + XCTAssertNil(model.handle(.right)) + XCTAssertEqual(model.handle(.enter), .start) + XCTAssertNil(model.handle(.left)) + XCTAssertNil(model.handle(.left)) + XCTAssertEqual(model.handle(.enter), .back) + XCTAssertNil(model.handle(.tab)) + XCTAssertEqual(model.handle(.enter), .start) + XCTAssertNil(model.handle(.tab)) + XCTAssertEqual(model.handle(.enter), .back) + } + + func testEscapeQAndNGoBackFromAnyFocus() { + for key in [TerminalKey.escape, .character("q"), .character("Q"), .character("n"), .character("N")] { + var model = StartConfirmation.explore + XCTAssertEqual(model.handle(key), .back) + } + } + + func testSStartsFromAnyFocus() { + var model = StartConfirmation.explore + _ = model.handle(.left) + XCTAssertEqual(model.handle(.character("s")), .start) + XCTAssertEqual(model.handle(.character("S")), .start) + } + + func testTooSmallScreenAcceptsOnlyEscapeQAndN() { + let model = StartConfirmation.explore + for key in [TerminalKey.escape, .character("q"), .character("Q"), .character("n"), .character("N")] { + XCTAssertTrue(model.acceptsKey(key, tooSmall: true)) + } + for key in [TerminalKey.enter, .character("s"), .character("S"), .left, .right, .tab, .space] { + XCTAssertFalse(model.acceptsKey(key, tooSmall: true)) + XCTAssertTrue(model.acceptsKey(key, tooSmall: false)) + } + } + + func testOtherKeysDoNothing() { + var model = StartConfirmation.explore + for key in [TerminalKey.up, .down, .space, .character("x"), .character("y")] { + XCTAssertNil(model.handle(key)) + } + XCTAssertEqual(model.handle(.enter), .start) + } + + func testRendersTheDialogOverTheFadedMenu() { + let buffer = StartConfirmation.explore.render(width: 80, height: 24) + let lines = buffer.plainLines() + XCTAssertEqual(lines.count, 24) + XCTAssertTrue(lines.contains { $0.contains("Explore your home folder?") }) + XCTAssertTrue(lines.contains { $0.contains("Measuring every folder can take a few minutes.") }) + XCTAssertTrue(lines.contains { $0.contains("[ Cancel ] › Start ‹") }) + XCTAssertTrue(lines.contains { $0.contains("Enter select Esc back") }) + let menuRow = lines.firstIndex { $0.contains("Clean caches") } + XCTAssertNotNil(menuRow) + XCTAssertEqual(buffer[2, menuRow ?? 0].style, TextStyle(dim: true)) + } + + func testFocusFollowsTheModel() { + var model = StartConfirmation.explore + _ = model.handle(.left) + let lines = model.render(width: 80, height: 24).plainLines() + XCTAssertTrue(lines.contains { $0.contains("› Cancel ‹ [ Start ]") }) + } + + func testFitsTheMinimumSize() { + let lines = StartConfirmation.explore.render(width: 40, height: 12).plainLines() + XCTAssertEqual(lines.count, 12) + XCTAssertTrue(lines.allSatisfy { Term.displayWidth($0) == 40 }) + XCTAssertTrue(lines.contains { $0.contains("Enter select Esc back") }) + } + + func testBelowTheMinimumSizeShowsWhatEscDoes() { + let lines = StartConfirmation.explore.render(width: 39, height: 11).plainLines() + XCTAssertTrue(lines.contains { $0.contains("Esc back") }) + } + + func testProjectCleanupDialogTitleMessageAndFocus() { + let lines = StartConfirmation.projectCleanup(folderCount: 3).render(width: 80, height: 24).plainLines() + XCTAssertTrue(lines.contains { $0.contains("Scan your project folders?") }) + XCTAssertTrue(lines.contains { $0.contains("Searching 3 folders for build artifacts can take a few") }) + XCTAssertTrue(lines.contains { $0.contains("[ Cancel ] › Start ‹") }) + XCTAssertTrue(lines.contains { $0.contains("Enter select Esc back") }) + XCTAssertTrue(lines.contains { $0.contains("Project cleanup") }) + } + + func testProjectCleanupMessageWrapsWithoutAnOrphanWord() { + let model = StartConfirmation.projectCleanup(folderCount: 1) + for (width, height) in [(64, 18), (40, 12)] { + let lines = model.render(width: width, height: height).plainLines() + let rows = lines.map { $0.trimmingCharacters(in: CharacterSet(charactersIn: "│╭╮╰╯─ ")) } + XCTAssertFalse(rows.contains("minutes."), "\(width)x\(height)") + } + let wide = model.render(width: 64, height: 18).plainLines().map { $0.trimmingCharacters(in: CharacterSet(charactersIn: "│╭╮╰╯─ ")) } + XCTAssertTrue(wide.contains("Searching 1 folder for build")) + XCTAssertTrue(wide.contains("artifacts can take a few minutes.")) + let narrow = model.render(width: 40, height: 12).plainLines().map { $0.trimmingCharacters(in: CharacterSet(charactersIn: "│╭╮╰╯─ ")) } + XCTAssertTrue(narrow.contains("take a few minutes.")) + } + + func testProjectCleanupMessageUsesTheSingularForOneFolder() { + let message = StartConfirmation.projectCleanup(folderCount: 1).message + XCTAssertEqual(message, "Searching 1 folder for build artifacts can take a few minutes.") + XCTAssertEqual( + StartConfirmation.projectCleanup(folderCount: 5).message, + "Searching 5 folders for build artifacts can take a few minutes." + ) + } + + func testProjectCleanupKeysMatchTheExplorerDialog() { + var model = StartConfirmation.projectCleanup(folderCount: 2) + XCTAssertEqual(model.handle(.character("s")), .start) + XCTAssertEqual(model.handle(.escape), .back) + XCTAssertNil(model.handle(.left)) + XCTAssertEqual(model.handle(.enter), .back) + } + + func testBackgroundMenuFocusFollowsTheEntry() { + let explore = StartConfirmation.explore.render(width: 80, height: 24).plainLines() + let project = StartConfirmation.projectCleanup(folderCount: 2).render(width: 80, height: 24).plainLines() + XCTAssertTrue(explore.contains { $0.contains("› 3. Explor") }) + XCTAssertTrue(project.contains { $0.contains("› 2. Project cleanup") }) + } + + func testProjectCleanupEntryAsksWhenFoldersExist() { + guard case .confirm(let confirmation) = HomeMenu.projectCleanupEntry(folderCount: { 2 }) else { + return XCTFail("expected a confirmation") + } + XCTAssertTrue(confirmation.message.contains("2 folders")) + } + + func testProjectCleanupEntryShowsTheNoFoldersNoticeWithoutFolders() { + guard case .notice(let message) = HomeMenu.projectCleanupEntry(folderCount: { 0 }) else { + return XCTFail("expected a notice") + } + XCTAssertEqual(message, Purge.noFoldersMessage) + } + + func testProjectCleanupEntryTurnsAnInvalidProjectsFileIntoANotice() { + let error = StoreError.invalidProjectsFile("/tmp/projects") + guard case .notice(let message) = HomeMenu.projectCleanupEntry(folderCount: { throw error }) else { + return XCTFail("expected a notice") + } + XCTAssertEqual(message, error.localizedDescription) + } + + func testNoticeShowsTheMessageInAnInfoDialogOverTheFadedMenu() { + let buffer = HomeMenu.projectCleanupNotice(message: Purge.noFoldersMessage, width: 80, height: 24) + let lines = buffer.plainLines() + XCTAssertTrue(lines.contains { $0.contains("No project folders configured. Add one in Preferences.") }) + XCTAssertTrue(lines.contains { $0.contains("› OK ‹") }) + XCTAssertTrue(lines.contains { $0.contains("Project cleanup") }) + XCTAssertEqual(buffer[0, 0].style, TextStyle(dim: true)) + } + + func testNoticeWithALongErrorWrapsInsideTheDialog() { + let message = StoreError.invalidProjectsFile("/Users/example/.config/puremac/projects").localizedDescription + let lines = HomeMenu.projectCleanupNotice(message: message, width: 40, height: 12).plainLines() + XCTAssertEqual(lines.count, 12) + XCTAssertTrue(lines.allSatisfy { Term.displayWidth($0) == 40 }) + XCTAssertTrue(lines.contains { $0.contains("› OK ‹") }) + } +} diff --git a/cli/Tests/puremacTests/StatusScreenTests.swift b/cli/Tests/puremacTests/StatusScreenTests.swift new file mode 100644 index 0000000..905ed3e --- /dev/null +++ b/cli/Tests/puremacTests/StatusScreenTests.swift @@ -0,0 +1,94 @@ +import XCTest +@testable import puremac +import TerminalUI + +final class StatusScreenTests: XCTestCase { + private let hint = StatusScreen.scanHint + private let hintText = "\(StatusScreen.scanHint.key) \(StatusScreen.scanHint.action)" + + func testRendersWindowRowsDividerAndHint() { + let buffer = StatusScreen.render( + title: "PureMac", + rows: [ + StatusRow("Working...", style: TextStyle(dim: true)), + StatusRow("/Users/example/" + String(repeating: "a", count: 60), truncatesInMiddle: true) + ], + hint: hint, + width: 40, + height: 12 + ) + let lines = buffer.plainLines() + XCTAssertEqual(lines.count, 12) + XCTAssertTrue(lines.allSatisfy { Term.displayWidth($0) == 40 }) + XCTAssertTrue(lines[0].hasPrefix("╭─ PureMac ")) + XCTAssertTrue(lines[1].contains("Working...")) + XCTAssertTrue(lines[2].contains("…")) + XCTAssertTrue(lines.contains { $0.hasPrefix("├") && $0.hasSuffix("┤") }) + XCTAssertTrue(lines.contains { $0.contains(hintText) }) + XCTAssertTrue(buffer[2, 1].style.dim) + } + + func testLongMessageEndsWithEllipsis() { + let message = "Scanning ~/Projects, ~/Code, ~/dev, ~/GitHub, ~/Workspace …" + let lines = StatusScreen.render( + title: "PureMac", rows: [StatusRow(message, style: TextStyle(dim: true)), StatusRow("")], hint: hint, width: 40, height: 12 + ).plainLines() + XCTAssertTrue(lines[1].contains("… │"), lines[1]) + XCTAssertFalse(lines[1].contains("Workspace")) + } + + func testRowsAfterTheLastOneStayBlank() { + let lines = StatusScreen.render( + title: "PureMac", rows: [StatusRow("Working...", style: TextStyle(dim: true))], hint: hint, width: 40, height: 12 + ).plainLines() + XCTAssertEqual(lines[2], "│" + String(repeating: " ", count: 38) + "│") + } + + func testBelowMinimumUsesTooSmallScreenWithHintAsExit() { + XCTAssertEqual( + StatusScreen.render(title: "PureMac", rows: [StatusRow("x", style: TextStyle(dim: true)), StatusRow("")], hint: hint, width: 39, height: 11), + TooSmallScreen.render(title: "PureMac", exitHint: hintText, width: 39, height: 11) + ) + } + + func testOverlayFadesBackgroundAndCentersDialog() { + let background = StatusScreen.render( + title: "PureMac", rows: [StatusRow("Working...", style: TextStyle(dim: true)), StatusRow("")], hint: hint, width: 60, height: 14 + ) + let dialog = Dialog(title: "Done", message: [Span("All good.")]) + let result = StatusScreen.overlay(dialog, on: background) + XCTAssertEqual(result[0, 0].style, TextStyle(dim: true)) + XCTAssertTrue(result.plainLines().contains { $0.contains("All good.") }) + XCTAssertTrue(result.plainLines().contains { $0.contains(hintText) }) + } + + func testOverlayLeavesTooSmallBackgroundUntouched() { + let background = TooSmallScreen.render(title: "PureMac", exitHint: hintText, width: 30, height: 8) + let result = StatusScreen.overlay(Dialog(title: "Done", message: [Span("All good.")]), on: background) + XCTAssertEqual(result, background) + } + + func testRowsRenderWithTheirOwnStyleAndTruncation() { + let long = "/Users/example/" + String(repeating: "a", count: 60) + "/end" + let buffer = StatusScreen.render( + title: "PureMac", + rows: [ + StatusRow("First", style: TextStyle(dim: true)), + StatusRow(long, truncatesInMiddle: true), + StatusRow("Third") + ], + hint: hint, + width: 40, + height: 12 + ) + let lines = buffer.plainLines() + XCTAssertTrue(lines.allSatisfy { Term.displayWidth($0) == 40 }) + XCTAssertTrue(lines[1].contains("First")) + XCTAssertTrue(buffer[2, 1].style.dim) + XCTAssertTrue(lines[2].contains("…") && lines[2].contains("/end")) + XCTAssertFalse(buffer[2, 2].style.dim) + XCTAssertTrue(lines[3].contains("Third")) + XCTAssertTrue(lines.contains { $0.hasPrefix("├") }) + XCTAssertTrue(lines.contains { $0.contains(hintText) }) + } +} diff --git a/cli/Tests/puremacTests/SystemStatusScreenTests.swift b/cli/Tests/puremacTests/SystemStatusScreenTests.swift new file mode 100644 index 0000000..f749ade --- /dev/null +++ b/cli/Tests/puremacTests/SystemStatusScreenTests.swift @@ -0,0 +1,286 @@ +import Foundation +import XCTest +@testable import puremac +import TerminalUI + +final class MachineInfoTests: XCTestCase { + private let strings = [ + "machdep.cpu.brand_string": "Apple M4 Pro", + "hw.model": "Mac16,7", + "kern.osversion": "25A354", + ] + private let ints: [String: Int64] = [ + "hw.perflevel0.physicalcpu": 10, + "hw.perflevel1.physicalcpu": 4, + "hw.ncpu": 14, + "hw.memsize": 25_769_803_776, + ] + + private func machine( + strings: [String: String]? = nil, + ints: [String: Int64]? = nil, + version: OperatingSystemVersion = OperatingSystemVersion(majorVersion: 26, minorVersion: 0, patchVersion: 1) + ) -> MachineInfo { + let strings = strings ?? self.strings + let ints = ints ?? self.ints + return SystemInfo.machine(sysctl: Sysctl(string: { strings[$0] }, int: { ints[$0] }), version: version, uptime: 3_661) + } + + func testFormatsFactsFromInjectedValues() { + XCTAssertEqual(machine(), MachineInfo( + chip: "Apple M4 Pro", + cores: "14 cores (10 performance, 4 efficiency)", + memory: 25_769_803_776, + macOS: "26.0.1 (25A354)", + model: "Mac16,7", + uptime: 3_661 + )) + } + + func testFallsBackToLogicalCoreCountWithoutPerformanceLevels() { + var ints = self.ints + ints["hw.perflevel1.physicalcpu"] = nil + XCTAssertEqual(machine(ints: ints).cores, "14 cores") + ints["hw.ncpu"] = 1 + XCTAssertEqual(machine(ints: ints).cores, "1 core") + } + + func testMissingValuesAreNilAndZeroPatchIsHidden() { + let info = machine(strings: [:], ints: [:], version: OperatingSystemVersion(majorVersion: 15, minorVersion: 2, patchVersion: 0)) + XCTAssertEqual(info, MachineInfo(chip: nil, cores: nil, memory: nil, macOS: "15.2", model: nil, uptime: 3_661)) + } + + func testSystemReaderFindsRealKeysAndRejectsUnknownOnes() { + XCTAssertNotNil(Sysctl.system.string("hw.model")) + XCTAssertNotNil(Sysctl.system.int("hw.memsize")) + XCTAssertNotNil(Sysctl.system.int("hw.ncpu")) + XCTAssertNil(Sysctl.system.string("puremac.no.such.key")) + XCTAssertNil(Sysctl.system.int("puremac.no.such.key")) + } + + func testThisMacSectionListsRowsInOrderAndOmitsMissingOnes() { + let section = Optimize.thisMacSection(machine()) + XCTAssertEqual(section.title, "This Mac") + XCTAssertEqual(section.rows.map(label), ["Chip", "Cores", "Memory", "macOS", "Model", "Uptime"]) + let sparse = Optimize.thisMacSection(MachineInfo(chip: nil, cores: nil, memory: nil, macOS: "15.2", model: nil, uptime: 60)) + XCTAssertEqual(sparse.rows.map(label), ["macOS", "Uptime"]) + } + + private func label(_ row: InfoRow) -> String? { + guard case .metric(let label, _, _) = row else { return nil } + return label + } +} + +final class SystemStatusScreenTests: XCTestCase { + private let machine = MachineInfo( + chip: "Apple M4 Pro", cores: "14 cores (10 performance, 4 efficiency)", memory: 25_769_803_776, + macOS: "26.0.1 (25A354)", model: "Mac16,7", uptime: 176_461 + ) + private let memory = MemoryStatus( + total: 17_179_869_184, free: 4_294_967_296, inactive: 1_073_741_824, + active: 2_147_483_648, wired: 3_221_225_472, compressed: 536_870_912 + ) + private let volume = VolumeStatus(availableNow: 107_374_182_400, availableForImportantUsage: 161_061_273_600, total: 536_870_912_000) + + private func screen(pressure: MemoryPressure? = .normal) -> SystemStatusScreen { + SystemStatusScreen.make(machine: machine, memory: .success(memory), pressure: pressure, volume: .success(volume)) + } + + private func lines(width: Int, height: Int, offset: Int = 0) -> [String] { + screen().render(width: width, height: height, offset: offset).plainLines() + } + + func testWideTerminalPutsMemoryAndStorageSideBySideUnderThisMac() { + let lines = lines(width: 100, height: 30) + XCTAssertEqual(lines.count, 30) + XCTAssertTrue(lines.allSatisfy { Term.displayWidth($0) == 100 }) + XCTAssertTrue(lines[0].hasPrefix("╭─ This Mac "), lines[0]) + XCTAssertTrue(lines[0].hasSuffix("╮")) + let panelTops = lines.filter { $0.contains("╭─ Memory ") } + XCTAssertEqual(panelTops.count, 1) + XCTAssertTrue(panelTops[0].contains("╮╭─ Storage "), panelTops[0]) + XCTAssertTrue(panelTops[0].contains(" Normal ─╮"), panelTops[0]) + XCTAssertTrue(panelTops[0].contains(" 20% free ─╮"), panelTops[0]) + } + + func testRowsSitOneColumnInsideTheFrameWithoutExtraIndent() { + let lines = lines(width: 100, height: 30) + XCTAssertTrue(lines[1].hasPrefix("│ Chip "), lines[1]) + let gauge = lines.first { $0.contains("12 GB in use") } + XCTAssertTrue(gauge?.hasPrefix("│ █") == true, gauge ?? "") + } + + func testEachSideBySidePanelTakesHalfTheWidth() { + let line = lines(width: 81, height: 30).first { $0.contains("╭─ Memory ") } + XCTAssertEqual(line?.range(of: "╭─ Storage").map { line!.distance(from: line!.startIndex, to: $0.lowerBound) }, 41) + } + + func testEightyColumnsIsTheFirstSideBySideWidth() { + let wide = lines(width: 80, height: 40) + let top = wide.first { $0.contains("╭─ Memory ") } + XCTAssertTrue(top?.contains("╮╭─ Storage ") == true, top ?? "") + XCTAssertEqual(wide.filter { $0.contains("╯╰") }.count, 1) + XCTAssertFalse(lines(width: 79, height: 60).contains { $0.contains("╯╰") }) + } + + func testStorageTitleShowsTheFlooredPercentSoItNeverCrossesTheColorThreshold() { + func info(availableNow: Int64) -> (text: String, color: TerminalColor?) { + let volume = VolumeStatus(availableNow: availableNow, availableForImportantUsage: availableNow, total: 1_000) + let screen = SystemStatusScreen.make(machine: machine, memory: .success(memory), pressure: .normal, volume: .success(volume)) + let buffer = screen.render(width: 100, height: 30, offset: 0) + let lines = buffer.plainLines() + let top = lines.firstIndex { $0.contains("╭─ Storage ") }! + let range = lines[top].range(of: #"\d+% free"#, options: .regularExpression)! + let column = lines[top].distance(from: lines[top].startIndex, to: range.lowerBound) + return (String(lines[top][range]), buffer[column, top].style.color) + } + XCTAssertEqual(info(availableNow: 196).text, "19% free") + XCTAssertEqual(info(availableNow: 196).color, .yellow) + XCTAssertEqual(info(availableNow: 96).text, "9% free") + XCTAssertEqual(info(availableNow: 96).color, .red) + } + + func testNarrowTerminalStacksThePanels() { + let lines = lines(width: 79, height: 50) + let tops = lines.enumerated().filter { $0.element.hasPrefix("╭") } + XCTAssertEqual(tops.count, 3) + XCTAssertTrue(tops[1].element.hasPrefix("╭─ Memory ")) + XCTAssertTrue(tops[2].element.hasPrefix("╭─ Storage ")) + XCTAssertFalse(lines.contains { $0.contains("╮╭") }) + } + + func testPanelsHoldTheFactsAndTheFootnoteAndHintsFollow() { + let lines = lines(width: 100, height: 30) + let text = lines.joined(separator: "\n") + for fact in ["Chip", "Apple M4 Pro", "14 cores (10 performance, 4 efficiency)", "26.0.1 (25A354)", "Mac16,7", "2d 1h 1m", + "12 GB in use of 16 GB", "Wired", "107.4 GB free of 536.9 GB", "Managed by macOS"] { + XCTAssertTrue(text.contains(fact), fact) + } + let footnote = lines.firstIndex { $0.contains(Optimize.statusFootnote) } + let lastBottom = lines.lastIndex { $0.hasPrefix("╰") } + XCTAssertNotNil(footnote) + XCTAssertLessThan(lastBottom ?? 0, footnote ?? 0) + XCTAssertTrue(lines.last?.hasPrefix(" ↑↓ scroll") == true, lines.last ?? "") + XCTAssertTrue(lines.last?.contains("Esc back") == true) + XCTAssertEqual(lines[footnote ?? 0].prefix(2), " ") + } + + func testScrollbarThumbIsOneContinuousRunAndFramesStayWhole() { + let screen = screen() + for (width, height) in [(80, 12), (60, 14), (40, 12)] { + for offset in 0...screen.maxOffset(width: width, height: height) { + let lines = screen.render(width: width, height: height, offset: offset).plainLines().dropLast() + let rows = lines.enumerated().filter { $0.element.hasSuffix("┃") }.map(\.offset) + XCTAssertFalse(rows.isEmpty, "\(width)x\(height) offset \(offset)") + XCTAssertEqual(rows.count, (rows.last ?? 0) - (rows.first ?? 0) + 1, "\(width)x\(height) offset \(offset)") + XCTAssertFalse(lines.contains { String($0.dropLast()).hasSuffix("┃") }, "\(width)x\(height) offset \(offset)") + } + } + } + + func testStylesLabelsDimAndPressureAndFreeInfoInTheirColors() { + let buffer = screen(pressure: .warning).render(width: 100, height: 30, offset: 0) + let lines = buffer.plainLines() + let chip = lines.firstIndex { $0.contains("Chip") }! + let column = lines[chip].distance(from: lines[chip].startIndex, to: lines[chip].range(of: "Chip")!.lowerBound) + XCTAssertTrue(buffer[column, chip].style.dim) + let top = lines.firstIndex { $0.contains("╭─ Memory ") }! + let warning = lines[top].distance(from: lines[top].startIndex, to: lines[top].range(of: "Warning")!.lowerBound) + XCTAssertEqual(buffer[warning, top].style.color, .yellow) + let free = lines[top].distance(from: lines[top].startIndex, to: lines[top].range(of: "20% free")!.lowerBound) + XCTAssertEqual(buffer[free, top].style.color, .green) + } + + func testMemoryPanelHasNoInfoWithoutPressure() { + let top = screen(pressure: nil).render(width: 100, height: 30, offset: 0).plainLines().first { $0.contains("╭─ Memory ") } + XCTAssertFalse(top?.contains("Normal") == true) + XCTAssertTrue(top?.contains("╭─ Memory ─") == true) + } + + func testErrorsShowInRedInsideTheirPanel() { + struct Failure: LocalizedError { var errorDescription: String? { "no memory data" } } + let broken = SystemStatusScreen.make(machine: machine, memory: .failure(Failure()), pressure: nil, volume: .success(volume)) + let buffer = broken.render(width: 100, height: 30, offset: 0) + let lines = buffer.plainLines() + let row = lines.firstIndex { $0.contains("no memory data") }! + let column = lines[row].distance(from: lines[row].startIndex, to: lines[row].range(of: "no memory")!.lowerBound) + XCTAssertEqual(buffer[column, row].style.color, .red) + XCTAssertTrue(lines.contains { $0.contains("107.4 GB free of 536.9 GB") }) + } + + func testScrollsWholeScreenWhenContentDoesNotFit() { + let screen = screen() + let width = 60, height = 14 + let maxOffset = screen.maxOffset(width: width, height: height) + XCTAssertGreaterThan(maxOffset, 0) + let top = screen.render(width: width, height: height, offset: 0).plainLines() + let bottom = screen.render(width: width, height: height, offset: maxOffset).plainLines() + XCTAssertTrue(top[0].hasPrefix("╭─ This Mac ")) + XCTAssertFalse(bottom[0].hasPrefix("╭─ This Mac ")) + XCTAssertTrue(bottom.contains { $0.contains(Optimize.statusFootnote.prefix(20)) }) + XCTAssertEqual(screen.render(width: width, height: height, offset: maxOffset + 9).plainLines(), bottom) + for lines in [top, bottom] { + XCTAssertTrue(lines.last?.contains("↑↓ scroll") == true) + XCTAssertTrue(lines.dropLast().contains { $0.hasSuffix("┃") }) + } + } + + func testNoScrollbarOrOffsetWhenEverythingFits() { + let screen = screen() + XCTAssertEqual(screen.maxOffset(width: 100, height: 60), 0) + XCTAssertFalse(screen.render(width: 100, height: 60, offset: 0).plainLines().contains { $0.contains("┃") }) + } + + func testBelowMinimumUsesTooSmallScreen() { + XCTAssertEqual( + screen().render(width: 39, height: 20, offset: 0), + TooSmallScreen.render(title: "System status", exitHint: "Esc back", width: 39, height: 20) + ) + XCTAssertEqual( + screen().render(width: 80, height: 11, offset: 0), + TooSmallScreen.render(title: "System status", exitHint: "Esc back", width: 80, height: 11) + ) + XCTAssertEqual(screen().maxOffset(width: 39, height: 20), 0) + } + + func testHomeMenuStatusEntryIsSystemStatus() { + XCTAssertEqual(HomeMenu.options[HomeMenu.statusIndex].title, "System status") + } + + func testWriteCutsAnOverlongRowWithAnEllipsis() { + var buffer = Buffer(width: 10, height: 1) + InfoSheet.write([Span(String(repeating: "a", count: 30))], in: Rect(x: 0, y: 0, width: 10, height: 1), buffer: &buffer) + XCTAssertEqual(buffer.plainLines(), ["aaaaaaaaa…"]) + var fits = Buffer(width: 10, height: 1) + InfoSheet.write([Span("short")], in: Rect(x: 0, y: 0, width: 10, height: 1), buffer: &fits) + XCTAssertEqual(fits.plainLines()[0].trimmingCharacters(in: .whitespaces), "short") + } + + func testScrollMovesByOneAndStaysInRange() { + var scroll = InfoSheetScroll() + XCTAssertFalse(scroll.handle(.up, maxOffset: 3)) + XCTAssertEqual(scroll.offset, 0) + for _ in 0..<5 { XCTAssertFalse(scroll.handle(.down, maxOffset: 3)) } + XCTAssertEqual(scroll.offset, 3) + XCTAssertFalse(scroll.handle(.up, maxOffset: 3)) + XCTAssertEqual(scroll.offset, 2) + XCTAssertFalse(scroll.handle(.down, maxOffset: 0)) + XCTAssertEqual(scroll.offset, 0) + } + + func testScrollClosesOnEscQEnterLeftAndBackspace() { + for key in [TerminalKey.escape, .enter, .left, .backspace, .character("q"), .character("Q")] { + var scroll = InfoSheetScroll() + XCTAssertTrue(scroll.handle(key, maxOffset: 3), "\(key)") + } + } + + func testScrollIgnoresOtherKeys() { + for key in [TerminalKey.right, .tab, .space, .pageDown, .character("x"), .unknown] { + var scroll = InfoSheetScroll() + XCTAssertFalse(scroll.handle(key, maxOffset: 3), "\(key)") + XCTAssertEqual(scroll.offset, 0) + } + } +} diff --git a/cli/Tests/puremacTests/TerminalUITests.swift b/cli/Tests/puremacTests/TerminalUITests.swift deleted file mode 100644 index 1e9fe6b..0000000 --- a/cli/Tests/puremacTests/TerminalUITests.swift +++ /dev/null @@ -1,201 +0,0 @@ -import XCTest -@testable import puremac - -final class TerminalUITests: XCTestCase { - func testANSIAndUnicodeDisplayWidth() { - XCTAssertEqual(Term.displayWidth("\u{1B}[31mclean\u{1B}[0m"), 5) - XCTAssertEqual(Term.displayWidth("界"), 2) - XCTAssertEqual(Term.displayWidth("a\u{301}"), 1) - } - - func testTerminalControlCharactersAreSanitized() { - XCTAssertEqual(Term.sanitize("safe\u{1B}[2Jname\n"), "safe�[2Jname�") - XCTAssertEqual(Term.sanitize("one\ttwo"), "one two") - } - - func testMiddleTruncationRespectsVisibleWidth() { - let value = Term.truncate("/Users/example/Library/Caches/tool", to: 18, middle: true) - XCTAssertLessThanOrEqual(Term.displayWidth(value), 18) - XCTAssertTrue(value.contains("…")) - XCTAssertTrue(value.hasPrefix("/Users/e")) - XCTAssertTrue(value.hasSuffix("ches/tool")) - } - - func testReviewStartsWithNothingSelected() { - let model = InteractiveReviewModel(categories: fixtureCategories()) - XCTAssertEqual(model.selectedCount, 0) - XCTAssertEqual(model.selectedBytes, 0) - XCTAssertEqual(model.matchingAddresses.count, 3) - } - - func testReviewSelectionAndSearchPreserveExactItems() { - var model = InteractiveReviewModel(categories: fixtureCategories()) - model.toggleFocused() - model.move(1) - model.toggleFocused() - XCTAssertEqual(model.selectedCount, 2) - XCTAssertEqual(model.selectedBytes, 3_000) - - model.updateQuery("second") - XCTAssertEqual(model.matchingAddresses.count, 1) - XCTAssertEqual(model.item(at: model.matchingAddresses[0]).path, "/tmp/second") - - model.selectNone() - model.selectAllMatching() - XCTAssertEqual(model.selectedCount, 1) - XCTAssertEqual(model.selectedResult()[0].selectedItems.map(\.path), ["/tmp/second"]) - } - - func testReviewRequiresSelectionBeforeFinalReview() { - var model = InteractiveReviewModel(categories: fixtureCategories()) - XCTAssertFalse(model.beginReview()) - XCTAssertFalse(model.isReviewing) - XCTAssertFalse(model.status.isEmpty) - - model.toggleFocused() - XCTAssertTrue(model.beginReview()) - XCTAssertTrue(model.isReviewing) - } - - func testReviewRenderIsBoundedAndKeepsControlsVisible() { - var model = InteractiveReviewModel(categories: fixtureCategories()) - model.selectAllMatching() - for (width, height) in [(40, 12), (58, 14), (80, 24)] { - let lines = InteractiveReview.render( - model: model, - title: "Review removable files", - width: width, - height: height - ) - - XCTAssertEqual(lines.count, height) - XCTAssertTrue(lines.contains { $0.contains("Space toggle") }) - XCTAssertTrue(lines.contains { $0.contains("Enter review") }) - XCTAssertTrue(lines.allSatisfy { Term.displayWidth($0) <= width }) - } - } - - func testReviewHeaderFitsFortyColumnsWithDifferentCountsAndSizes() { - let categories = [ - CategoryScan( - id: "cache", - title: "Caches", - groups: [ - ToolGroup( - tool: "Applications", - items: [ - ScanItem(path: "/tmp/one", sizeBytes: 138_300, modified: nil), - ScanItem(path: "/tmp/two", sizeBytes: 1_000, modified: nil) - ] - ) - ] - ) - ] - let model = InteractiveReviewModel(categories: categories) - let lines = InteractiveReview.render( - model: model, - title: "Review removable files", - width: 40, - height: 12 - ) - - XCTAssertEqual(Term.displayWidth(lines[2]), 40) - XCTAssertTrue(lines[2].contains("2 shown")) - XCTAssertTrue(lines[2].contains("0 selected")) - XCTAssertTrue(lines.allSatisfy { Term.displayWidth($0) <= 40 }) - } - - func testFinalReviewControlsFitAtMinimumSupportedWidth() { - var model = InteractiveReviewModel(categories: fixtureCategories()) - model.selectAllMatching() - XCTAssertTrue(model.beginReview()) - let lines = InteractiveReview.render( - model: model, - title: "Review removable files", - width: 40, - height: 12 - ) - XCTAssertEqual(lines.count, 12) - XCTAssertTrue(lines.contains { $0.contains("Enter confirm") }) - XCTAssertTrue(lines.contains { $0.contains("q cancel") }) - XCTAssertTrue(lines.allSatisfy { Term.displayWidth($0) <= 40 }) - } - - func testCompactReviewDoesNotLieAboutTerminalDimensions() { - let model = InteractiveReviewModel(categories: fixtureCategories()) - let lines = InteractiveReview.render( - model: model, - title: "Review", - width: 18, - height: 4 - ) - - XCTAssertEqual(lines.count, 4) - XCTAssertTrue(lines.contains { $0.contains("too small") }) - XCTAssertTrue(lines.allSatisfy { Term.displayWidth($0) <= 18 }) - } - - func testFocusedPathWindowCanRevealEntirePath() { - let path = "/Users/example/Library/Caches/application/data" - let first = InteractiveReview.horizontalWindow(path, offset: 0, width: 20) - let last = InteractiveReview.horizontalWindow(path, offset: 30, width: 20) - - XCTAssertTrue(first.hasSuffix("›")) - XCTAssertTrue(last.hasPrefix("‹")) - XCTAssertLessThanOrEqual(Term.displayWidth(first), 20) - XCTAssertLessThanOrEqual(Term.displayWidth(last), 20) - } - - func testMenuSelectionClampsAndRenderPaginates() { - var model = TerminalMenuModel(optionCount: 8) - model.move(99) - XCTAssertEqual(model.selection, 7) - model.move(-99) - XCTAssertEqual(model.selection, 0) - XCTAssertEqual(model.select(number: 7), 6) - XCTAssertEqual(model.selection, 6) - XCTAssertNil(model.select(number: 9)) - XCTAssertNil(model.select(number: 0)) - - let options = (1...8).map { (title: "Tool \($0)", detail: "Description \($0)") } - for (width, height) in [(40, 12), (80, 24)] { - let lines = TerminalMenu.render( - title: "PureMac", - subtitle: "Choose a task", - options: options, - selection: 6, - width: width, - height: height - ) - XCTAssertEqual(lines.count, height) - XCTAssertTrue(lines.contains { $0.contains("Tool 7") }) - XCTAssertTrue(lines.contains { $0.contains("Enter open") }) - XCTAssertTrue(lines.contains { $0.contains("1-9 open") }) - XCTAssertTrue(lines.allSatisfy { Term.displayWidth($0) <= width }) - } - } - - private func fixtureCategories() -> [CategoryScan] { - [ - CategoryScan( - id: "dev", - title: "Developer", - groups: [ - ToolGroup( - tool: "Build caches", - items: [ - ScanItem(path: "/tmp/first", sizeBytes: 1_000, modified: nil, selected: true), - ScanItem(path: "/tmp/second", sizeBytes: 2_000, modified: nil, selected: true) - ] - ), - ToolGroup( - tool: "Package caches", - items: [ - ScanItem(path: "/tmp/third", sizeBytes: 3_000, modified: nil, selected: false) - ] - ) - ] - ) - ] - } -} diff --git a/cli/Tests/puremacTests/puremacTests.swift b/cli/Tests/puremacTests/puremacTests.swift index ded7b31..d3e7d22 100644 --- a/cli/Tests/puremacTests/puremacTests.swift +++ b/cli/Tests/puremacTests/puremacTests.swift @@ -4,6 +4,18 @@ import XCTest final class PureMacTests: XCTestCase { let home = FileManager.default.homeDirectoryForCurrentUser.path + func testMemoryFormattingUsesBinaryUnits() { + XCTAssertEqual(ByteCount.memory(51_539_607_552), "48 GB") + XCTAssertEqual(ByteCount.memory(17_179_869_184), "16 GB") + XCTAssertEqual(ByteCount.memory(536_870_912), "512 MB") + XCTAssertEqual(ByteCount.memory(1_610_612_736), "1.5 GB") + XCTAssertEqual(ByteCount.memory(1_000_000), "976.6 KB") + XCTAssertEqual(ByteCount.memory(1_024), "1 KB") + XCTAssertEqual(ByteCount.memory(1_023), "1023 bytes") + XCTAssertEqual(ByteCount.memory(0), "Zero KB") + XCTAssertEqual(ByteCount.human(51_539_607_552), "51.5 GB") + } + func testByteCountFormatting() { XCTAssertEqual(ByteCount.human(0), "Zero KB") XCTAssertEqual(ByteCount.human(1_000), "1 KB") @@ -83,4 +95,10 @@ final class PureMacTests: XCTestCase { XCTAssertFalse(Safety.canRemove(tmp, ignore: ig).ok) _ = try ig.remove(tmp) } + + func testCountedUsesTheSingularOnlyForOne() { + XCTAssertEqual(Render.counted(0, "item"), "0 items") + XCTAssertEqual(Render.counted(1, "folder"), "1 folder") + XCTAssertEqual(Render.counted(2, "core"), "2 cores") + } } diff --git a/cli/screenshots/clean.png b/cli/screenshots/clean.png index 2a544c4..505fcfc 100644 Binary files a/cli/screenshots/clean.png and b/cli/screenshots/clean.png differ diff --git a/cli/screenshots/workspace.png b/cli/screenshots/workspace.png index eb0f75d..867d35a 100644 Binary files a/cli/screenshots/workspace.png and b/cli/screenshots/workspace.png differ