Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@ jobs:

- name: Build
run: swift build

- name: Test
run: swift test
4 changes: 4 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,9 @@ let package = Package(
name: "devtail",
dependencies: ["DevtailKit"]
),
.testTarget(
name: "DevtailKitTests",
dependencies: ["DevtailKit"]
),
]
)
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,7 @@ swift run
```

The app appears as a terminal icon in your menu bar.

## License

MIT
40 changes: 10 additions & 30 deletions Sources/DevtailKit/ANSIParser.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
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 standard(UInt8)
case bright(UInt8)
case palette(UInt8)
case rgb(UInt8, UInt8, UInt8)
}

Expand Down Expand Up @@ -41,8 +39,6 @@ public enum TerminalAction: Sendable {
case cursorUp(Int)
}

// MARK: - Parser

public struct ANSIParser: Sendable {
public var currentStyle = ANSIStyle()

Expand All @@ -64,7 +60,7 @@ public struct ANSIParser: Sendable {
let ch = input[i]

switch ch {
case "\u{1B}": // ESC
case "\u{1B}":
flushText()
let next = input.index(after: i)
if next < input.endIndex && input[next] == "[" {
Expand All @@ -77,18 +73,11 @@ public struct ANSIParser: Sendable {
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":
Expand All @@ -97,7 +86,6 @@ public struct ANSIParser: Sendable {

default:
if let ascii = ch.asciiValue, ascii < 32, ascii != 9 {
// Skip control characters except tab
} else {
textBuf.append(ch)
}
Expand All @@ -110,8 +98,6 @@ public struct ANSIParser: Sendable {
return actions
}

// MARK: - CSI Parsing

private mutating func parseCSI(_ input: String, from start: String.Index) -> (TerminalAction?, String.Index)? {
var paramStr = ""
var i = start
Expand All @@ -129,22 +115,22 @@ public struct ANSIParser: Sendable {
i = input.index(after: i)
}

return nil // Incomplete sequence
return nil
}

private mutating func handleCSI(params: String, final: Character) -> TerminalAction? {
let parts = params.split(separator: ";", omittingEmptySubsequences: false).map { Int($0) }

switch final {
case "m": // SGR — Select Graphic Rendition
case "m":
applySGR(parts)
return nil

case "K": // Erase in Line
case "K":
let mode = parts.first.flatMap({ $0 }) ?? 0
return mode == 2 ? .eraseLine : .eraseToEndOfLine

case "A": // Cursor Up
case "A":
let n = parts.first.flatMap({ $0 }) ?? 1
return .cursorUp(max(1, n))

Expand All @@ -153,8 +139,6 @@ public struct ANSIParser: Sendable {
}
}

// MARK: - SGR

private mutating func applySGR(_ params: [Int?]) {
if params.isEmpty || (params.count == 1 && params[0] == nil) {
currentStyle = ANSIStyle()
Expand All @@ -179,7 +163,6 @@ public struct ANSIParser: Sendable {
case 24: currentStyle.underline = false
case 29: currentStyle.strikethrough = false

// Foreground colors
case 30...37:
currentStyle.foreground = .standard(UInt8(code - 30))
case 38:
Expand All @@ -190,7 +173,6 @@ public struct ANSIParser: Sendable {
case 39:
currentStyle.foreground = .default

// Background colors
case 40...47:
currentStyle.background = .standard(UInt8(code - 40))
case 48:
Expand All @@ -201,11 +183,9 @@ public struct ANSIParser: Sendable {
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))

Expand All @@ -222,10 +202,10 @@ public struct ANSIParser: Sendable {
let mode = params[index] ?? 0

switch mode {
case 5: // 256-color palette
case 5:
guard index + 1 < params.count, let n = params[index + 1] else { return nil }
return (.palette(UInt8(clamping: n)), 2)
case 2: // Truecolor RGB
case 2:
guard index + 3 < params.count else { return nil }
let r = UInt8(clamping: params[index + 1] ?? 0)
let g = UInt8(clamping: params[index + 2] ?? 0)
Expand Down
9 changes: 0 additions & 9 deletions Sources/DevtailKit/ProcessRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import Foundation
public final class ProcessRunner {
private var process: Process?
private var readTask: Task<Void, Never>?
/// 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() {}
Expand Down Expand Up @@ -99,8 +97,6 @@ public final class ProcessRunner {
let pid = launchedPID
guard pid != 0 else { return }

// Always kill the process group — the shell may have exited
// but npm/node/next-server children can still be alive.
kill(-pid, SIGTERM)

Task.detached {
Expand All @@ -114,24 +110,19 @@ public final class ProcessRunner {
readTask = nil
}

/// 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 }

kill(-pid, SIGTERM)

// Poll until the group leader is gone
let deadline = Date().addingTimeInterval(timeout)
while kill(pid, 0) == 0 && Date() < deadline {
Thread.sleep(forTimeInterval: 0.01)
}

// Force kill any survivors
if kill(pid, 0) == 0 {
kill(-pid, SIGKILL)
// Brief wait for kernel cleanup
Thread.sleep(forTimeInterval: 0.05)
}

Expand Down
4 changes: 0 additions & 4 deletions Sources/DevtailKit/TerminalBuffer.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import SwiftUI

// MARK: - Terminal Line

public struct TerminalLine: Sendable, Identifiable {
public let id: Int
public var spans: [StyledSpan]
Expand All @@ -20,8 +18,6 @@ public struct TerminalLine: Sendable, Identifiable {
}
}

// MARK: - Terminal Buffer

@MainActor
@Observable
public final class TerminalBuffer {
Expand Down
18 changes: 1 addition & 17 deletions Sources/DevtailKit/TerminalOutputView.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import AppKit
import SwiftUI

// MARK: - Full Output View (NSTextView-backed for performance + selection)

public struct TerminalOutputView: View {
let buffer: TerminalBuffer
var fontSize: CGFloat
Expand All @@ -13,8 +11,7 @@ public struct TerminalOutputView: View {
}

public var body: some View {
// Reading version registers @Observable tracking
let _ = buffer.version // swiftlint:disable:this redundant_discardable_let
let _ = buffer.version
TerminalNSView(buffer: buffer, version: buffer.version, fontSize: fontSize)
}
}
Expand Down Expand Up @@ -65,7 +62,6 @@ private struct TerminalNSView: NSViewRepresentable {
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?
Expand All @@ -77,9 +73,6 @@ private struct TerminalNSView: NSViewRepresentable {
guard let storage = textView.textStorage else { return }
guard let scrollView = textView.enclosingScrollView else { return }

// Check if we're at (or near) the bottom before updating content.
// If so, follow new output. If the user scrolled up, stay put.
// On first update, always scroll to bottom (new view showing existing content).
let clipView = scrollView.contentView
let maxScrollY = max(textView.frame.height - clipView.bounds.height, 0)
let isAtBottom = isFirstUpdate || clipView.bounds.origin.y >= maxScrollY - 20
Expand All @@ -94,9 +87,6 @@ private struct TerminalNSView: NSViewRepresentable {
if isAtBottom {
if isFirstUpdate {
isFirstUpdate = false
// Defer scroll until after the view has been laid out by the window system.
// On first render, geometry is zero-sized so an immediate scroll has no effect.
// We need two run-loop passes: one for the window to lay out, one to scroll.
DispatchQueue.main.async { [weak textView] in
guard let textView, let sv = textView.enclosingScrollView else { return }
sv.layoutSubtreeIfNeeded()
Expand Down Expand Up @@ -168,8 +158,6 @@ private struct TerminalNSView: NSViewRepresentable {
}
}

// MARK: - Preview Text (for cards, compact display)

public struct TerminalPreviewText: View {
let buffer: TerminalBuffer
var lineLimit: Int
Expand Down Expand Up @@ -226,8 +214,6 @@ public struct TerminalPreviewText: View {
}
}

// MARK: - ANSIColor → SwiftUI Color

extension ANSIColor {
public var swiftUIColor: Color {
switch self {
Expand Down Expand Up @@ -299,8 +285,6 @@ extension ANSIColor {
}
}

// MARK: - ANSIColor → NSColor

extension ANSIColor {
public var nsColor: NSColor {
switch self {
Expand Down
1 change: 0 additions & 1 deletion Sources/devtail/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
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
Expand Down
4 changes: 0 additions & 4 deletions Sources/devtail/AppNotifications.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,6 @@ enum AppNotifications {
}
}

// MARK: - Bundled .app — UNUserNotificationCenter

private static func sendUNNotification(title: String, body: String) {
let content = UNMutableNotificationContent()
content.title = title
Expand All @@ -42,8 +40,6 @@ enum AppNotifications {
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: "\\\\")
Expand Down
7 changes: 0 additions & 7 deletions Sources/devtail/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,6 @@ struct ContentView: View {
.background(.background)
}

// MARK: - Header

private var headerBar: some View {
HStack(spacing: 10) {
if viewState != .list {
Expand Down Expand Up @@ -78,8 +76,6 @@ struct ContentView: View {
.animation(.easeInOut(duration: 0.2), value: viewState)
}

// MARK: - Content

@ViewBuilder
private var contentArea: some View {
switch viewState {
Expand Down Expand Up @@ -189,9 +185,6 @@ struct ContentView: View {
.padding(.horizontal, 24)
}

// MARK: - Footer

/// SMAppService requires a proper .app bundle to work.
private var canManageLaunchAtLogin: Bool {
Bundle.main.bundlePath.hasSuffix(".app")
}
Expand Down
Loading
Loading