diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f250f73 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint-and-build: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + + - name: Install Swift 6.2 + uses: swift-actions/setup-swift@v2 + with: + swift-version: '6.2' + + - name: Swift Format + run: swift format lint --strict --recursive Sources/ + + - name: SwiftLint + run: brew install swiftlint && swiftlint lint --strict + + - name: Build + run: swift build diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..c5af289 --- /dev/null +++ b/.swift-format @@ -0,0 +1,11 @@ +{ + "version": 1, + "indentation": { + "spaces": 2 + }, + "lineLength": 120, + "indentConditionalCompilationBlocks": true, + "lineBreakBeforeControlFlowKeywords": false, + "lineBreakBeforeEachArgument": false, + "respectsExistingLineBreaks": true +} diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..8d61f44 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,17 @@ +indentation: 2 +line_length: + warning: 120 + error: 200 +function_body_length: + warning: 80 + error: 120 +cyclomatic_complexity: + warning: 25 + error: 40 +identifier_name: + min_length: 1 +disabled_rules: + - trailing_comma + - opening_brace +excluded: + - .build diff --git a/README.md b/README.md index 4f43f16..ce504f0 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Devtail +# devtail A macOS menu bar app for launching and monitoring local development processes. diff --git a/Sources/DevtailKit/ANSIParser.swift b/Sources/DevtailKit/ANSIParser.swift index eaf7e9b..cbd111b 100644 --- a/Sources/DevtailKit/ANSIParser.swift +++ b/Sources/DevtailKit/ANSIParser.swift @@ -3,234 +3,236 @@ import Foundation // MARK: - Types public enum ANSIColor: Sendable, Hashable { - case `default` - case standard(UInt8) // 0-7: black, red, green, yellow, blue, magenta, cyan, white - case bright(UInt8) // 0-7: bright variants - case palette(UInt8) // 0-255 - case rgb(UInt8, UInt8, UInt8) + case `default` + case standard(UInt8) // 0-7: black, red, green, yellow, blue, magenta, cyan, white + case bright(UInt8) // 0-7: bright variants + case palette(UInt8) // 0-255 + case rgb(UInt8, UInt8, UInt8) } public struct ANSIStyle: Sendable, Hashable { - public var foreground: ANSIColor = .default - public var background: ANSIColor = .default - public var bold: Bool = false - public var dim: Bool = false - public var italic: Bool = false - public var underline: Bool = false - public var strikethrough: Bool = false - - public init() {} + public var foreground: ANSIColor = .default + public var background: ANSIColor = .default + public var bold: Bool = false + public var dim: Bool = false + public var italic: Bool = false + public var underline: Bool = false + public var strikethrough: Bool = false + + public init() {} } public struct StyledSpan: Sendable { - public var text: String - public var style: ANSIStyle + public var text: String + public var style: ANSIStyle - public init(text: String, style: ANSIStyle) { - self.text = text - self.style = style - } + public init(text: String, style: ANSIStyle) { + self.text = text + self.style = style + } } public enum TerminalAction: Sendable { - case text(StyledSpan) - case newline - case carriageReturn - case eraseLine - case eraseToEndOfLine - case cursorUp(Int) + case text(StyledSpan) + case newline + case carriageReturn + case eraseLine + case eraseToEndOfLine + case cursorUp(Int) } // MARK: - Parser public struct ANSIParser: Sendable { - public var currentStyle = ANSIStyle() + public var currentStyle = ANSIStyle() - public init() {} + public init() {} - public mutating func parse(_ input: String) -> [TerminalAction] { - var actions: [TerminalAction] = [] - var textBuf = "" - var i = input.startIndex + public mutating func parse(_ input: String) -> [TerminalAction] { + var actions: [TerminalAction] = [] + var textBuf = "" + var i = input.startIndex - func flushText() { - if !textBuf.isEmpty { - actions.append(.text(StyledSpan(text: textBuf, style: currentStyle))) - textBuf = "" - } - } + func flushText() { + if !textBuf.isEmpty { + actions.append(.text(StyledSpan(text: textBuf, style: currentStyle))) + textBuf = "" + } + } + + while i < input.endIndex { + let ch = input[i] - while i < input.endIndex { - let ch = input[i] - - switch ch { - case "\u{1B}": // ESC - flushText() - let next = input.index(after: i) - if next < input.endIndex && input[next] == "[" { - let csiStart = input.index(after: next) - if let (action, end) = parseCSI(input, from: csiStart) { - if let action = action { - actions.append(action) - } - i = end - continue - } - } - // Skip unrecognized escape — advance past ESC - i = input.index(after: i) - continue - - case "\r": - flushText() - let next = input.index(after: i) - if next < input.endIndex && input[next] == "\n" { - actions.append(.newline) - i = input.index(after: next) - continue - } - actions.append(.carriageReturn) - - case "\n": - flushText() - actions.append(.newline) - - default: - if let ascii = ch.asciiValue, ascii < 32, ascii != 9 { - // Skip control characters except tab - } else { - textBuf.append(ch) - } + switch ch { + case "\u{1B}": // ESC + flushText() + let next = input.index(after: i) + if next < input.endIndex && input[next] == "[" { + let csiStart = input.index(after: next) + if let (action, end) = parseCSI(input, from: csiStart) { + if let action = action { + actions.append(action) } + i = end + continue + } + } + // Skip unrecognized escape — advance past ESC + i = input.index(after: i) + continue - i = input.index(after: i) + case "\r": + flushText() + let next = input.index(after: i) + if next < input.endIndex && input[next] == "\n" { + actions.append(.newline) + i = input.index(after: next) + continue } + actions.append(.carriageReturn) + case "\n": flushText() - return actions - } + actions.append(.newline) - // MARK: - CSI Parsing - - private mutating func parseCSI(_ input: String, from start: String.Index) -> (TerminalAction?, String.Index)? { - var paramStr = "" - var i = start - - while i < input.endIndex { - let ch = input[i] - if ch.isLetter || ch == "@" || ch == "`" { - let result = handleCSI(params: paramStr, final: ch) - return (result, input.index(after: i)) - } else if ch == ";" || ch == ":" || ch.isNumber || ch == "?" { - paramStr.append(ch) - } else { - return nil - } - i = input.index(after: i) + default: + if let ascii = ch.asciiValue, ascii < 32, ascii != 9 { + // Skip control characters except tab + } else { + textBuf.append(ch) } + } - return nil // Incomplete sequence + i = input.index(after: i) } - private mutating func handleCSI(params: String, final: Character) -> TerminalAction? { - let parts = params.split(separator: ";", omittingEmptySubsequences: false).map { Int($0) } + flushText() + return actions + } + + // MARK: - CSI Parsing + + private mutating func parseCSI(_ input: String, from start: String.Index) -> (TerminalAction?, String.Index)? { + var paramStr = "" + var i = start + + while i < input.endIndex { + let ch = input[i] + if ch.isLetter || ch == "@" || ch == "`" { + let result = handleCSI(params: paramStr, final: ch) + return (result, input.index(after: i)) + } else if ch == ";" || ch == ":" || ch.isNumber || ch == "?" { + paramStr.append(ch) + } else { + return nil + } + i = input.index(after: i) + } - switch final { - case "m": // SGR — Select Graphic Rendition - applySGR(parts) - return nil + return nil // Incomplete sequence + } - case "K": // Erase in Line - let mode = parts.first.flatMap({ $0 }) ?? 0 - return mode == 2 ? .eraseLine : .eraseToEndOfLine + private mutating func handleCSI(params: String, final: Character) -> TerminalAction? { + let parts = params.split(separator: ";", omittingEmptySubsequences: false).map { Int($0) } - case "A": // Cursor Up - let n = parts.first.flatMap({ $0 }) ?? 1 - return .cursorUp(max(1, n)) + switch final { + case "m": // SGR — Select Graphic Rendition + applySGR(parts) + return nil - default: - return nil - } - } + case "K": // Erase in Line + let mode = parts.first.flatMap({ $0 }) ?? 0 + return mode == 2 ? .eraseLine : .eraseToEndOfLine - // MARK: - SGR + case "A": // Cursor Up + let n = parts.first.flatMap({ $0 }) ?? 1 + return .cursorUp(max(1, n)) - private mutating func applySGR(_ params: [Int?]) { - if params.isEmpty || (params.count == 1 && params[0] == nil) { - currentStyle = ANSIStyle() - return - } + default: + return nil + } + } - var i = 0 - while i < params.count { - let code = params[i] ?? 0 - - switch code { - case 0: currentStyle = ANSIStyle() - case 1: currentStyle.bold = true - case 2: currentStyle.dim = true - case 3: currentStyle.italic = true - case 4: currentStyle.underline = true - case 9: currentStyle.strikethrough = true - case 22: currentStyle.bold = false; currentStyle.dim = false - case 23: currentStyle.italic = false - case 24: currentStyle.underline = false - case 29: currentStyle.strikethrough = false - - // Foreground colors - case 30...37: - currentStyle.foreground = .standard(UInt8(code - 30)) - case 38: - if let (color, advance) = parseExtendedColor(params, from: i + 1) { - currentStyle.foreground = color - i += advance - } - case 39: - currentStyle.foreground = .default - - // Background colors - case 40...47: - currentStyle.background = .standard(UInt8(code - 40)) - case 48: - if let (color, advance) = parseExtendedColor(params, from: i + 1) { - currentStyle.background = color - i += advance - } - case 49: - currentStyle.background = .default - - // Bright foreground - case 90...97: - currentStyle.foreground = .bright(UInt8(code - 90)) - - // Bright background - case 100...107: - currentStyle.background = .bright(UInt8(code - 100)) - - default: - break - } + // MARK: - SGR - i += 1 - } + private mutating func applySGR(_ params: [Int?]) { + if params.isEmpty || (params.count == 1 && params[0] == nil) { + currentStyle = ANSIStyle() + return } - private func parseExtendedColor(_ params: [Int?], from index: Int) -> (ANSIColor, Int)? { - guard index < params.count else { return nil } - let mode = params[index] ?? 0 - - switch mode { - case 5: // 256-color palette - guard index + 1 < params.count, let n = params[index + 1] else { return nil } - return (.palette(UInt8(clamping: n)), 2) - case 2: // Truecolor RGB - guard index + 3 < params.count else { return nil } - let r = UInt8(clamping: params[index + 1] ?? 0) - let g = UInt8(clamping: params[index + 2] ?? 0) - let b = UInt8(clamping: params[index + 3] ?? 0) - return (.rgb(r, g, b), 4) - default: - return nil + var i = 0 + while i < params.count { + let code = params[i] ?? 0 + + switch code { + case 0: currentStyle = ANSIStyle() + case 1: currentStyle.bold = true + case 2: currentStyle.dim = true + case 3: currentStyle.italic = true + case 4: currentStyle.underline = true + case 9: currentStyle.strikethrough = true + case 22: + currentStyle.bold = false + currentStyle.dim = false + case 23: currentStyle.italic = false + case 24: currentStyle.underline = false + case 29: currentStyle.strikethrough = false + + // Foreground colors + case 30...37: + currentStyle.foreground = .standard(UInt8(code - 30)) + case 38: + if let (color, advance) = parseExtendedColor(params, from: i + 1) { + currentStyle.foreground = color + i += advance + } + case 39: + currentStyle.foreground = .default + + // Background colors + case 40...47: + currentStyle.background = .standard(UInt8(code - 40)) + case 48: + if let (color, advance) = parseExtendedColor(params, from: i + 1) { + currentStyle.background = color + i += advance } + case 49: + currentStyle.background = .default + + // Bright foreground + case 90...97: + currentStyle.foreground = .bright(UInt8(code - 90)) + + // Bright background + case 100...107: + currentStyle.background = .bright(UInt8(code - 100)) + + default: + break + } + + i += 1 + } + } + + private func parseExtendedColor(_ params: [Int?], from index: Int) -> (ANSIColor, Int)? { + guard index < params.count else { return nil } + let mode = params[index] ?? 0 + + switch mode { + case 5: // 256-color palette + guard index + 1 < params.count, let n = params[index + 1] else { return nil } + return (.palette(UInt8(clamping: n)), 2) + case 2: // Truecolor RGB + guard index + 3 < params.count else { return nil } + let r = UInt8(clamping: params[index + 1] ?? 0) + let g = UInt8(clamping: params[index + 2] ?? 0) + let b = UInt8(clamping: params[index + 3] ?? 0) + return (.rgb(r, g, b), 4) + default: + return nil } + } } diff --git a/Sources/DevtailKit/ProcessRunner.swift b/Sources/DevtailKit/ProcessRunner.swift index 89872ce..c172461 100644 --- a/Sources/DevtailKit/ProcessRunner.swift +++ b/Sources/DevtailKit/ProcessRunner.swift @@ -2,140 +2,140 @@ import Foundation @MainActor public final class ProcessRunner { - private var process: Process? - private var readTask: Task? - /// Stored separately because the shell may exit before its children. - /// We need the PID to kill the entire process group even after the shell is gone. - private var launchedPID: Int32 = 0 + private var process: Process? + private var readTask: Task? + /// Stored separately because the shell may exit before its children. + /// We need the PID to kill the entire process group even after the shell is gone. + private var launchedPID: Int32 = 0 + + public init() {} + + public var isRunning: Bool { + process?.isRunning ?? false + } + + public func start( + command: String, + workingDirectory: String? = nil, + buffer: TerminalBuffer, + onExit: (@MainActor @Sendable (Int32) -> Void)? = nil + ) { + stop() + + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/bin/zsh") + proc.arguments = ["-l", "-c", command] + + if let dir = workingDirectory, !dir.isEmpty { + let expanded = NSString(string: dir).expandingTildeInPath + proc.currentDirectoryURL = URL(fileURLWithPath: expanded) + } - public init() {} + var env = ProcessInfo.processInfo.environment + env["FORCE_COLOR"] = "1" + env["TERM"] = "xterm-256color" + proc.environment = env - public var isRunning: Bool { - process?.isRunning ?? false - } + let pipe = Pipe() + proc.standardOutput = pipe + proc.standardError = pipe - public func start( - command: String, - workingDirectory: String? = nil, - buffer: TerminalBuffer, - onExit: (@MainActor @Sendable (Int32) -> Void)? = nil - ) { - stop() - - let proc = Process() - proc.executableURL = URL(fileURLWithPath: "/bin/zsh") - proc.arguments = ["-l", "-c", command] - - if let dir = workingDirectory, !dir.isEmpty { - let expanded = NSString(string: dir).expandingTildeInPath - proc.currentDirectoryURL = URL(fileURLWithPath: expanded) - } + self.process = proc + + let handle = pipe.fileHandleForReading + let procRef = proc - var env = ProcessInfo.processInfo.environment - env["FORCE_COLOR"] = "1" - env["TERM"] = "xterm-256color" - proc.environment = env - - let pipe = Pipe() - proc.standardOutput = pipe - proc.standardError = pipe - - self.process = proc - - let handle = pipe.fileHandleForReading - let procRef = proc - - readTask = Task.detached { [weak self] in - var pending = "" - var lastFlush = ContinuousClock.now - - while true { - let data = handle.availableData - if data.isEmpty { - if !pending.isEmpty { - let batch = pending - await buffer.append(batch) - } - break - } - if let str = String(data: data, encoding: .utf8) { - pending += str - } - - let now = ContinuousClock.now - if now - lastFlush >= .milliseconds(50) || pending.count > 16_384 { - let batch = pending - pending = "" - lastFlush = now - await buffer.append(batch) - } - } - - procRef.waitUntilExit() - let status = procRef.terminationStatus - await MainActor.run { [weak self] in - if self?.process === procRef { - self?.process = nil - self?.launchedPID = 0 - } - onExit?(status) - } + readTask = Task.detached { [weak self] in + var pending = "" + var lastFlush = ContinuousClock.now + + while true { + let data = handle.availableData + if data.isEmpty { + if !pending.isEmpty { + let batch = pending + await buffer.append(batch) + } + break + } + if let str = String(data: data, encoding: .utf8) { + pending += str } - do { - try proc.run() - launchedPID = proc.processIdentifier - } catch { - self.process = nil - self.launchedPID = 0 - buffer.append("Failed to start: \(error.localizedDescription)\n") - onExit?(-1) + let now = ContinuousClock.now + if now - lastFlush >= .milliseconds(50) || pending.count > 16_384 { + let batch = pending + pending = "" + lastFlush = now + await buffer.append(batch) + } + } + + procRef.waitUntilExit() + let status = procRef.terminationStatus + await MainActor.run { [weak self] in + if self?.process === procRef { + self?.process = nil + self?.launchedPID = 0 } + onExit?(status) + } } - public func stop() { - let pid = launchedPID - guard pid != 0 else { return } + do { + try proc.run() + launchedPID = proc.processIdentifier + } catch { + self.process = nil + self.launchedPID = 0 + buffer.append("Failed to start: \(error.localizedDescription)\n") + onExit?(-1) + } + } - // Always kill the process group — the shell may have exited - // but npm/node/next-server children can still be alive. - kill(-pid, SIGTERM) + public func stop() { + let pid = launchedPID + guard pid != 0 else { return } - Task.detached { - try? await Task.sleep(for: .milliseconds(800)) - kill(-pid, SIGKILL) - } + // Always kill the process group — the shell may have exited + // but npm/node/next-server children can still be alive. + kill(-pid, SIGTERM) - process = nil - launchedPID = 0 - readTask?.cancel() - readTask = nil + Task.detached { + try? await Task.sleep(for: .milliseconds(800)) + kill(-pid, SIGKILL) } - /// Synchronous stop that blocks until the process group is dead. - /// Only use during app quit — blocks the main thread. - public func stopSync(timeout: TimeInterval = 2.0) { - let pid = launchedPID - guard pid != 0 else { return } + process = nil + launchedPID = 0 + readTask?.cancel() + readTask = nil + } - kill(-pid, SIGTERM) + /// Synchronous stop that blocks until the process group is dead. + /// Only use during app quit — blocks the main thread. + public func stopSync(timeout: TimeInterval = 2.0) { + let pid = launchedPID + guard pid != 0 else { return } - // Poll until the group leader is gone - let deadline = Date().addingTimeInterval(timeout) - while kill(pid, 0) == 0 && Date() < deadline { - Thread.sleep(forTimeInterval: 0.01) - } + kill(-pid, SIGTERM) - // Force kill any survivors - if kill(pid, 0) == 0 { - kill(-pid, SIGKILL) - // Brief wait for kernel cleanup - Thread.sleep(forTimeInterval: 0.05) - } + // Poll until the group leader is gone + let deadline = Date().addingTimeInterval(timeout) + while kill(pid, 0) == 0 && Date() < deadline { + Thread.sleep(forTimeInterval: 0.01) + } - process = nil - launchedPID = 0 - readTask?.cancel() - readTask = nil + // Force kill any survivors + if kill(pid, 0) == 0 { + kill(-pid, SIGKILL) + // Brief wait for kernel cleanup + Thread.sleep(forTimeInterval: 0.05) } + + process = nil + launchedPID = 0 + readTask?.cancel() + readTask = nil + } } diff --git a/Sources/DevtailKit/TerminalBuffer.swift b/Sources/DevtailKit/TerminalBuffer.swift index 327ea6c..c07a705 100644 --- a/Sources/DevtailKit/TerminalBuffer.swift +++ b/Sources/DevtailKit/TerminalBuffer.swift @@ -3,21 +3,21 @@ import SwiftUI // MARK: - Terminal Line public struct TerminalLine: Sendable, Identifiable { - public let id: Int - public var spans: [StyledSpan] + public let id: Int + public var spans: [StyledSpan] - public init(id: Int, spans: [StyledSpan] = []) { - self.id = id - self.spans = spans - } + public init(id: Int, spans: [StyledSpan] = []) { + self.id = id + self.spans = spans + } - public var isEmpty: Bool { - spans.isEmpty || spans.allSatisfy { $0.text.isEmpty } - } + public var isEmpty: Bool { + spans.isEmpty || spans.allSatisfy { $0.text.isEmpty } + } - public var plainText: String { - spans.map(\.text).joined() - } + public var plainText: String { + spans.map(\.text).joined() + } } // MARK: - Terminal Buffer @@ -25,85 +25,85 @@ public struct TerminalLine: Sendable, Identifiable { @MainActor @Observable public final class TerminalBuffer { - public private(set) var lines: [TerminalLine] = [] - public private(set) var version: Int = 0 - - private var parser = ANSIParser() - private var cursorRow: Int = 0 - private var nextLineID: Int = 0 - private let maxLines: Int - - public init(maxLines: Int = 2000) { - self.maxLines = maxLines - lines.append(makeLine()) - } - - public var hasContent: Bool { - lines.count > 1 || !(lines.first?.isEmpty ?? true) - } - - public func append(_ data: String) { - let actions = parser.parse(data) - - for action in actions { - switch action { - case .text(let span): - ensureCursorValid() - lines[cursorRow].spans.append(span) - - case .newline: - cursorRow += 1 - if cursorRow >= lines.count { - lines.append(makeLine()) - } - - case .carriageReturn: - ensureCursorValid() - lines[cursorRow].spans = [] - - case .eraseLine: - ensureCursorValid() - lines[cursorRow].spans = [] - - case .eraseToEndOfLine: - ensureCursorValid() - lines[cursorRow].spans = [] - - case .cursorUp(let n): - cursorRow = max(0, cursorRow - n) - ensureCursorValid() - lines[cursorRow].spans = [] - } + public private(set) var lines: [TerminalLine] = [] + public private(set) var version: Int = 0 + + private var parser = ANSIParser() + private var cursorRow: Int = 0 + private var nextLineID: Int = 0 + private let maxLines: Int + + public init(maxLines: Int = 2000) { + self.maxLines = maxLines + lines.append(makeLine()) + } + + public var hasContent: Bool { + lines.count > 1 || !(lines.first?.isEmpty ?? true) + } + + public func append(_ data: String) { + let actions = parser.parse(data) + + for action in actions { + switch action { + case .text(let span): + ensureCursorValid() + lines[cursorRow].spans.append(span) + + case .newline: + cursorRow += 1 + if cursorRow >= lines.count { + lines.append(makeLine()) } - trimLines() - version += 1 - } + case .carriageReturn: + ensureCursorValid() + lines[cursorRow].spans = [] - public func clear() { - lines = [makeLine()] - cursorRow = 0 - parser = ANSIParser() - version += 1 - } + case .eraseLine: + ensureCursorValid() + lines[cursorRow].spans = [] - private func makeLine() -> TerminalLine { - let line = TerminalLine(id: nextLineID) - nextLineID += 1 - return line + case .eraseToEndOfLine: + ensureCursorValid() + lines[cursorRow].spans = [] + + case .cursorUp(let n): + cursorRow = max(0, cursorRow - n) + ensureCursorValid() + lines[cursorRow].spans = [] + } } - private func ensureCursorValid() { - while cursorRow >= lines.count { - lines.append(makeLine()) - } + trimLines() + version += 1 + } + + public func clear() { + lines = [makeLine()] + cursorRow = 0 + parser = ANSIParser() + version += 1 + } + + private func makeLine() -> TerminalLine { + let line = TerminalLine(id: nextLineID) + nextLineID += 1 + return line + } + + private func ensureCursorValid() { + while cursorRow >= lines.count { + lines.append(makeLine()) } + } - private func trimLines() { - if lines.count > maxLines { - let excess = lines.count - maxLines - lines.removeFirst(excess) - cursorRow = max(0, cursorRow - excess) - } + private func trimLines() { + if lines.count > maxLines { + let excess = lines.count - maxLines + lines.removeFirst(excess) + cursorRow = max(0, cursorRow - excess) } + } } diff --git a/Sources/DevtailKit/TerminalOutputView.swift b/Sources/DevtailKit/TerminalOutputView.swift index 51f283f..aec1560 100644 --- a/Sources/DevtailKit/TerminalOutputView.swift +++ b/Sources/DevtailKit/TerminalOutputView.swift @@ -1,352 +1,352 @@ -import SwiftUI import AppKit +import SwiftUI // MARK: - Full Output View (NSTextView-backed for performance + selection) public struct TerminalOutputView: View { - let buffer: TerminalBuffer - var fontSize: CGFloat - - public init(buffer: TerminalBuffer, fontSize: CGFloat = 11) { - self.buffer = buffer - self.fontSize = fontSize - } - - public var body: some View { - // Reading version registers @Observable tracking - let _ = buffer.version - TerminalNSView(buffer: buffer, version: buffer.version, fontSize: fontSize) - } + let buffer: TerminalBuffer + var fontSize: CGFloat + + public init(buffer: TerminalBuffer, fontSize: CGFloat = 11) { + self.buffer = buffer + self.fontSize = fontSize + } + + public var body: some View { + // Reading version registers @Observable tracking + let _ = buffer.version // swiftlint:disable:this redundant_discardable_let + TerminalNSView(buffer: buffer, version: buffer.version, fontSize: fontSize) + } } private struct TerminalNSView: NSViewRepresentable { - let buffer: TerminalBuffer - let version: Int - let fontSize: CGFloat - - func makeNSView(context: Context) -> NSScrollView { - let textView = NSTextView() - textView.isEditable = false - textView.isSelectable = true - textView.backgroundColor = .clear - textView.drawsBackground = false - textView.font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) - textView.textContainerInset = NSSize(width: 8, height: 8) - textView.isAutomaticQuoteSubstitutionEnabled = false - textView.isAutomaticDashSubstitutionEnabled = false - textView.isAutomaticTextReplacementEnabled = false - textView.isRichText = false - textView.textContainer?.widthTracksTextView = true - textView.textContainer?.lineFragmentPadding = 4 - textView.isVerticallyResizable = true - textView.isHorizontallyResizable = false - textView.autoresizingMask = [.width] - - let scrollView = NSScrollView() - scrollView.documentView = textView - scrollView.hasVerticalScroller = true - scrollView.hasHorizontalScroller = false - scrollView.drawsBackground = false - scrollView.autohidesScrollers = true - - context.coordinator.textView = textView - return scrollView + let buffer: TerminalBuffer + let version: Int + let fontSize: CGFloat + + func makeNSView(context: Context) -> NSScrollView { + let textView = NSTextView() + textView.isEditable = false + textView.isSelectable = true + textView.backgroundColor = .clear + textView.drawsBackground = false + textView.font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) + textView.textContainerInset = NSSize(width: 8, height: 8) + textView.isAutomaticQuoteSubstitutionEnabled = false + textView.isAutomaticDashSubstitutionEnabled = false + textView.isAutomaticTextReplacementEnabled = false + textView.isRichText = false + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.lineFragmentPadding = 4 + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = false + textView.autoresizingMask = [.width] + + let scrollView = NSScrollView() + scrollView.documentView = textView + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.drawsBackground = false + scrollView.autohidesScrollers = true + + context.coordinator.textView = textView + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + context.coordinator.update(buffer: buffer, fontSize: fontSize) + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + @MainActor + class Coordinator { + weak var textView: NSTextView? + + // Cached objects — avoid recreating on every 50ms update + private var cachedFontSize: CGFloat = 0 + private var regularFont: NSFont? + private var boldFont: NSFont? + private var colorCache: [ANSIColor: NSColor] = [:] + + func update(buffer: TerminalBuffer, fontSize: CGFloat) { + guard let textView else { return } + guard let storage = textView.textStorage else { return } + + ensureFontCache(fontSize: fontSize) + let attrStr = buildAttributedString(buffer: buffer, fontSize: fontSize) + + storage.beginEditing() + storage.setAttributedString(attrStr) + storage.endEditing() + + textView.scrollToEndOfDocument(nil) } - func updateNSView(_ scrollView: NSScrollView, context: Context) { - context.coordinator.update(buffer: buffer, fontSize: fontSize) + private func ensureFontCache(fontSize: CGFloat) { + guard fontSize != cachedFontSize else { return } + cachedFontSize = fontSize + regularFont = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) + boldFont = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .bold) + colorCache.removeAll() } - func makeCoordinator() -> Coordinator { - Coordinator() + private func resolveColor(_ color: ANSIColor) -> NSColor { + if let cached = colorCache[color] { return cached } + let resolved = color.nsColor + colorCache[color] = resolved + return resolved } - @MainActor - class Coordinator { - weak var textView: NSTextView? - - // Cached objects — avoid recreating on every 50ms update - private var cachedFontSize: CGFloat = 0 - private var regularFont: NSFont? - private var boldFont: NSFont? - private var colorCache: [ANSIColor: NSColor] = [:] - - func update(buffer: TerminalBuffer, fontSize: CGFloat) { - guard let textView else { return } - guard let storage = textView.textStorage else { return } - - ensureFontCache(fontSize: fontSize) - let attrStr = buildAttributedString(buffer: buffer, fontSize: fontSize) - - storage.beginEditing() - storage.setAttributedString(attrStr) - storage.endEditing() - - textView.scrollToEndOfDocument(nil) - } + private func buildAttributedString(buffer: TerminalBuffer, fontSize: CGFloat) -> NSAttributedString { + let result = NSMutableAttributedString() + let regFont = regularFont! + let bldFont = boldFont! + let defaultAttrs: [NSAttributedString.Key: Any] = [ + .font: regFont, + .foregroundColor: NSColor.labelColor, + ] + + for (i, line) in buffer.lines.enumerated() { + if line.isEmpty { + result.append(NSAttributedString(string: " ", attributes: defaultAttrs)) + } else { + for span in line.spans { + var attrs = defaultAttrs + let style = span.style - private func ensureFontCache(fontSize: CGFloat) { - guard fontSize != cachedFontSize else { return } - cachedFontSize = fontSize - regularFont = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) - boldFont = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .bold) - colorCache.removeAll() - } + if style.foreground != .default { + attrs[.foregroundColor] = resolveColor(style.foreground) + } + if style.bold { + attrs[.font] = bldFont + } + if style.dim, style.foreground == .default { + attrs[.foregroundColor] = NSColor.secondaryLabelColor + } + if style.underline { + attrs[.underlineStyle] = NSUnderlineStyle.single.rawValue + } + if style.strikethrough { + attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue + } - private func resolveColor(_ color: ANSIColor) -> NSColor { - if let cached = colorCache[color] { return cached } - let resolved = color.nsColor - colorCache[color] = resolved - return resolved + result.append(NSAttributedString(string: span.text, attributes: attrs)) + } } - - private func buildAttributedString(buffer: TerminalBuffer, fontSize: CGFloat) -> NSAttributedString { - let result = NSMutableAttributedString() - let regFont = regularFont! - let bldFont = boldFont! - let defaultAttrs: [NSAttributedString.Key: Any] = [ - .font: regFont, - .foregroundColor: NSColor.labelColor - ] - - for (i, line) in buffer.lines.enumerated() { - if line.isEmpty { - result.append(NSAttributedString(string: " ", attributes: defaultAttrs)) - } else { - for span in line.spans { - var attrs = defaultAttrs - let style = span.style - - if style.foreground != .default { - attrs[.foregroundColor] = resolveColor(style.foreground) - } - if style.bold { - attrs[.font] = bldFont - } - if style.dim, style.foreground == .default { - attrs[.foregroundColor] = NSColor.secondaryLabelColor - } - if style.underline { - attrs[.underlineStyle] = NSUnderlineStyle.single.rawValue - } - if style.strikethrough { - attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue - } - - result.append(NSAttributedString(string: span.text, attributes: attrs)) - } - } - if i < buffer.lines.count - 1 { - result.append(NSAttributedString(string: "\n", attributes: defaultAttrs)) - } - } - return result + if i < buffer.lines.count - 1 { + result.append(NSAttributedString(string: "\n", attributes: defaultAttrs)) } + } + return result } + } } // MARK: - Preview Text (for cards, compact display) public struct TerminalPreviewText: View { - let buffer: TerminalBuffer - var lineLimit: Int - var fontSize: CGFloat - - public init(buffer: TerminalBuffer, lineLimit: Int = 3, fontSize: CGFloat = 10) { - self.buffer = buffer - self.lineLimit = lineLimit - self.fontSize = fontSize - } - - public var body: some View { - Text(previewAttributedString()) - .font(.system(size: fontSize, design: .monospaced)) - .lineLimit(lineLimit) - .truncationMode(.tail) - .frame(maxWidth: .infinity, alignment: .leading) - } - - private func previewAttributedString() -> AttributedString { - let nonEmptyLines = buffer.lines.filter { !$0.isEmpty } - let lastLines = nonEmptyLines.suffix(lineLimit) - - var result = AttributedString() - for (i, line) in lastLines.enumerated() { - for span in line.spans { - var attr = AttributedString(span.text) - if span.style.foreground != .default { - attr.foregroundColor = span.style.foreground.swiftUIColor - } - if span.style.bold { - attr.font = .system(size: fontSize, weight: .bold, design: .monospaced) - } - if span.style.dim, span.style.foreground == .default { - attr.foregroundColor = .secondary - } - if span.style.underline { - attr.underlineStyle = .single - } - result.append(attr) - } - if i < lastLines.count - 1 { - result.append(AttributedString("\n")) - } + let buffer: TerminalBuffer + var lineLimit: Int + var fontSize: CGFloat + + public init(buffer: TerminalBuffer, lineLimit: Int = 3, fontSize: CGFloat = 10) { + self.buffer = buffer + self.lineLimit = lineLimit + self.fontSize = fontSize + } + + public var body: some View { + Text(previewAttributedString()) + .font(.system(size: fontSize, design: .monospaced)) + .lineLimit(lineLimit) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func previewAttributedString() -> AttributedString { + let nonEmptyLines = buffer.lines.filter { !$0.isEmpty } + let lastLines = nonEmptyLines.suffix(lineLimit) + + var result = AttributedString() + for (i, line) in lastLines.enumerated() { + for span in line.spans { + var attr = AttributedString(span.text) + if span.style.foreground != .default { + attr.foregroundColor = span.style.foreground.swiftUIColor } - - if result.characters.isEmpty { - var placeholder = AttributedString("Waiting for output...") - placeholder.foregroundColor = .secondary - return placeholder + if span.style.bold { + attr.font = .system(size: fontSize, weight: .bold, design: .monospaced) + } + if span.style.dim, span.style.foreground == .default { + attr.foregroundColor = .secondary + } + if span.style.underline { + attr.underlineStyle = .single } + result.append(attr) + } + if i < lastLines.count - 1 { + result.append(AttributedString("\n")) + } + } - return result + if result.characters.isEmpty { + var placeholder = AttributedString("Waiting for output...") + placeholder.foregroundColor = .secondary + return placeholder } + + return result + } } // MARK: - ANSIColor → SwiftUI Color extension ANSIColor { - public var swiftUIColor: Color { - switch self { - case .default: - return .primary - case .standard(let n): - return Self.standardSwiftUIColor(n) - case .bright(let n): - return Self.brightSwiftUIColor(n) - case .palette(let n): - return Self.paletteSwiftUIColor(n) - case .rgb(let r, let g, let b): - return Color( - red: Double(r) / 255, - green: Double(g) / 255, - blue: Double(b) / 255 - ) - } + public var swiftUIColor: Color { + switch self { + case .default: + return .primary + case .standard(let n): + return Self.standardSwiftUIColor(n) + case .bright(let n): + return Self.brightSwiftUIColor(n) + case .palette(let n): + return Self.paletteSwiftUIColor(n) + case .rgb(let r, let g, let b): + return Color( + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255 + ) } - - private static func standardSwiftUIColor(_ index: UInt8) -> Color { - switch index { - case 0: Color(red: 0.2, green: 0.2, blue: 0.2) - case 1: Color(red: 0.85, green: 0.25, blue: 0.25) - case 2: Color(red: 0.25, green: 0.75, blue: 0.35) - case 3: Color(red: 0.85, green: 0.75, blue: 0.25) - case 4: Color(red: 0.35, green: 0.45, blue: 0.9) - case 5: Color(red: 0.8, green: 0.35, blue: 0.8) - case 6: Color(red: 0.3, green: 0.8, blue: 0.85) - case 7: Color(red: 0.8, green: 0.8, blue: 0.8) - default: .primary - } + } + + private static func standardSwiftUIColor(_ index: UInt8) -> Color { + switch index { + case 0: Color(red: 0.2, green: 0.2, blue: 0.2) + case 1: Color(red: 0.85, green: 0.25, blue: 0.25) + case 2: Color(red: 0.25, green: 0.75, blue: 0.35) + case 3: Color(red: 0.85, green: 0.75, blue: 0.25) + case 4: Color(red: 0.35, green: 0.45, blue: 0.9) + case 5: Color(red: 0.8, green: 0.35, blue: 0.8) + case 6: Color(red: 0.3, green: 0.8, blue: 0.85) + case 7: Color(red: 0.8, green: 0.8, blue: 0.8) + default: .primary } - - private static func brightSwiftUIColor(_ index: UInt8) -> Color { - switch index { - case 0: Color(red: 0.5, green: 0.5, blue: 0.5) - case 1: Color(red: 1.0, green: 0.35, blue: 0.35) - case 2: Color(red: 0.35, green: 0.95, blue: 0.45) - case 3: Color(red: 1.0, green: 0.95, blue: 0.35) - case 4: Color(red: 0.45, green: 0.55, blue: 1.0) - case 5: Color(red: 0.95, green: 0.45, blue: 0.95) - case 6: Color(red: 0.4, green: 0.95, blue: 1.0) - case 7: Color(red: 1.0, green: 1.0, blue: 1.0) - default: .primary - } + } + + private static func brightSwiftUIColor(_ index: UInt8) -> Color { + switch index { + case 0: Color(red: 0.5, green: 0.5, blue: 0.5) + case 1: Color(red: 1.0, green: 0.35, blue: 0.35) + case 2: Color(red: 0.35, green: 0.95, blue: 0.45) + case 3: Color(red: 1.0, green: 0.95, blue: 0.35) + case 4: Color(red: 0.45, green: 0.55, blue: 1.0) + case 5: Color(red: 0.95, green: 0.45, blue: 0.95) + case 6: Color(red: 0.4, green: 0.95, blue: 1.0) + case 7: Color(red: 1.0, green: 1.0, blue: 1.0) + default: .primary } - - private static func paletteSwiftUIColor(_ index: UInt8) -> Color { - let n = Int(index) - if n < 8 { - return standardSwiftUIColor(index) - } else if n < 16 { - return brightSwiftUIColor(UInt8(n - 8)) - } else if n < 232 { - let adjusted = n - 16 - let r = adjusted / 36 - let g = (adjusted % 36) / 6 - let b = adjusted % 6 - return Color( - red: r == 0 ? 0 : Double(r * 40 + 55) / 255, - green: g == 0 ? 0 : Double(g * 40 + 55) / 255, - blue: b == 0 ? 0 : Double(b * 40 + 55) / 255 - ) - } else { - let gray = Double((n - 232) * 10 + 8) / 255 - return Color(red: gray, green: gray, blue: gray) - } + } + + private static func paletteSwiftUIColor(_ index: UInt8) -> Color { + let n = Int(index) + if n < 8 { + return standardSwiftUIColor(index) + } else if n < 16 { + return brightSwiftUIColor(UInt8(n - 8)) + } else if n < 232 { + let adjusted = n - 16 + let r = adjusted / 36 + let g = (adjusted % 36) / 6 + let b = adjusted % 6 + return Color( + red: r == 0 ? 0 : Double(r * 40 + 55) / 255, + green: g == 0 ? 0 : Double(g * 40 + 55) / 255, + blue: b == 0 ? 0 : Double(b * 40 + 55) / 255 + ) + } else { + let gray = Double((n - 232) * 10 + 8) / 255 + return Color(red: gray, green: gray, blue: gray) } + } } // MARK: - ANSIColor → NSColor extension ANSIColor { - public var nsColor: NSColor { - switch self { - case .default: - return .labelColor - case .standard(let n): - return Self.standardNSColor(n) - case .bright(let n): - return Self.brightNSColor(n) - case .palette(let n): - return Self.paletteNSColor(n) - case .rgb(let r, let g, let b): - return NSColor( - red: CGFloat(r) / 255, - green: CGFloat(g) / 255, - blue: CGFloat(b) / 255, - alpha: 1 - ) - } + public var nsColor: NSColor { + switch self { + case .default: + return .labelColor + case .standard(let n): + return Self.standardNSColor(n) + case .bright(let n): + return Self.brightNSColor(n) + case .palette(let n): + return Self.paletteNSColor(n) + case .rgb(let r, let g, let b): + return NSColor( + red: CGFloat(r) / 255, + green: CGFloat(g) / 255, + blue: CGFloat(b) / 255, + alpha: 1 + ) } - - private static func standardNSColor(_ index: UInt8) -> NSColor { - switch index { - case 0: NSColor(red: 0.2, green: 0.2, blue: 0.2, alpha: 1) - case 1: NSColor(red: 0.85, green: 0.25, blue: 0.25, alpha: 1) - case 2: NSColor(red: 0.25, green: 0.75, blue: 0.35, alpha: 1) - case 3: NSColor(red: 0.85, green: 0.75, blue: 0.25, alpha: 1) - case 4: NSColor(red: 0.35, green: 0.45, blue: 0.9, alpha: 1) - case 5: NSColor(red: 0.8, green: 0.35, blue: 0.8, alpha: 1) - case 6: NSColor(red: 0.3, green: 0.8, blue: 0.85, alpha: 1) - case 7: NSColor(red: 0.8, green: 0.8, blue: 0.8, alpha: 1) - default: .labelColor - } + } + + private static func standardNSColor(_ index: UInt8) -> NSColor { + switch index { + case 0: NSColor(red: 0.2, green: 0.2, blue: 0.2, alpha: 1) + case 1: NSColor(red: 0.85, green: 0.25, blue: 0.25, alpha: 1) + case 2: NSColor(red: 0.25, green: 0.75, blue: 0.35, alpha: 1) + case 3: NSColor(red: 0.85, green: 0.75, blue: 0.25, alpha: 1) + case 4: NSColor(red: 0.35, green: 0.45, blue: 0.9, alpha: 1) + case 5: NSColor(red: 0.8, green: 0.35, blue: 0.8, alpha: 1) + case 6: NSColor(red: 0.3, green: 0.8, blue: 0.85, alpha: 1) + case 7: NSColor(red: 0.8, green: 0.8, blue: 0.8, alpha: 1) + default: .labelColor } - - private static func brightNSColor(_ index: UInt8) -> NSColor { - switch index { - case 0: NSColor(red: 0.5, green: 0.5, blue: 0.5, alpha: 1) - case 1: NSColor(red: 1.0, green: 0.35, blue: 0.35, alpha: 1) - case 2: NSColor(red: 0.35, green: 0.95, blue: 0.45, alpha: 1) - case 3: NSColor(red: 1.0, green: 0.95, blue: 0.35, alpha: 1) - case 4: NSColor(red: 0.45, green: 0.55, blue: 1.0, alpha: 1) - case 5: NSColor(red: 0.95, green: 0.45, blue: 0.95, alpha: 1) - case 6: NSColor(red: 0.4, green: 0.95, blue: 1.0, alpha: 1) - case 7: NSColor(red: 1.0, green: 1.0, blue: 1.0, alpha: 1) - default: .labelColor - } + } + + private static func brightNSColor(_ index: UInt8) -> NSColor { + switch index { + case 0: NSColor(red: 0.5, green: 0.5, blue: 0.5, alpha: 1) + case 1: NSColor(red: 1.0, green: 0.35, blue: 0.35, alpha: 1) + case 2: NSColor(red: 0.35, green: 0.95, blue: 0.45, alpha: 1) + case 3: NSColor(red: 1.0, green: 0.95, blue: 0.35, alpha: 1) + case 4: NSColor(red: 0.45, green: 0.55, blue: 1.0, alpha: 1) + case 5: NSColor(red: 0.95, green: 0.45, blue: 0.95, alpha: 1) + case 6: NSColor(red: 0.4, green: 0.95, blue: 1.0, alpha: 1) + case 7: NSColor(red: 1.0, green: 1.0, blue: 1.0, alpha: 1) + default: .labelColor } - - private static func paletteNSColor(_ index: UInt8) -> NSColor { - let n = Int(index) - if n < 8 { - return standardNSColor(index) - } else if n < 16 { - return brightNSColor(UInt8(n - 8)) - } else if n < 232 { - let adjusted = n - 16 - let r = adjusted / 36 - let g = (adjusted % 36) / 6 - let b = adjusted % 6 - return NSColor( - red: r == 0 ? 0 : CGFloat(r * 40 + 55) / 255, - green: g == 0 ? 0 : CGFloat(g * 40 + 55) / 255, - blue: b == 0 ? 0 : CGFloat(b * 40 + 55) / 255, - alpha: 1 - ) - } else { - let gray = CGFloat((n - 232) * 10 + 8) / 255 - return NSColor(red: gray, green: gray, blue: gray, alpha: 1) - } + } + + private static func paletteNSColor(_ index: UInt8) -> NSColor { + let n = Int(index) + if n < 8 { + return standardNSColor(index) + } else if n < 16 { + return brightNSColor(UInt8(n - 8)) + } else if n < 232 { + let adjusted = n - 16 + let r = adjusted / 36 + let g = (adjusted % 36) / 6 + let b = adjusted % 6 + return NSColor( + red: r == 0 ? 0 : CGFloat(r * 40 + 55) / 255, + green: g == 0 ? 0 : CGFloat(g * 40 + 55) / 255, + blue: b == 0 ? 0 : CGFloat(b * 40 + 55) / 255, + alpha: 1 + ) + } else { + let gray = CGFloat((n - 232) * 10 + 8) / 255 + return NSColor(red: gray, green: gray, blue: gray, alpha: 1) } + } } diff --git a/Sources/devtail/AppDelegate.swift b/Sources/devtail/AppDelegate.swift index 559855b..e8ea87f 100644 --- a/Sources/devtail/AppDelegate.swift +++ b/Sources/devtail/AppDelegate.swift @@ -2,32 +2,32 @@ import AppKit @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { - var store: ProcessStore? + var store: ProcessStore? - private var signalSource: DispatchSourceSignal? + private var signalSource: DispatchSourceSignal? - func applicationDidFinishLaunching(_ notification: Notification) { - // Ensure child processes are cleaned up even if we're killed with SIGTERM - signal(SIGTERM, SIG_IGN) - let source = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main) - source.setEventHandler { [weak self] in - MainActor.assumeIsolated { - self?.performCleanup() - } - exit(0) - } - source.resume() - signalSource = source + func applicationDidFinishLaunching(_ notification: Notification) { + // Ensure child processes are cleaned up even if we're killed with SIGTERM + signal(SIGTERM, SIG_IGN) + let source = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main) + source.setEventHandler { [weak self] in + MainActor.assumeIsolated { + self?.performCleanup() + } + exit(0) } + source.resume() + signalSource = source + } - nonisolated func applicationWillTerminate(_ notification: Notification) { - MainActor.assumeIsolated { - performCleanup() - } + nonisolated func applicationWillTerminate(_ notification: Notification) { + MainActor.assumeIsolated { + performCleanup() } + } - private func performCleanup() { - PopOutWindowManager.shared.closeAll() - store?.stopAllForQuit() - } + private func performCleanup() { + PopOutWindowManager.shared.closeAll() + store?.stopAllForQuit() + } } diff --git a/Sources/devtail/AppNotifications.swift b/Sources/devtail/AppNotifications.swift index 0e8c6ff..d32d028 100644 --- a/Sources/devtail/AppNotifications.swift +++ b/Sources/devtail/AppNotifications.swift @@ -2,58 +2,59 @@ import AppKit import UserNotifications enum AppNotifications { - private static var isBundledApp: Bool { - Bundle.main.bundlePath.hasSuffix(".app") + private static var isBundledApp: Bool { + Bundle.main.bundlePath.hasSuffix(".app") + } + + static func requestPermission() { + guard isBundledApp else { return } + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in } + } + + @MainActor + static func processExited(name: String, exitCode: Int32) { + let title = exitCode == 0 ? "Process Exited" : "Process Crashed" + let body = + exitCode == 0 + ? "\(name) exited normally." + : "\(name) exited with code \(exitCode)." + + if isBundledApp { + sendUNNotification(title: title, body: body) + } else { + sendScriptNotification(title: title, body: body) } - - static func requestPermission() { - guard isBundledApp else { return } - UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in } - } - - @MainActor - static func processExited(name: String, exitCode: Int32) { - let title = exitCode == 0 ? "Process Exited" : "Process Crashed" - let body = exitCode == 0 - ? "\(name) exited normally." - : "\(name) exited with code \(exitCode)." - - if isBundledApp { - sendUNNotification(title: title, body: body) - } else { - sendScriptNotification(title: title, body: body) - } - } - - // MARK: - Bundled .app — UNUserNotificationCenter - - private static func sendUNNotification(title: String, body: String) { - let content = UNMutableNotificationContent() - content.title = title - content.body = body - content.sound = .default - - let request = UNNotificationRequest( - identifier: UUID().uuidString, - content: content, - trigger: nil - ) - UNUserNotificationCenter.current().add(request) - } - - // MARK: - Unbundled executable — osascript fallback - - private static func sendScriptNotification(title: String, body: String) { - let escaped = { (s: String) -> String in - s.replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - } - let script = "display notification \"\(escaped(body))\" with title \"\(escaped(title))\"" - let proc = Process() - proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") - proc.arguments = ["-e", script] - proc.standardOutput = FileHandle.nullDevice - proc.standardError = FileHandle.nullDevice - try? proc.run() + } + + // MARK: - Bundled .app — UNUserNotificationCenter + + private static func sendUNNotification(title: String, body: String) { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + + let request = UNNotificationRequest( + identifier: UUID().uuidString, + content: content, + trigger: nil + ) + UNUserNotificationCenter.current().add(request) + } + + // MARK: - Unbundled executable — osascript fallback + + private static func sendScriptNotification(title: String, body: String) { + let escaped = { (s: String) -> String in + s.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") } + let script = "display notification \"\(escaped(body))\" with title \"\(escaped(title))\"" + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + proc.arguments = ["-e", script] + proc.standardOutput = FileHandle.nullDevice + proc.standardError = FileHandle.nullDevice + try? proc.run() + } } diff --git a/Sources/devtail/ContentView.swift b/Sources/devtail/ContentView.swift index fc9be6c..abe34a6 100644 --- a/Sources/devtail/ContentView.swift +++ b/Sources/devtail/ContentView.swift @@ -1,234 +1,234 @@ -import SwiftUI import ServiceManagement +import SwiftUI enum ViewState: Equatable { - case list - case detail(UUID) - case add - case edit(UUID) + case list + case detail(UUID) + case add + case edit(UUID) } struct ContentView: View { - var store: ProcessStore - @State private var viewState: ViewState = .list - @State private var launchAtLogin = SMAppService.mainApp.status == .enabled + var store: ProcessStore + @State private var viewState: ViewState = .list + @State private var launchAtLogin = SMAppService.mainApp.status == .enabled - var body: some View { - VStack(spacing: 0) { - headerBar - Divider() + var body: some View { + VStack(spacing: 0) { + headerBar + Divider() - contentArea - .frame(maxHeight: .infinity) + contentArea + .frame(maxHeight: .infinity) - Divider() - footerBar - } - .frame(width: 360, height: 500) - .background(.background) + Divider() + footerBar } - - // MARK: - Header - - private var headerBar: some View { - HStack(spacing: 10) { - if viewState != .list { - Button { - withAnimation(.spring(duration: 0.25)) { - switch viewState { - case .edit(let id): - viewState = .detail(id) - default: - viewState = .list - } - } - } label: { - Image(systemName: "chevron.left") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.secondary) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .transition(.opacity) - } - - Image(systemName: "terminal.fill") - .font(.system(size: 16)) - .foregroundStyle(.tint) - - Text("Devtail") - .font(.system(size: 15, weight: .semibold)) - - Spacer() - - if viewState == .list { - Button { - withAnimation(.spring(duration: 0.25)) { viewState = .add } - } label: { - Image(systemName: "plus.circle.fill") - .font(.system(size: 22)) - .symbolRenderingMode(.hierarchical) - } - .buttonStyle(.plain) - .transition(.opacity) + .frame(width: 360, height: 500) + .background(.background) + } + + // MARK: - Header + + private var headerBar: some View { + HStack(spacing: 10) { + if viewState != .list { + Button { + withAnimation(.spring(duration: 0.25)) { + switch viewState { + case .edit(let id): + viewState = .detail(id) + default: + viewState = .list } + } + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + .contentShape(Rectangle()) } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .animation(.easeInOut(duration: 0.2), value: viewState) - } - - // MARK: - Content - - @ViewBuilder - private var contentArea: some View { - switch viewState { - case .list: - listContent - .transition(.move(edge: .leading).combined(with: .opacity)) - - case .detail(let id): - if let process = store.processes.first(where: { $0.id == id }) { - ProcessDetailView( - process: process, - onToggle: { - withAnimation(.easeInOut(duration: 0.2)) { - process.toggle() - } - }, - onEdit: { - withAnimation(.spring(duration: 0.25)) { - viewState = .edit(id) - } - } - ) - .transition(.move(edge: .trailing).combined(with: .opacity)) - } else { - listContent - } - - case .add: - ProcessFormView(store: store) { - withAnimation(.spring(duration: 0.25)) { viewState = .list } - } - .transition(.move(edge: .trailing).combined(with: .opacity)) - - case .edit(let id): - if let process = store.processes.first(where: { $0.id == id }) { - ProcessFormView(store: store, editing: process) { - withAnimation(.spring(duration: 0.25)) { viewState = .detail(id) } - } - .transition(.move(edge: .trailing).combined(with: .opacity)) - } else { - listContent - } + .buttonStyle(.plain) + .transition(.opacity) + } + + Image(systemName: "terminal.fill") + .font(.system(size: 16)) + .foregroundStyle(.tint) + + Text("devtail") + .font(.system(size: 15, weight: .semibold)) + + Spacer() + + if viewState == .list { + Button { + withAnimation(.spring(duration: 0.25)) { viewState = .add } + } label: { + Image(systemName: "plus.circle.fill") + .font(.system(size: 22)) + .symbolRenderingMode(.hierarchical) } + .buttonStyle(.plain) + .transition(.opacity) + } } - - private var listContent: some View { - Group { - if store.processes.isEmpty { - emptyState - } else { - ScrollView { - LazyVStack(spacing: 8) { - ForEach(store.processes) { process in - ProcessCardView( - process: process, - onSelect: { - withAnimation(.spring(duration: 0.3)) { - viewState = .detail(process.id) - } - }, - onToggle: { - withAnimation(.easeInOut(duration: 0.2)) { - process.toggle() - } - }, - onDelete: { store.removeProcess(id: process.id) } - ) - } - } - .padding(12) - } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .animation(.easeInOut(duration: 0.2), value: viewState) + } + + // MARK: - Content + + @ViewBuilder + private var contentArea: some View { + switch viewState { + case .list: + listContent + .transition(.move(edge: .leading).combined(with: .opacity)) + + case .detail(let id): + if let process = store.processes.first(where: { $0.id == id }) { + ProcessDetailView( + process: process, + onToggle: { + withAnimation(.easeInOut(duration: 0.2)) { + process.toggle() } + }, + onEdit: { + withAnimation(.spring(duration: 0.25)) { + viewState = .edit(id) + } + } + ) + .transition(.move(edge: .trailing).combined(with: .opacity)) + } else { + listContent + } + + case .add: + ProcessFormView(store: store) { + withAnimation(.spring(duration: 0.25)) { viewState = .list } + } + .transition(.move(edge: .trailing).combined(with: .opacity)) + + case .edit(let id): + if let process = store.processes.first(where: { $0.id == id }) { + ProcessFormView(store: store, editing: process) { + withAnimation(.spring(duration: 0.25)) { viewState = .detail(id) } } + .transition(.move(edge: .trailing).combined(with: .opacity)) + } else { + listContent + } } - - private var emptyState: some View { - VStack(spacing: 16) { - Spacer() - - Image(systemName: "terminal") - .font(.system(size: 36)) - .foregroundStyle(.quaternary) - - VStack(spacing: 4) { - Text("No processes yet") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.secondary) - Text("Add a dev server, build command, or any\nlong-running process to manage from here.") - .font(.system(size: 11)) - .foregroundStyle(.tertiary) - .multilineTextAlignment(.center) - .lineSpacing(2) + } + + private var listContent: some View { + Group { + if store.processes.isEmpty { + emptyState + } else { + ScrollView { + LazyVStack(spacing: 8) { + ForEach(store.processes) { process in + ProcessCardView( + process: process, + onSelect: { + withAnimation(.spring(duration: 0.3)) { + viewState = .detail(process.id) + } + }, + onToggle: { + withAnimation(.easeInOut(duration: 0.2)) { + process.toggle() + } + }, + onDelete: { store.removeProcess(id: process.id) } + ) } - - Button { - withAnimation(.spring(duration: 0.25)) { viewState = .add } - } label: { - Label("Add Process", systemImage: "plus") - .font(.system(size: 12, weight: .medium)) - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - - Spacer() + } + .padding(12) } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.horizontal, 24) + } } - - // MARK: - Footer - - /// SMAppService requires a proper .app bundle to work. - private var canManageLaunchAtLogin: Bool { - Bundle.main.bundlePath.hasSuffix(".app") + } + + private var emptyState: some View { + VStack(spacing: 16) { + Spacer() + + Image(systemName: "terminal") + .font(.system(size: 36)) + .foregroundStyle(.quaternary) + + VStack(spacing: 4) { + Text("No processes yet") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) + Text("Add a dev server, build command, or any\nlong-running process to manage from here.") + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + .lineSpacing(2) + } + + Button { + withAnimation(.spring(duration: 0.25)) { viewState = .add } + } label: { + Label("Add Process", systemImage: "plus") + .font(.system(size: 12, weight: .medium)) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + + Spacer() } - - private var footerBar: some View { - HStack { - Button("Quit Devtail") { - NSApplication.shared.terminate(nil) - } - .buttonStyle(.plain) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 24) + } + + // MARK: - Footer + + /// SMAppService requires a proper .app bundle to work. + private var canManageLaunchAtLogin: Bool { + Bundle.main.bundlePath.hasSuffix(".app") + } + + private var footerBar: some View { + HStack { + Button("Quit devtail") { + NSApplication.shared.terminate(nil) + } + .buttonStyle(.plain) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + + Spacer() + + if canManageLaunchAtLogin { + Toggle(isOn: $launchAtLogin) { + Text("Launch at Login") .font(.system(size: 11)) .foregroundStyle(.secondary) - - Spacer() - - if canManageLaunchAtLogin { - Toggle(isOn: $launchAtLogin) { - Text("Launch at Login") - .font(.system(size: 11)) - .foregroundStyle(.secondary) - } - .toggleStyle(.switch) - .controlSize(.mini) - .onChange(of: launchAtLogin) { _, newValue in - do { - if newValue { - try SMAppService.mainApp.register() - } else { - try SMAppService.mainApp.unregister() - } - } catch { - launchAtLogin = SMAppService.mainApp.status == .enabled - } - } + } + .toggleStyle(.switch) + .controlSize(.mini) + .onChange(of: launchAtLogin) { _, newValue in + do { + if newValue { + try SMAppService.mainApp.register() + } else { + try SMAppService.mainApp.unregister() } + } catch { + launchAtLogin = SMAppService.mainApp.status == .enabled + } } - .padding(.horizontal, 16) - .padding(.vertical, 8) + } } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } } diff --git a/Sources/devtail/DevtailApp.swift b/Sources/devtail/DevtailApp.swift index d6dbb15..842e445 100644 --- a/Sources/devtail/DevtailApp.swift +++ b/Sources/devtail/DevtailApp.swift @@ -2,17 +2,17 @@ import SwiftUI @main struct DevtailApp: App { - @State private var store = ProcessStore() - @NSApplicationDelegateAdaptor private var appDelegate: AppDelegate + @State private var store = ProcessStore() + @NSApplicationDelegateAdaptor private var appDelegate: AppDelegate - var body: some Scene { - MenuBarExtra("Devtail", systemImage: "terminal") { - ContentView(store: store) - .onAppear { - appDelegate.store = store - AppNotifications.requestPermission() - } + var body: some Scene { + MenuBarExtra("devtail", systemImage: "terminal") { + ContentView(store: store) + .onAppear { + appDelegate.store = store + AppNotifications.requestPermission() } - .menuBarExtraStyle(.window) } + .menuBarExtraStyle(.window) + } } diff --git a/Sources/devtail/Models.swift b/Sources/devtail/Models.swift index cf7b3bc..2aaa8c7 100644 --- a/Sources/devtail/Models.swift +++ b/Sources/devtail/Models.swift @@ -1,156 +1,156 @@ -import SwiftUI import DevtailKit +import SwiftUI @MainActor @Observable final class DevProcess: Identifiable { - let id: UUID - var name: String - var command: String - var workingDirectory: String - var auxiliaryCommands: [AuxiliaryCommand] - - let buffer = TerminalBuffer() - private var auxiliaryBuffers: [UUID: TerminalBuffer] = [:] - var isRunning = false - - private var runner: ProcessRunner? - private var auxiliaryRunners: [UUID: ProcessRunner] = [:] - private var userStopped = false - - /// Called when running state changes so the store can persist. - var onStateChange: (() -> Void)? - - init( - id: UUID = UUID(), - name: String, - command: String, - workingDirectory: String = "", - auxiliaryCommands: [AuxiliaryCommand] = [] - ) { - self.id = id - self.name = name - self.command = command - self.workingDirectory = workingDirectory - self.auxiliaryCommands = auxiliaryCommands - } - - func bufferFor(auxiliary id: UUID) -> TerminalBuffer { - if let buf = auxiliaryBuffers[id] { - return buf - } - let buf = TerminalBuffer() - auxiliaryBuffers[id] = buf - return buf - } - - /// Remove buffers for auxiliary commands that no longer exist. - func cleanupAuxiliaryBuffers() { - let validIDs = Set(auxiliaryCommands.map(\.id)) - for key in auxiliaryBuffers.keys where !validIDs.contains(key) { - auxiliaryBuffers.removeValue(forKey: key) - } + let id: UUID + var name: String + var command: String + var workingDirectory: String + var auxiliaryCommands: [AuxiliaryCommand] + + let buffer = TerminalBuffer() + private var auxiliaryBuffers: [UUID: TerminalBuffer] = [:] + var isRunning = false + + private var runner: ProcessRunner? + private var auxiliaryRunners: [UUID: ProcessRunner] = [:] + private var userStopped = false + + /// Called when running state changes so the store can persist. + var onStateChange: (() -> Void)? + + init( + id: UUID = UUID(), + name: String, + command: String, + workingDirectory: String = "", + auxiliaryCommands: [AuxiliaryCommand] = [] + ) { + self.id = id + self.name = name + self.command = command + self.workingDirectory = workingDirectory + self.auxiliaryCommands = auxiliaryCommands + } + + func bufferFor(auxiliary id: UUID) -> TerminalBuffer { + if let buf = auxiliaryBuffers[id] { + return buf } - - // MARK: - Lifecycle - - func start() { - guard !isRunning else { return } - userStopped = false - isRunning = true - buffer.clear() - - let r = ProcessRunner() - runner = r - r.start( - command: command, - workingDirectory: workingDirectory.isEmpty ? nil : workingDirectory, - buffer: buffer - ) { [weak self] status in - guard let self, self.runner === r else { return } - self.runner = nil - self.isRunning = false - self.stopAuxiliaryCommands() - - if status == 0 { - self.buffer.append("\n\u{1B}[2mProcess exited\u{1B}[0m\n") - } else { - self.buffer.append("\n\u{1B}[31mProcess exited with code \(status)\u{1B}[0m\n") - } - - if !self.userStopped { - AppNotifications.processExited(name: self.name, exitCode: status) - } - - self.onStateChange?() - } - - startAuxiliaryCommands() - onStateChange?() - } - - func stop() { - guard isRunning else { return } - userStopped = true - runner?.stop() - runner = nil - stopAuxiliaryCommands() - isRunning = false - buffer.append("\n\u{1B}[2mProcess stopped\u{1B}[0m\n") - onStateChange?() + let buf = TerminalBuffer() + auxiliaryBuffers[id] = buf + return buf + } + + /// Remove buffers for auxiliary commands that no longer exist. + func cleanupAuxiliaryBuffers() { + let validIDs = Set(auxiliaryCommands.map(\.id)) + for key in auxiliaryBuffers.keys where !validIDs.contains(key) { + auxiliaryBuffers.removeValue(forKey: key) } - - /// Synchronous stop that blocks until all processes are dead. - /// Only use during app quit. - func forceStop() { - userStopped = true - runner?.stopSync() - runner = nil - for (_, r) in auxiliaryRunners { - r.stopSync() - } - auxiliaryRunners.removeAll() - isRunning = false + } + + // MARK: - Lifecycle + + func start() { + guard !isRunning else { return } + userStopped = false + isRunning = true + buffer.clear() + + let r = ProcessRunner() + runner = r + r.start( + command: command, + workingDirectory: workingDirectory.isEmpty ? nil : workingDirectory, + buffer: buffer + ) { [weak self] status in + guard let self, self.runner === r else { return } + self.runner = nil + self.isRunning = false + self.stopAuxiliaryCommands() + + if status == 0 { + self.buffer.append("\n\u{1B}[2mProcess exited\u{1B}[0m\n") + } else { + self.buffer.append("\n\u{1B}[31mProcess exited with code \(status)\u{1B}[0m\n") + } + + if !self.userStopped { + AppNotifications.processExited(name: self.name, exitCode: status) + } + + self.onStateChange?() } - func toggle() { - if isRunning { stop() } else { start() } + startAuxiliaryCommands() + onStateChange?() + } + + func stop() { + guard isRunning else { return } + userStopped = true + runner?.stop() + runner = nil + stopAuxiliaryCommands() + isRunning = false + buffer.append("\n\u{1B}[2mProcess stopped\u{1B}[0m\n") + onStateChange?() + } + + /// Synchronous stop that blocks until all processes are dead. + /// Only use during app quit. + func forceStop() { + userStopped = true + runner?.stopSync() + runner = nil + for (_, r) in auxiliaryRunners { + r.stopSync() } - - // MARK: - Auxiliary Commands - - private func startAuxiliaryCommands() { - for aux in auxiliaryCommands { - let auxRunner = ProcessRunner() - let auxBuffer = bufferFor(auxiliary: aux.id) - auxBuffer.clear() - auxiliaryRunners[aux.id] = auxRunner - auxRunner.start( - command: aux.command, - workingDirectory: workingDirectory.isEmpty ? nil : workingDirectory, - buffer: auxBuffer - ) { [weak self] _ in - self?.auxiliaryRunners[aux.id] = nil - } - } + auxiliaryRunners.removeAll() + isRunning = false + } + + func toggle() { + if isRunning { stop() } else { start() } + } + + // MARK: - Auxiliary Commands + + private func startAuxiliaryCommands() { + for aux in auxiliaryCommands { + let auxRunner = ProcessRunner() + let auxBuffer = bufferFor(auxiliary: aux.id) + auxBuffer.clear() + auxiliaryRunners[aux.id] = auxRunner + auxRunner.start( + command: aux.command, + workingDirectory: workingDirectory.isEmpty ? nil : workingDirectory, + buffer: auxBuffer + ) { [weak self] _ in + self?.auxiliaryRunners[aux.id] = nil + } } + } - private func stopAuxiliaryCommands() { - for (_, r) in auxiliaryRunners { - r.stop() - } - auxiliaryRunners.removeAll() + private func stopAuxiliaryCommands() { + for (_, r) in auxiliaryRunners { + r.stop() } + auxiliaryRunners.removeAll() + } } struct AuxiliaryCommand: Identifiable { - let id: UUID - var name: String - var command: String - - init(id: UUID = UUID(), name: String, command: String) { - self.id = id - self.name = name - self.command = command - } + let id: UUID + var name: String + var command: String + + init(id: UUID = UUID(), name: String, command: String) { + self.id = id + self.name = name + self.command = command + } } diff --git a/Sources/devtail/Persistence.swift b/Sources/devtail/Persistence.swift index c787356..af82307 100644 --- a/Sources/devtail/Persistence.swift +++ b/Sources/devtail/Persistence.swift @@ -1,47 +1,48 @@ import Foundation struct SavedProcess: Codable { + let id: UUID + var name: String + var command: String + var workingDirectory: String + var auxiliaryCommands: [SavedAuxCommand] + var wasRunning: Bool + + struct SavedAuxCommand: Codable { let id: UUID var name: String var command: String - var workingDirectory: String - var auxiliaryCommands: [SavedAuxCommand] - var wasRunning: Bool - - struct SavedAuxCommand: Codable { - let id: UUID - var name: String - var command: String - } + } } enum Persistence { - private static let key = "devtail.processes" + private static let key = "devtail.processes" - @MainActor - static func save(_ processes: [DevProcess]) { - let saved = processes.map { p in - SavedProcess( - id: p.id, - name: p.name, - command: p.command, - workingDirectory: p.workingDirectory, - auxiliaryCommands: p.auxiliaryCommands.map { - SavedProcess.SavedAuxCommand(id: $0.id, name: $0.name, command: $0.command) - }, - wasRunning: p.isRunning - ) - } - if let data = try? JSONEncoder().encode(saved) { - UserDefaults.standard.set(data, forKey: key) - } + @MainActor + static func save(_ processes: [DevProcess]) { + let saved = processes.map { p in + SavedProcess( + id: p.id, + name: p.name, + command: p.command, + workingDirectory: p.workingDirectory, + auxiliaryCommands: p.auxiliaryCommands.map { + SavedProcess.SavedAuxCommand(id: $0.id, name: $0.name, command: $0.command) + }, + wasRunning: p.isRunning + ) + } + if let data = try? JSONEncoder().encode(saved) { + UserDefaults.standard.set(data, forKey: key) } + } - static func load() -> [SavedProcess] { - guard let data = UserDefaults.standard.data(forKey: key), - let saved = try? JSONDecoder().decode([SavedProcess].self, from: data) else { - return [] - } - return saved + static func load() -> [SavedProcess] { + guard let data = UserDefaults.standard.data(forKey: key), + let saved = try? JSONDecoder().decode([SavedProcess].self, from: data) + else { + return [] } + return saved + } } diff --git a/Sources/devtail/PopOutProcessView.swift b/Sources/devtail/PopOutProcessView.swift index ed3bd9d..8ec6076 100644 --- a/Sources/devtail/PopOutProcessView.swift +++ b/Sources/devtail/PopOutProcessView.swift @@ -1,20 +1,20 @@ -import SwiftUI import DevtailKit +import SwiftUI struct PopOutProcessView: View { - let buffer: TerminalBuffer + let buffer: TerminalBuffer - var body: some View { - Group { - if buffer.hasContent { - TerminalOutputView(buffer: buffer) - } else { - Text("Waiting for output...") - .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(.tertiary) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } - .background(Color(nsColor: .textBackgroundColor)) + var body: some View { + Group { + if buffer.hasContent { + TerminalOutputView(buffer: buffer) + } else { + Text("Waiting for output...") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } } + .background(Color(nsColor: .textBackgroundColor)) + } } diff --git a/Sources/devtail/PopOutWindowManager.swift b/Sources/devtail/PopOutWindowManager.swift index f130a97..f0722eb 100644 --- a/Sources/devtail/PopOutWindowManager.swift +++ b/Sources/devtail/PopOutWindowManager.swift @@ -1,90 +1,90 @@ import AppKit -import SwiftUI import DevtailKit +import SwiftUI @MainActor final class PopOutWindowManager { - static let shared = PopOutWindowManager() - - /// Keyed by buffer's ObjectIdentifier so each buffer gets one window. - private var windows: [ObjectIdentifier: NSWindow] = [:] - private var delegates: [ObjectIdentifier: WindowCloseDelegate] = [:] + static let shared = PopOutWindowManager() - func openWindow(buffer: TerminalBuffer, title: String) { - let key = ObjectIdentifier(buffer) + /// Keyed by buffer's ObjectIdentifier so each buffer gets one window. + private var windows: [ObjectIdentifier: NSWindow] = [:] + private var delegates: [ObjectIdentifier: WindowCloseDelegate] = [:] - // If window already exists, bring it to front - if let existing = windows[key], existing.isVisible { - existing.makeKeyAndOrderFront(nil) - NSApp.activate() - return - } + func openWindow(buffer: TerminalBuffer, title: String) { + let key = ObjectIdentifier(buffer) - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 480, height: 400), - styleMask: [.titled, .closable, .resizable, .miniaturizable], - backing: .buffered, - defer: false - ) + // If window already exists, bring it to front + if let existing = windows[key], existing.isVisible { + existing.makeKeyAndOrderFront(nil) + NSApp.activate() + return + } - let hostingView = NSHostingView(rootView: PopOutProcessView(buffer: buffer)) - window.contentView = hostingView - window.title = title - window.minSize = NSSize(width: 300, height: 200) - window.isReleasedWhenClosed = false - window.center() + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 400), + styleMask: [.titled, .closable, .resizable, .miniaturizable], + backing: .buffered, + defer: false + ) - let delegate = WindowCloseDelegate { [weak self] in - self?.windows.removeValue(forKey: key) - self?.delegates.removeValue(forKey: key) - self?.hideAppIfNoWindows() - } - window.delegate = delegate - delegates[key] = delegate - windows[key] = window + let hostingView = NSHostingView(rootView: PopOutProcessView(buffer: buffer)) + window.contentView = hostingView + window.title = title + window.minSize = NSSize(width: 300, height: 200) + window.isReleasedWhenClosed = false + window.center() - window.makeKeyAndOrderFront(nil) - NSApp.setActivationPolicy(.regular) - NSApp.activate() + let delegate = WindowCloseDelegate { [weak self] in + self?.windows.removeValue(forKey: key) + self?.delegates.removeValue(forKey: key) + self?.hideAppIfNoWindows() } + window.delegate = delegate + delegates[key] = delegate + windows[key] = window + + window.makeKeyAndOrderFront(nil) + NSApp.setActivationPolicy(.regular) + NSApp.activate() + } - func closeWindow(for buffer: TerminalBuffer) { - let key = ObjectIdentifier(buffer) - if let window = windows.removeValue(forKey: key) { - window.close() - } - delegates.removeValue(forKey: key) - hideAppIfNoWindows() + func closeWindow(for buffer: TerminalBuffer) { + let key = ObjectIdentifier(buffer) + if let window = windows.removeValue(forKey: key) { + window.close() } + delegates.removeValue(forKey: key) + hideAppIfNoWindows() + } - func closeAll() { - for (_, window) in windows { - window.close() - } - windows.removeAll() - delegates.removeAll() - hideAppIfNoWindows() + func closeAll() { + for (_, window) in windows { + window.close() } + windows.removeAll() + delegates.removeAll() + hideAppIfNoWindows() + } - private func hideAppIfNoWindows() { - if windows.isEmpty { - NSApp.setActivationPolicy(.accessory) - } + private func hideAppIfNoWindows() { + if windows.isEmpty { + NSApp.setActivationPolicy(.accessory) } + } } // MARK: - Window Close Delegate private final class WindowCloseDelegate: NSObject, NSWindowDelegate, @unchecked Sendable { - let onClose: @MainActor () -> Void + let onClose: @MainActor () -> Void - init(onClose: @escaping @MainActor () -> Void) { - self.onClose = onClose - } + init(onClose: @escaping @MainActor () -> Void) { + self.onClose = onClose + } - func windowWillClose(_ notification: Notification) { - MainActor.assumeIsolated { - onClose() - } + func windowWillClose(_ notification: Notification) { + MainActor.assumeIsolated { + onClose() } + } } diff --git a/Sources/devtail/ProcessCardView.swift b/Sources/devtail/ProcessCardView.swift index 497f78d..9657a18 100644 --- a/Sources/devtail/ProcessCardView.swift +++ b/Sources/devtail/ProcessCardView.swift @@ -1,126 +1,126 @@ -import SwiftUI import DevtailKit +import SwiftUI struct ProcessCardView: View { - let process: DevProcess - var onSelect: () -> Void - var onToggle: () -> Void - var onDelete: () -> Void + let process: DevProcess + var onSelect: () -> Void + var onToggle: () -> Void + var onDelete: () -> Void - @State private var isHovered = false + @State private var isHovered = false - var body: some View { - Button(action: onSelect) { - VStack(alignment: .leading, spacing: 8) { - // Name and status - HStack { - Text(process.name) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(.primary) + var body: some View { + Button(action: onSelect) { + VStack(alignment: .leading, spacing: 8) { + // Name and status + HStack { + Text(process.name) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.primary) - Spacer() + Spacer() - StatusDot(isRunning: process.isRunning) - } + StatusDot(isRunning: process.isRunning) + } - // Command output preview - if process.buffer.hasContent { - TerminalBlock { - TerminalPreviewText( - buffer: process.buffer, - lineLimit: 3, - fontSize: 10 - ) - } - } + // Command output preview + if process.buffer.hasContent { + TerminalBlock { + TerminalPreviewText( + buffer: process.buffer, + lineLimit: 3, + fontSize: 10 + ) + } + } - // Auxiliary command previews - ForEach(process.auxiliaryCommands) { aux in - let auxBuf = process.bufferFor(auxiliary: aux.id) - VStack(alignment: .leading, spacing: 2) { - Text(aux.name.uppercased()) - .font(.system(size: 9, weight: .semibold, design: .rounded)) - .foregroundStyle(.tertiary) - .tracking(0.5) + // Auxiliary command previews + ForEach(process.auxiliaryCommands) { aux in + let auxBuf = process.bufferFor(auxiliary: aux.id) + VStack(alignment: .leading, spacing: 2) { + Text(aux.name.uppercased()) + .font(.system(size: 9, weight: .semibold, design: .rounded)) + .foregroundStyle(.tertiary) + .tracking(0.5) - TerminalBlock { - if auxBuf.hasContent { - TerminalPreviewText( - buffer: auxBuf, - lineLimit: 2, - fontSize: 10 - ) - } else { - Text(aux.command) - .font(.system(size: 10, design: .monospaced)) - .foregroundStyle(.tertiary) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - } - } - } - .padding(12) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color(nsColor: .controlBackgroundColor)) - ) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .strokeBorder( - isHovered - ? Color.accentColor.opacity(0.3) - : Color(nsColor: .separatorColor), - lineWidth: isHovered ? 1.5 : 1 + TerminalBlock { + if auxBuf.hasContent { + TerminalPreviewText( + buffer: auxBuf, + lineLimit: 2, + fontSize: 10 ) - ) - .scaleEffect(isHovered ? 1.01 : 1.0) - } - .buttonStyle(.plain) - .onHover { hovering in - withAnimation(.easeInOut(duration: 0.15)) { - isHovered = hovering - } - } - .contextMenu { - Button(process.isRunning ? "Stop Process" : "Start Process") { - onToggle() - } - Divider() - Button("Delete", role: .destructive) { - onDelete() + } else { + Text(aux.command) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity, alignment: .leading) + } } + } } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder( + isHovered + ? Color.accentColor.opacity(0.3) + : Color(nsColor: .separatorColor), + lineWidth: isHovered ? 1.5 : 1 + ) + ) + .scaleEffect(isHovered ? 1.01 : 1.0) + } + .buttonStyle(.plain) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.15)) { + isHovered = hovering + } + } + .contextMenu { + Button(process.isRunning ? "Stop Process" : "Start Process") { + onToggle() + } + Divider() + Button("Delete", role: .destructive) { + onDelete() + } } + } } // MARK: - Shared Components struct StatusDot: View { - let isRunning: Bool + let isRunning: Bool - var body: some View { - Circle() - .fill(isRunning ? Color.green : Color(nsColor: .separatorColor)) - .frame(width: 10, height: 10) - .shadow(color: isRunning ? .green.opacity(0.6) : .clear, radius: 4) - } + var body: some View { + Circle() + .fill(isRunning ? Color.green : Color(nsColor: .separatorColor)) + .frame(width: 10, height: 10) + .shadow(color: isRunning ? .green.opacity(0.6) : .clear, radius: 4) + } } struct TerminalBlock: View { - @ViewBuilder let content: () -> Content + @ViewBuilder let content: () -> Content - var body: some View { - content() - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color(nsColor: .textBackgroundColor)) - ) - .overlay( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 0.5) - ) - } + var body: some View { + content() + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color(nsColor: .textBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 0.5) + ) + } } diff --git a/Sources/devtail/ProcessDetailView.swift b/Sources/devtail/ProcessDetailView.swift index 0d30cf1..a55e428 100644 --- a/Sources/devtail/ProcessDetailView.swift +++ b/Sources/devtail/ProcessDetailView.swift @@ -1,157 +1,156 @@ -import SwiftUI import DevtailKit +import SwiftUI struct ProcessDetailView: View { - let process: DevProcess - var onToggle: () -> Void - var onEdit: () -> Void + let process: DevProcess + var onToggle: () -> Void + var onEdit: () -> Void - @State private var selectedTab = 0 - @State private var isTerminalHovered = false + @State private var selectedTab = 0 + @State private var isTerminalHovered = false - var body: some View { - VStack(alignment: .leading, spacing: 0) { - // Process info header - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 8) { - Text(process.name) - .font(.system(size: 15, weight: .semibold)) + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Process info header + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(process.name) + .font(.system(size: 15, weight: .semibold)) - Spacer() + Spacer() - Button(action: onEdit) { - Image(systemName: "gearshape") - .font(.system(size: 12)) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) + Button(action: onEdit) { + Image(systemName: "gearshape") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) - Button(action: onToggle) { - HStack(spacing: 6) { - StatusDot(isRunning: process.isRunning) - Text(process.isRunning ? "Running" : "Stopped") - .font(.system(size: 11)) - .foregroundStyle(.secondary) - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background( - Capsule() - .fill(Color(nsColor: .controlBackgroundColor)) - ) - .overlay( - Capsule() - .strokeBorder(Color(nsColor: .separatorColor), lineWidth: 0.5) - ) - } - .buttonStyle(.plain) - } + Button(action: onToggle) { + HStack(spacing: 6) { + StatusDot(isRunning: process.isRunning) + Text(process.isRunning ? "Running" : "Stopped") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + Capsule() + .fill(Color(nsColor: .controlBackgroundColor)) + ) + .overlay( + Capsule() + .strokeBorder(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + } - HStack(spacing: 4) { - Image(systemName: "chevron.right") - .font(.system(size: 8)) - Text(process.command) - .font(.system(size: 11, design: .monospaced)) - } - .foregroundStyle(.tertiary) + HStack(spacing: 4) { + Image(systemName: "chevron.right") + .font(.system(size: 8)) + Text(process.command) + .font(.system(size: 11, design: .monospaced)) + } + .foregroundStyle(.tertiary) - if !process.workingDirectory.isEmpty { - HStack(spacing: 4) { - Image(systemName: "folder") - .font(.system(size: 8)) - Text(process.workingDirectory) - .font(.system(size: 10)) - } - .foregroundStyle(.quaternary) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 10) + if !process.workingDirectory.isEmpty { + HStack(spacing: 4) { + Image(systemName: "folder") + .font(.system(size: 8)) + Text(process.workingDirectory) + .font(.system(size: 10)) + } + .foregroundStyle(.quaternary) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) - // Tab picker for output sources - if !process.auxiliaryCommands.isEmpty { - Picker("", selection: $selectedTab) { - Text("Output").tag(0) - ForEach(Array(process.auxiliaryCommands.enumerated()), id: \.element.id) { pair in - Text(pair.element.name).tag(pair.offset + 1) - } - } - .pickerStyle(.segmented) - .labelsHidden() - .padding(.horizontal, 12) - .padding(.bottom, 8) - } + // Tab picker for output sources + if !process.auxiliaryCommands.isEmpty { + Picker("", selection: $selectedTab) { + Text("Output").tag(0) + ForEach(Array(process.auxiliaryCommands.enumerated()), id: \.element.id) { pair in + Text(pair.element.name).tag(pair.offset + 1) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .padding(.horizontal, 12) + .padding(.bottom, 8) + } - // Terminal output - Group { - if currentBuffer.hasContent { - TerminalOutputView(buffer: currentBuffer) - } else { - Text("Waiting for output...") - .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(.tertiary) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } - .background(Color(nsColor: .textBackgroundColor)) - .overlay(alignment: .bottom) { - if isTerminalHovered { - Button { - let title: String - let auxIndex = selectedTab - 1 - if selectedTab == 0 || auxIndex >= process.auxiliaryCommands.count { - title = process.name - } else { - let aux = process.auxiliaryCommands[auxIndex] - title = "\(process.name) — \(aux.name)" - } - PopOutWindowManager.shared.openWindow(buffer: currentBuffer, title: title) - } label: { - HStack(spacing: 4) { - Text("Pop Out") - .font(.system(size: 11, weight: .medium)) - Image(systemName: "arrow.up.forward.app") - .font(.system(size: 10, weight: .medium)) - } - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity) - .padding(.vertical, 6) - .background(.ultraThinMaterial) - } - .buttonStyle(.plain) - .transition(.opacity) - } + // Terminal output + Group { + if currentBuffer.hasContent { + TerminalOutputView(buffer: currentBuffer) + } else { + Text("Waiting for output...") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .background(Color(nsColor: .textBackgroundColor)) + .overlay(alignment: .bottom) { + if isTerminalHovered { + Button { + let title: String + let auxIndex = selectedTab - 1 + if selectedTab == 0 || auxIndex >= process.auxiliaryCommands.count { + title = process.name + } else { + let aux = process.auxiliaryCommands[auxIndex] + title = "\(process.name) — \(aux.name)" } - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .strokeBorder(Color(nsColor: .separatorColor), lineWidth: 0.5) - ) - .onHover { hovering in - withAnimation(.easeInOut(duration: 0.15)) { - isTerminalHovered = hovering - } + PopOutWindowManager.shared.openWindow(buffer: currentBuffer, title: title) + } label: { + HStack(spacing: 4) { + Text("Pop Out") + .font(.system(size: 11, weight: .medium)) + Image(systemName: "arrow.up.forward.app") + .font(.system(size: 10, weight: .medium)) } - .padding(.horizontal, 12) - .padding(.bottom, 12) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + .background(.ultraThinMaterial) + } + .buttonStyle(.plain) + .transition(.opacity) } - // Reset tab if aux commands were removed during edit - .onChange(of: process.auxiliaryCommands.count) { - if selectedTab > process.auxiliaryCommands.count { - selectedTab = 0 - } + } + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.15)) { + isTerminalHovered = hovering } + } + .padding(.horizontal, 12) + .padding(.bottom, 12) } + // Reset tab if aux commands were removed during edit + .onChange(of: process.auxiliaryCommands.count) { + if selectedTab > process.auxiliaryCommands.count { + selectedTab = 0 + } + } + } - private var currentBuffer: TerminalBuffer { - if selectedTab == 0 { - return process.buffer - } - let auxIndex = selectedTab - 1 - guard auxIndex >= 0, auxIndex < process.auxiliaryCommands.count else { - return process.buffer - } - return process.bufferFor(auxiliary: process.auxiliaryCommands[auxIndex].id) + private var currentBuffer: TerminalBuffer { + if selectedTab == 0 { + return process.buffer } + let auxIndex = selectedTab - 1 + guard auxIndex >= 0, auxIndex < process.auxiliaryCommands.count else { + return process.buffer + } + return process.bufferFor(auxiliary: process.auxiliaryCommands[auxIndex].id) + } } - diff --git a/Sources/devtail/ProcessFormView.swift b/Sources/devtail/ProcessFormView.swift index 16e6455..335ca74 100644 --- a/Sources/devtail/ProcessFormView.swift +++ b/Sources/devtail/ProcessFormView.swift @@ -1,198 +1,202 @@ import SwiftUI struct ProcessFormView: View { - var store: ProcessStore - var editing: DevProcess? - var onDismiss: () -> Void - - @State private var name: String - @State private var command: String - @State private var workingDirectory: String - @State private var auxEntries: [AuxEntry] - @State private var isAddingAux = false - @State private var newAuxName = "" - @State private var newAuxCommand = "" - - private var isEditing: Bool { editing != nil } - - init(store: ProcessStore, editing: DevProcess? = nil, onDismiss: @escaping () -> Void) { - self.store = store - self.editing = editing - self.onDismiss = onDismiss - _name = State(initialValue: editing?.name ?? "") - _command = State(initialValue: editing?.command ?? "") - _workingDirectory = State(initialValue: editing?.workingDirectory ?? "") - _auxEntries = State(initialValue: editing?.auxiliaryCommands.map { - AuxEntry(id: $0.id, name: $0.name, command: $0.command) - } ?? []) - } + var store: ProcessStore + var editing: DevProcess? + var onDismiss: () -> Void + + @State private var name: String + @State private var command: String + @State private var workingDirectory: String + @State private var auxEntries: [AuxEntry] + @State private var isAddingAux = false + @State private var newAuxName = "" + @State private var newAuxCommand = "" + + private var isEditing: Bool { editing != nil } + + init(store: ProcessStore, editing: DevProcess? = nil, onDismiss: @escaping () -> Void) { + self.store = store + self.editing = editing + self.onDismiss = onDismiss + _name = State(initialValue: editing?.name ?? "") + _command = State(initialValue: editing?.command ?? "") + _workingDirectory = State(initialValue: editing?.workingDirectory ?? "") + _auxEntries = State( + initialValue: editing?.auxiliaryCommands.map { + AuxEntry(id: $0.id, name: $0.name, command: $0.command) + } ?? []) + } + + var body: some View { + VStack(spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text(isEditing ? "Edit Process" : "New Process") + .font(.system(size: 15, weight: .semibold)) + + VStack(alignment: .leading, spacing: 12) { + formField(label: "Name", placeholder: "my-server", text: $name) + formField(label: "Command", placeholder: "npm run dev", text: $command, monospaced: true) + formField( + label: "Working Directory", placeholder: "~/projects/app", text: $workingDirectory, monospaced: true) + } + + // Auxiliary commands section + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("LOG WATCHERS") + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .foregroundStyle(.tertiary) + .tracking(0.5) + + Spacer() + + Button { + withAnimation(.spring(duration: 0.2)) { isAddingAux = true } + } label: { + Image(systemName: "plus.circle") + .font(.system(size: 14)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } - var body: some View { - VStack(spacing: 0) { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - Text(isEditing ? "Edit Process" : "New Process") - .font(.system(size: 15, weight: .semibold)) - - VStack(alignment: .leading, spacing: 12) { - formField(label: "Name", placeholder: "my-server", text: $name) - formField(label: "Command", placeholder: "npm run dev", text: $command, monospaced: true) - formField(label: "Working Directory", placeholder: "~/projects/app", text: $workingDirectory, monospaced: true) - } - - // Auxiliary commands section - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("LOG WATCHERS") - .font(.system(size: 10, weight: .semibold, design: .rounded)) - .foregroundStyle(.tertiary) - .tracking(0.5) - - Spacer() - - Button { - withAnimation(.spring(duration: 0.2)) { isAddingAux = true } - } label: { - Image(systemName: "plus.circle") - .font(.system(size: 14)) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - } - - ForEach(auxEntries) { entry in - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(entry.name) - .font(.system(size: 11, weight: .medium)) - Text(entry.command) - .font(.system(size: 10, design: .monospaced)) - .foregroundStyle(.secondary) - } - Spacer() - Button { - withAnimation { - auxEntries.removeAll { $0.id == entry.id } - } - } label: { - Image(systemName: "minus.circle.fill") - .symbolRenderingMode(.hierarchical) - .foregroundStyle(.red) - } - .buttonStyle(.plain) - } - .padding(8) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color(nsColor: .controlBackgroundColor)) - ) - } - - if isAddingAux { - VStack(spacing: 8) { - TextField("Log name", text: $newAuxName) - .textFieldStyle(.roundedBorder) - .font(.system(size: 11)) - TextField("tail -f /path/to/log", text: $newAuxCommand) - .textFieldStyle(.roundedBorder) - .font(.system(size: 11, design: .monospaced)) - HStack { - Button("Cancel") { - isAddingAux = false - newAuxName = "" - newAuxCommand = "" - } - .controlSize(.small) - - Spacer() - - Button("Add") { - guard !newAuxName.isEmpty, !newAuxCommand.isEmpty else { return } - auxEntries.append(AuxEntry(name: newAuxName, command: newAuxCommand)) - newAuxName = "" - newAuxCommand = "" - isAddingAux = false - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - .disabled(newAuxName.isEmpty || newAuxCommand.isEmpty) - } - } - .padding(10) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color(nsColor: .controlBackgroundColor)) - ) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .strokeBorder(Color.accentColor.opacity(0.3), lineWidth: 1) - ) - } - } + ForEach(auxEntries) { entry in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(entry.name) + .font(.system(size: 11, weight: .medium)) + Text(entry.command) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.secondary) + } + Spacer() + Button { + withAnimation { + auxEntries.removeAll { $0.id == entry.id } + } + } label: { + Image(systemName: "minus.circle.fill") + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.red) } - .padding(16) + .buttonStyle(.plain) + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor)) + ) } - .scrollIndicators(.hidden) - Divider() - - Button(action: save) { - Text(isEditing ? "Save Changes" : "Create Process") - .font(.system(size: 13, weight: .medium)) - .frame(maxWidth: .infinity) + if isAddingAux { + VStack(spacing: 8) { + TextField("Log name", text: $newAuxName) + .textFieldStyle(.roundedBorder) + .font(.system(size: 11)) + TextField("tail -f /path/to/log", text: $newAuxCommand) + .textFieldStyle(.roundedBorder) + .font(.system(size: 11, design: .monospaced)) + HStack { + Button("Cancel") { + isAddingAux = false + newAuxName = "" + newAuxCommand = "" + } + .controlSize(.small) + + Spacer() + + Button("Add") { + guard !newAuxName.isEmpty, !newAuxCommand.isEmpty else { return } + auxEntries.append(AuxEntry(name: newAuxName, command: newAuxCommand)) + newAuxName = "" + newAuxCommand = "" + isAddingAux = false + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(newAuxName.isEmpty || newAuxCommand.isEmpty) + } + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(Color.accentColor.opacity(0.3), lineWidth: 1) + ) } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .disabled(name.isEmpty || command.isEmpty) - .padding(.horizontal, 16) - .padding(.vertical, 12) + } } + .padding(16) + } + .scrollIndicators(.hidden) + + Divider() + + Button(action: save) { + Text(isEditing ? "Save Changes" : "Create Process") + .font(.system(size: 13, weight: .medium)) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(name.isEmpty || command.isEmpty) + .padding(.horizontal, 16) + .padding(.vertical, 12) } - - @ViewBuilder - private func formField(label: String, placeholder: String, text: Binding, monospaced: Bool = false) -> some View { - VStack(alignment: .leading, spacing: 4) { - Text(label) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(.secondary) - TextField(placeholder, text: text) - .textFieldStyle(.roundedBorder) - .font(.system(size: 12, design: monospaced ? .monospaced : .default)) - } + } + + @ViewBuilder + private func formField(label: String, placeholder: String, text: Binding, monospaced: Bool = false) + -> some View + { + VStack(alignment: .leading, spacing: 4) { + Text(label) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + TextField(placeholder, text: text) + .textFieldStyle(.roundedBorder) + .font(.system(size: 12, design: monospaced ? .monospaced : .default)) } + } - private func save() { - let auxCommands = auxEntries.map { - AuxiliaryCommand(id: $0.id, name: $0.name, command: $0.command) - } - if let process = editing { - store.updateProcess( - id: process.id, - name: name, - command: command, - workingDirectory: workingDirectory, - auxiliaryCommands: auxCommands - ) - } else { - store.addProcess( - name: name, - command: command, - workingDirectory: workingDirectory, - auxiliaryCommands: auxCommands - ) - } - onDismiss() + private func save() { + let auxCommands = auxEntries.map { + AuxiliaryCommand(id: $0.id, name: $0.name, command: $0.command) } + if let process = editing { + store.updateProcess( + id: process.id, + name: name, + command: command, + workingDirectory: workingDirectory, + auxiliaryCommands: auxCommands + ) + } else { + store.addProcess( + name: name, + command: command, + workingDirectory: workingDirectory, + auxiliaryCommands: auxCommands + ) + } + onDismiss() + } } private struct AuxEntry: Identifiable { - let id: UUID - var name: String - var command: String - - init(id: UUID = UUID(), name: String, command: String) { - self.id = id - self.name = name - self.command = command - } + let id: UUID + var name: String + var command: String + + init(id: UUID = UUID(), name: String, command: String) { + self.id = id + self.name = name + self.command = command + } } diff --git a/Sources/devtail/ProcessStore.swift b/Sources/devtail/ProcessStore.swift index b1de96f..6b207b4 100644 --- a/Sources/devtail/ProcessStore.swift +++ b/Sources/devtail/ProcessStore.swift @@ -1,100 +1,102 @@ -import SwiftUI import DevtailKit +import SwiftUI @MainActor @Observable final class ProcessStore { - var processes: [DevProcess] - private var isQuitting = false + var processes: [DevProcess] + private var isQuitting = false - init() { - let saved = Persistence.load() - self.processes = saved.map { config in - DevProcess( - id: config.id, - name: config.name, - command: config.command, - workingDirectory: config.workingDirectory, - auxiliaryCommands: config.auxiliaryCommands.map { - AuxiliaryCommand(id: $0.id, name: $0.name, command: $0.command) - } - ) + init() { + let saved = Persistence.load() + self.processes = saved.map { config in + DevProcess( + id: config.id, + name: config.name, + command: config.command, + workingDirectory: config.workingDirectory, + auxiliaryCommands: config.auxiliaryCommands.map { + AuxiliaryCommand(id: $0.id, name: $0.name, command: $0.command) } + ) + } - // Wire up persistence callbacks - for process in processes { - process.onStateChange = { [weak self] in self?.save() } - } + // Wire up persistence callbacks + for process in processes { + process.onStateChange = { [weak self] in self?.save() } + } - // Defer auto-start so views have time to initialize - let autoStartIDs = Set(saved.filter(\.wasRunning).map(\.id)) - if !autoStartIDs.isEmpty { - Task { @MainActor [weak self] in - try? await Task.sleep(for: .milliseconds(100)) - guard let self else { return } - for process in self.processes where autoStartIDs.contains(process.id) { - process.start() - } - } + // Defer auto-start so views have time to initialize + let autoStartIDs = Set(saved.filter(\.wasRunning).map(\.id)) + if !autoStartIDs.isEmpty { + Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(100)) + guard let self else { return } + for process in self.processes where autoStartIDs.contains(process.id) { + process.start() } + } } + } - // MARK: - CRUD + // MARK: - CRUD - func addProcess(name: String, command: String, workingDirectory: String, auxiliaryCommands: [AuxiliaryCommand]) { - let process = DevProcess( - name: name, - command: command, - workingDirectory: workingDirectory, - auxiliaryCommands: auxiliaryCommands - ) - process.onStateChange = { [weak self] in self?.save() } - withAnimation(.spring(duration: 0.35, bounce: 0.2)) { - processes.insert(process, at: 0) - } - save() + func addProcess(name: String, command: String, workingDirectory: String, auxiliaryCommands: [AuxiliaryCommand]) { + let process = DevProcess( + name: name, + command: command, + workingDirectory: workingDirectory, + auxiliaryCommands: auxiliaryCommands + ) + process.onStateChange = { [weak self] in self?.save() } + withAnimation(.spring(duration: 0.35, bounce: 0.2)) { + processes.insert(process, at: 0) } + save() + } - func removeProcess(id: UUID) { - if let process = processes.first(where: { $0.id == id }) { - process.stop() - PopOutWindowManager.shared.closeWindow(for: process.buffer) - for aux in process.auxiliaryCommands { - PopOutWindowManager.shared.closeWindow(for: process.bufferFor(auxiliary: aux.id)) - } - } - withAnimation(.spring(duration: 0.3)) { - processes.removeAll { $0.id == id } - } - save() + func removeProcess(id: UUID) { + if let process = processes.first(where: { $0.id == id }) { + process.stop() + PopOutWindowManager.shared.closeWindow(for: process.buffer) + for aux in process.auxiliaryCommands { + PopOutWindowManager.shared.closeWindow(for: process.bufferFor(auxiliary: aux.id)) + } + } + withAnimation(.spring(duration: 0.3)) { + processes.removeAll { $0.id == id } } + save() + } - func updateProcess(id: UUID, name: String, command: String, workingDirectory: String, auxiliaryCommands: [AuxiliaryCommand]) { - guard let process = processes.first(where: { $0.id == id }) else { return } - if process.isRunning { process.stop() } + func updateProcess( + id: UUID, name: String, command: String, workingDirectory: String, auxiliaryCommands: [AuxiliaryCommand] + ) { + guard let process = processes.first(where: { $0.id == id }) else { return } + if process.isRunning { process.stop() } - process.name = name - process.command = command - process.workingDirectory = workingDirectory - process.auxiliaryCommands = auxiliaryCommands - process.cleanupAuxiliaryBuffers() - save() - } + process.name = name + process.command = command + process.workingDirectory = workingDirectory + process.auxiliaryCommands = auxiliaryCommands + process.cleanupAuxiliaryBuffers() + save() + } - // MARK: - Persistence + // MARK: - Persistence - func save() { - guard !isQuitting else { return } - Persistence.save(processes) - } + func save() { + guard !isQuitting else { return } + Persistence.save(processes) + } - /// Save state then synchronously kill all processes. - /// Blocks until every child process is dead — only call during app quit. - func stopAllForQuit() { - isQuitting = true - Persistence.save(processes) // Captures wasRunning before we stop - for process in processes where process.isRunning { - process.forceStop() - } + /// Save state then synchronously kill all processes. + /// Blocks until every child process is dead — only call during app quit. + func stopAllForQuit() { + isQuitting = true + Persistence.save(processes) // Captures wasRunning before we stop + for process in processes where process.isRunning { + process.forceStop() } + } }