diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b702c9..cf840d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,11 +14,6 @@ jobs: 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/ diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 800f249..cf79136 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -20,11 +20,6 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install Swift 6.2 - uses: swift-actions/setup-swift@v2 - with: - swift-version: '6.2' - - name: Import code signing certificate env: DEVELOPER_CERT_P12: ${{ secrets.DEVELOPER_CERT_P12 }} diff --git a/Sources/devtail/AppDelegate.swift b/Sources/devtail/AppDelegate.swift index 1283dd9..18aebde 100644 --- a/Sources/devtail/AppDelegate.swift +++ b/Sources/devtail/AppDelegate.swift @@ -1,12 +1,34 @@ import AppKit +import SwiftUI @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { - var store: ProcessStore? + var store: ProcessStore! + private var statusItem: NSStatusItem! + private var popover: NSPopover! private var signalSource: DispatchSourceSignal? func applicationDidFinishLaunching(_ notification: Notification) { + store = ProcessStore() + store.onIconChange = { [weak self] in self?.updateMenuBarIcon() } + + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + updateMenuBarIcon() + + if let button = statusItem.button { + button.action = #selector(togglePopover) + button.target = self + } + + popover = NSPopover() + popover.contentSize = NSSize(width: 360, height: 500) + popover.behavior = .transient + popover.contentViewController = NSHostingController( + rootView: ContentView(store: store) + .onAppear { AppNotifications.requestPermission() } + ) + signal(SIGTERM, SIG_IGN) let source = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main) source.setEventHandler { [weak self] in @@ -25,6 +47,44 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } + @objc private func togglePopover() { + if popover.isShown { + popover.close() + } else if let button = statusItem.button { + popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + NSApp.activate() + } + } + + private func updateMenuBarIcon() { + let anyRunning = store.processes.contains { $0.isRunning } + guard + let baseImage = NSImage( + systemSymbolName: "terminal", + accessibilityDescription: "devtail" + ) + else { return } + + let dotSize: CGFloat = 4.5 + let dotGap: CGFloat = 1.5 + let totalWidth = anyRunning ? baseImage.size.width + dotGap + dotSize : baseImage.size.width + let composited = NSImage( + size: NSSize(width: totalWidth, height: baseImage.size.height), + flipped: false + ) { _ in + baseImage.draw(in: NSRect(origin: .zero, size: baseImage.size)) + if anyRunning { + let dotY = (baseImage.size.height - dotSize) / 2 + let dotRect = NSRect(x: baseImage.size.width + dotGap, y: dotY, width: dotSize, height: dotSize) + NSColor.black.setFill() + NSBezierPath(ovalIn: dotRect).fill() + } + return true + } + composited.isTemplate = true + statusItem.button?.image = composited + } + private func performCleanup() { PopOutWindowManager.shared.closeAll() store?.stopAllForQuit() diff --git a/Sources/devtail/DevtailApp.swift b/Sources/devtail/DevtailApp.swift index 842e445..767683f 100644 --- a/Sources/devtail/DevtailApp.swift +++ b/Sources/devtail/DevtailApp.swift @@ -2,17 +2,9 @@ import SwiftUI @main struct DevtailApp: App { - @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() - } - } - .menuBarExtraStyle(.window) + Settings { EmptyView() } } } diff --git a/Sources/devtail/ProcessCardView.swift b/Sources/devtail/ProcessCardView.swift index 236fafe..730d11e 100644 --- a/Sources/devtail/ProcessCardView.swift +++ b/Sources/devtail/ProcessCardView.swift @@ -9,6 +9,20 @@ struct ProcessCardView: View { @State private var isHovered = false + private func popOutButton(buffer: TerminalBuffer, title: String) -> some View { + Button { + PopOutWindowManager.shared.openWindow(buffer: buffer, title: title) + } label: { + Image(systemName: "arrow.up.forward.app") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.tertiary) + .padding(4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("Pop out") + } + var body: some View { Button(action: onSelect) { VStack(alignment: .leading, spacing: 8) { @@ -30,6 +44,9 @@ struct ProcessCardView: View { fontSize: 10 ) } + .overlay(alignment: .topTrailing) { + popOutButton(buffer: process.buffer, title: process.name) + } } ForEach(process.auxiliaryCommands) { aux in @@ -54,6 +71,12 @@ struct ProcessCardView: View { .frame(maxWidth: .infinity, alignment: .leading) } } + .overlay(alignment: .topTrailing) { + popOutButton( + buffer: auxBuf, + title: "\(process.name) — \(aux.name)" + ) + } } } } diff --git a/Sources/devtail/ProcessStore.swift b/Sources/devtail/ProcessStore.swift index 059c6f6..8b52808 100644 --- a/Sources/devtail/ProcessStore.swift +++ b/Sources/devtail/ProcessStore.swift @@ -5,6 +5,7 @@ import SwiftUI @Observable final class ProcessStore { var processes: [DevProcess] + var onIconChange: (() -> Void)? private var isQuitting = false init() { @@ -22,7 +23,10 @@ final class ProcessStore { } for process in processes { - process.onStateChange = { [weak self] in self?.save() } + process.onStateChange = { [weak self] in + self?.save() + self?.onIconChange?() + } } let autoStartIDs = Set(saved.filter(\.wasRunning).map(\.id)) @@ -44,7 +48,10 @@ final class ProcessStore { workingDirectory: workingDirectory, auxiliaryCommands: auxiliaryCommands ) - process.onStateChange = { [weak self] in self?.save() } + process.onStateChange = { [weak self] in + self?.save() + self?.onIconChange?() + } withAnimation(.spring(duration: 0.35, bounce: 0.2)) { processes.insert(process, at: 0) }