diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index fd2f7d12..dd202506 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -7,6 +7,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent weak var tabManager: TabManager? weak var notificationStore: TerminalNotificationStore? + weak var sidebarState: SidebarState? private var workspaceObserver: NSObjectProtocol? private let updateController = UpdateController() private lazy var titlebarAccessoryController = UpdateTitlebarAccessoryController(viewModel: updateViewModel) @@ -61,9 +62,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent notificationStore?.clearAll() } - func configure(tabManager: TabManager, notificationStore: TerminalNotificationStore) { + func configure(tabManager: TabManager, notificationStore: TerminalNotificationStore, sidebarState: SidebarState) { self.tabManager = tabManager self.notificationStore = notificationStore + self.sidebarState = sidebarState } @objc func checkForUpdates(_ sender: Any?) { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 9a8a218c..bc8649e7 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1,10 +1,19 @@ import AppKit import SwiftUI +final class SidebarState: ObservableObject { + @Published var isVisible: Bool = true + + func toggle() { + isVisible.toggle() + } +} + struct ContentView: View { @ObservedObject var updateViewModel: UpdateViewModel @EnvironmentObject var tabManager: TabManager @EnvironmentObject var notificationStore: TerminalNotificationStore + @EnvironmentObject var sidebarState: SidebarState @State private var sidebarWidth: CGFloat = 200 @State private var sidebarMinX: CGFloat = 0 @State private var isResizerHovering = false @@ -17,9 +26,8 @@ struct ContentView: View { var body: some View { HStack(spacing: 0) { - // Vertical Tabs Sidebar + if sidebarState.isVisible { VerticalTabsSidebar( - sidebarWidth: sidebarWidth, selection: $sidebarSelection, selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex @@ -79,23 +87,24 @@ struct ContentView: View { } ) } + } - // Terminal Content - use ZStack to keep all surfaces alive + // Terminal Content - use ZStack to keep all surfaces alive + ZStack { ZStack { - ZStack { - ForEach(tabManager.tabs) { tab in - let isActive = tabManager.selectedTabId == tab.id - TerminalSplitTreeView(tab: tab, isTabActive: isActive) - .opacity(isActive ? 1 : 0) - .allowsHitTesting(isActive) - .focusable() - .focused($focusedTabId, equals: tab.id) - } + ForEach(tabManager.tabs) { tab in + let isActive = tabManager.selectedTabId == tab.id + TerminalSplitTreeView(tab: tab, isTabActive: isActive) + .opacity(isActive ? 1 : 0) + .allowsHitTesting(isActive) + .focusable() + .focused($focusedTabId, equals: tab.id) } - .opacity(sidebarSelection == .tabs ? 1 : 0) - .allowsHitTesting(sidebarSelection == .tabs) + } + .opacity(sidebarSelection == .tabs ? 1 : 0) + .allowsHitTesting(sidebarSelection == .tabs) - NotificationsPage(selection: $sidebarSelection) + NotificationsPage(selection: $sidebarSelection) .opacity(sidebarSelection == .notifications ? 1 : 0) .allowsHitTesting(sidebarSelection == .notifications) } @@ -144,86 +153,46 @@ struct ContentView: View { }) } + private func addTab() { + tabManager.addTab() + sidebarSelection = .tabs + } + } struct VerticalTabsSidebar: View { @EnvironmentObject var tabManager: TabManager - @EnvironmentObject var notificationStore: TerminalNotificationStore - let sidebarWidth: CGFloat @Binding var selection: SidebarSelection @Binding var selectedTabIds: Set @Binding var lastSidebarSelectionIndex: Int? var body: some View { - VStack(spacing: 0) { - // Header with title - HStack { - Button(action: { selection = .tabs }) { - Text("Tabs") - .font(.headline) - .foregroundColor(selection == .tabs ? .primary : .secondary) - } - .buttonStyle(.plain) - - Spacer() - - Button(action: { selection = .notifications }) { - HStack(spacing: 6) { - Image(systemName: "bell") - .font(.system(size: 12, weight: .medium)) - if notificationStore.unreadCount > 0 { - Text("\(notificationStore.unreadCount)") - .font(.system(size: 10, weight: .semibold)) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Capsule().fill(Color.accentColor)) - .foregroundColor(.white) + GeometryReader { proxy in + ScrollView { + VStack(spacing: 0) { + LazyVStack(spacing: 2) { + ForEach(Array(tabManager.tabs.enumerated()), id: \.element.id) { index, tab in + TabItemView( + tab: tab, + index: index, + selection: $selection, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex + ) } } - } - .buttonStyle(.plain) - .foregroundColor(selection == .notifications ? .primary : .secondary) + .padding(.vertical, 8) - Button(action: { tabManager.addTab() }) { - Image(systemName: "plus") - .font(.system(size: 12, weight: .medium)) + SidebarEmptyArea( + selection: $selection, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) } - .buttonStyle(.plain) - .foregroundColor(.secondary) - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - - Divider() - - // Tab List - GeometryReader { proxy in - ScrollView { - VStack(spacing: 0) { - LazyVStack(spacing: 2) { - ForEach(Array(tabManager.tabs.enumerated()), id: \.element.id) { index, tab in - TabItemView( - tab: tab, - index: index, - selection: $selection, - selectedTabIds: $selectedTabIds, - lastSidebarSelectionIndex: $lastSidebarSelectionIndex - ) - } - } - .padding(.vertical, 4) - - SidebarEmptyArea( - selection: $selection, - selectedTabIds: $selectedTabIds, - lastSidebarSelectionIndex: $lastSidebarSelectionIndex - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .frame(minHeight: proxy.size.height, alignment: .top) - } - .accessibilityIdentifier("Sidebar") + .frame(minHeight: proxy.size.height, alignment: .top) } + .accessibilityIdentifier("Sidebar") } .background(Color(nsColor: .controlBackgroundColor)) } diff --git a/Sources/Splits/TerminalSplitTreeView.swift b/Sources/Splits/TerminalSplitTreeView.swift index 99d66bb0..c224ee9b 100644 --- a/Sources/Splits/TerminalSplitTreeView.swift +++ b/Sources/Splits/TerminalSplitTreeView.swift @@ -67,21 +67,20 @@ fileprivate struct TerminalSplitSubtreeView: View { ) .background(Color.clear) - if notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surface.id) { - Circle() - .stroke(Color(nsColor: .systemBlue), lineWidth: 2.5) - .frame(width: 14, height: 14) - .shadow(color: Color(nsColor: .systemBlue).opacity(0.35), radius: 2) - .padding(6) - .allowsHitTesting(false) - } - if isSplit && !isFocused && appearance.unfocusedOverlayOpacity > 0 { Rectangle() .fill(appearance.unfocusedOverlayColor) .opacity(appearance.unfocusedOverlayOpacity) .allowsHitTesting(false) } + + if notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surface.id) { + Rectangle() + .stroke(Color(nsColor: .systemBlue), lineWidth: 2.5) + .shadow(color: Color(nsColor: .systemBlue).opacity(0.35), radius: 3) + .padding(2) + .allowsHitTesting(false) + } } case .split(let split): let splitViewDirection: SplitViewDirection = switch split.direction { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 086251a2..cda460c9 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -49,6 +49,7 @@ class Tab: Identifiable, ObservableObject { guard isSelectedTab && isAppFocused else { return } guard let notificationStore = AppDelegate.shared?.notificationStore else { return } if notificationStore.hasUnreadNotification(forTabId: self.id, surfaceId: id) { + triggerNotificationFocusFlash(surfaceId: id, requiresSplit: false, shouldFocus: false) notificationStore.markRead(forTabId: self.id, surfaceId: id) return } @@ -347,6 +348,8 @@ class TabManager: ObservableObject { func closeTab(_ tab: Tab) { guard tabs.count > 1 else { return } + AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id) + if let index = tabs.firstIndex(where: { $0.id == tab.id }) { tabs.remove(at: index) @@ -383,7 +386,7 @@ class TabManager: ObservableObject { ) else { return } } - _ = tab.closeSurface(focusedSurfaceId) + _ = closeSurface(tabId: selectedId, surfaceId: focusedSurfaceId) } func closeCurrentTabWithConfirmation() { @@ -466,6 +469,9 @@ class TabManager: ObservableObject { guard let surfaceId = focusedSurfaceId(for: tabId) else { return } guard let notificationStore = AppDelegate.shared?.notificationStore else { return } guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return } + if let tab = tabs.first(where: { $0.id == tabId }) { + tab.triggerNotificationFocusFlash(surfaceId: surfaceId, requiresSplit: false, shouldFocus: false) + } notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId) } @@ -612,6 +618,7 @@ class TabManager: ObservableObject { guard let tabIndex = tabs.firstIndex(where: { $0.id == tabId }) else { return false } let tab = tabs[tabIndex] guard tab.closeSurface(surfaceId) else { return false } + AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tabId, surfaceId: surfaceId) if tab.splitTree.isEmpty { if tabs.count > 1 { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 52faecec..8014ae29 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -119,12 +119,10 @@ class TerminalController { guard !trimmed.isEmpty else { continue } let response = processCommand(trimmed) - response.withCString { ptr in + let payload = response + "\n" + payload.withCString { ptr in _ = write(socket, ptr, strlen(ptr)) } - "\n".withCString { ptr in - _ = write(socket, ptr, 1) - } } } } diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 8620b6db..de49e822 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -149,6 +149,17 @@ final class TerminalNotificationStore: ObservableObject { } } + func clearNotifications(forTabId tabId: UUID) { + let ids = notifications + .filter { $0.tabId == tabId } + .map { $0.id.uuidString } + notifications.removeAll { $0.tabId == tabId } + if !ids.isEmpty { + center.removeDeliveredNotifications(withIdentifiers: ids) + center.removePendingNotificationRequests(withIdentifiers: ids) + } + } + private func scheduleUserNotification(_ notification: TerminalNotification) { ensureAuthorization { [weak self] authorized in guard let self, authorized else { return } diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index 8b5e0201..e141b0d0 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -84,6 +84,259 @@ private struct TitlebarAccessoryView: View { } } +private struct TitlebarControlsView: View { + @ObservedObject var notificationStore: TerminalNotificationStore + let onToggleSidebar: () -> Void + let onNewTab: () -> Void + @State private var isShowingNotifications = false + + var body: some View { + HStack(spacing: 10) { + Button(action: onToggleSidebar) { + Image(systemName: "sidebar.left") + .font(.system(size: 15, weight: .semibold)) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + .accessibilityLabel("Toggle Sidebar") + + Button(action: { isShowingNotifications.toggle() }) { + ZStack(alignment: .topTrailing) { + Image(systemName: "bell") + .font(.system(size: 15, weight: .semibold)) + .frame(width: 24, height: 24) + + if notificationStore.unreadCount > 0 { + Text("\(min(notificationStore.unreadCount, 99))") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(.white) + .frame(width: 14, height: 14) + .background( + Circle().fill(Color.accentColor) + ) + .offset(x: 2, y: -2) + } + } + .frame(width: 26, height: 24) + } + .buttonStyle(.plain) + .accessibilityLabel("Notifications") + .popover(isPresented: $isShowingNotifications, arrowEdge: .top) { + NotificationsPopoverView(notificationStore: notificationStore) + } + + Button(action: onNewTab) { + Image(systemName: "plus") + .font(.system(size: 15, weight: .semibold)) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + .accessibilityLabel("New Tab") + } + .padding(.leading, 4) + } +} + +final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewController { + private let hostingView: NonDraggableHostingView + private let containerView = NSView() + private var pendingSizeUpdate = false + + init(notificationStore: TerminalNotificationStore) { + let toggleSidebar = { _ = AppDelegate.shared?.sidebarState?.toggle() } + let newTab = { _ = AppDelegate.shared?.tabManager?.addTab() } + + hostingView = NonDraggableHostingView( + rootView: TitlebarControlsView( + notificationStore: notificationStore, + onToggleSidebar: toggleSidebar, + onNewTab: newTab + ) + ) + + super.init(nibName: nil, bundle: nil) + + view = containerView + containerView.translatesAutoresizingMaskIntoConstraints = true + hostingView.translatesAutoresizingMaskIntoConstraints = true + hostingView.autoresizingMask = [.width, .height] + containerView.addSubview(hostingView) + + scheduleSizeUpdate() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidAppear() { + super.viewDidAppear() + scheduleSizeUpdate() + } + + override func viewDidLayout() { + super.viewDidLayout() + scheduleSizeUpdate() + } + + private func scheduleSizeUpdate() { + guard !pendingSizeUpdate else { return } + pendingSizeUpdate = true + DispatchQueue.main.async { [weak self] in + self?.pendingSizeUpdate = false + self?.updateSize() + } + } + + private func updateSize() { + hostingView.invalidateIntrinsicContentSize() + hostingView.layoutSubtreeIfNeeded() + let contentSize = hostingView.fittingSize + let titlebarHeight = view.window.map { window in + window.frame.height - window.contentLayoutRect.height + } ?? contentSize.height + let containerHeight = max(contentSize.height, titlebarHeight) + let yOffset = max(0, (containerHeight - contentSize.height) / 2.0) + preferredContentSize = NSSize(width: contentSize.width, height: containerHeight) + containerView.frame = NSRect(x: 0, y: 0, width: contentSize.width, height: containerHeight) + hostingView.frame = NSRect(x: 0, y: yOffset, width: contentSize.width, height: contentSize.height) + } +} + +private struct NotificationsPopoverView: View { + @ObservedObject var notificationStore: TerminalNotificationStore + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("Notifications") + .font(.headline) + Spacer() + if !notificationStore.notifications.isEmpty { + Button("Clear All") { + notificationStore.clearAll() + } + .buttonStyle(.bordered) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + + Divider() + + if notificationStore.notifications.isEmpty { + VStack(spacing: 8) { + Image(systemName: "bell.slash") + .font(.system(size: 28)) + .foregroundColor(.secondary) + Text("No notifications yet") + .font(.headline) + Text("Desktop notifications will appear here.") + .font(.subheadline) + .foregroundColor(.secondary) + } + .frame(width: 320, height: 180) + } else { + ScrollView { + LazyVStack(spacing: 8) { + ForEach(notificationStore.notifications) { notification in + NotificationPopoverRow( + notification: notification, + tabTitle: tabTitle(for: notification.tabId), + onOpen: { open(notification) }, + onClear: { notificationStore.remove(id: notification.id) } + ) + } + } + .padding(12) + } + .frame(width: 360, height: 360) + } + } + .background(Color(nsColor: .windowBackgroundColor)) + } + + private func tabTitle(for tabId: UUID) -> String? { + AppDelegate.shared?.tabManager?.tabs.first(where: { $0.id == tabId })?.title + } + + private func open(_ notification: TerminalNotification) { + AppDelegate.shared?.tabManager?.focusTabFromNotification(notification.tabId, surfaceId: notification.surfaceId) + markReadIfFocused(notification) + } + + private func markReadIfFocused(_ notification: TerminalNotification) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + guard let tabManager = AppDelegate.shared?.tabManager else { return } + guard tabManager.selectedTabId == notification.tabId else { return } + if let surfaceId = notification.surfaceId { + guard tabManager.focusedSurfaceId(for: notification.tabId) == surfaceId else { return } + } + notificationStore.markRead(id: notification.id) + } + } +} + +private struct NotificationPopoverRow: View { + let notification: TerminalNotification + let tabTitle: String? + let onOpen: () -> Void + let onClear: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Circle() + .fill(notification.isRead ? Color.clear : Color.accentColor) + .frame(width: 8, height: 8) + .overlay( + Circle() + .stroke(Color.accentColor.opacity(notification.isRead ? 0.2 : 1), lineWidth: 1) + ) + .padding(.top, 6) + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(notification.title) + .font(.headline) + .foregroundColor(.primary) + Spacer() + Text(notification.createdAt, style: .time) + .font(.caption) + .foregroundColor(.secondary) + } + + if !notification.body.isEmpty { + Text(notification.body) + .font(.subheadline) + .foregroundColor(.secondary) + .lineLimit(3) + } + + if let tabTitle { + Text(tabTitle) + .font(.caption) + .foregroundColor(.secondary) + } + } + + Spacer(minLength: 0) + + Button(action: onClear) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + .contentShape(Rectangle()) + .onTapGesture(perform: onOpen) + } +} + final class UpdateAccessoryViewController: NSTitlebarAccessoryViewController { private let hostingView: NonDraggableHostingView private let containerView = NSView() @@ -156,6 +409,7 @@ final class UpdateTitlebarAccessoryController { private var stateCancellable: AnyCancellable? private var lastIsIdle: Bool? private let updateIdentifier = NSUserInterfaceItemIdentifier("cmux.updateAccessory") + private let controlsIdentifier = NSUserInterfaceItemIdentifier("cmux.titlebarControls") #if DEBUG private let devIdentifier = NSUserInterfaceItemIdentifier("cmux.devAccessory") #endif @@ -213,6 +467,16 @@ final class UpdateTitlebarAccessoryController { guard let updateViewModel else { return } guard !attachedWindows.contains(window) else { return } guard window.styleMask.contains(.titled) else { return } + guard !isSettingsWindow(window) else { return } + + if !window.titlebarAccessoryViewControllers.contains(where: { $0.view.identifier == controlsIdentifier }) { + let controls = TitlebarControlsAccessoryViewController( + notificationStore: TerminalNotificationStore.shared + ) + controls.layoutAttribute = .left + controls.view.identifier = controlsIdentifier + window.addTitlebarAccessoryViewController(controls) + } #if DEBUG if !window.titlebarAccessoryViewControllers.contains(where: { $0.view.identifier == devIdentifier }) { @@ -233,6 +497,13 @@ final class UpdateTitlebarAccessoryController { attachedWindows.add(window) } + private func isSettingsWindow(_ window: NSWindow) -> Bool { + if window.identifier?.rawValue == "cmux.settings" { + return true + } + return window.title == "Settings" + } + private func installStateObserver() { guard let updateViewModel else { return } stateCancellable = Publishers.CombineLatest(updateViewModel.$state, updateViewModel.$overrideState) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index fa386d28..f73f2cb4 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -5,6 +5,8 @@ import SwiftUI struct cmuxApp: App { @StateObject private var tabManager = TabManager() @StateObject private var notificationStore = TerminalNotificationStore.shared + @StateObject private var sidebarState = SidebarState() + @AppStorage("appearanceMode") private var appearanceMode = AppearanceMode.dark.rawValue @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate init() { @@ -17,13 +19,21 @@ struct cmuxApp: App { ContentView(updateViewModel: appDelegate.updateViewModel) .environmentObject(tabManager) .environmentObject(notificationStore) + .environmentObject(sidebarState) .onAppear { // Start the Unix socket controller for programmatic access TerminalController.shared.start(tabManager: tabManager) - appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore) + appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore, sidebarState: sidebarState) + applyAppearance() + } + .onChange(of: appearanceMode) { _ in + applyAppearance() } } .windowToolbarStyle(.automatic) + Settings { + SettingsRootView() + } .commands { CommandGroup(replacing: .appInfo) { Button("About cmuxterm") { @@ -97,6 +107,13 @@ struct cmuxApp: App { // Tab navigation CommandGroup(after: .toolbar) { + Button("Toggle Sidebar") { + sidebarState.toggle() + } + .keyboardShortcut("b", modifiers: .command) + + Divider() + Button("Next Tab") { tabManager.selectNextTab() } @@ -148,4 +165,54 @@ struct cmuxApp: App { ]) NSApp.activate(ignoringOtherApps: true) } + + private func applyAppearance() { + guard let mode = AppearanceMode(rawValue: appearanceMode) else { return } + switch mode { + case .auto: + NSApp.appearance = nil + case .system: + let match = NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) ?? .aqua + NSApp.appearance = NSAppearance(named: match) + case .dark: + NSApp.appearance = NSAppearance(named: .darkAqua) + } + } +} + +enum AppearanceMode: String, CaseIterable, Identifiable { + case auto + case system + case dark + + var id: String { rawValue } +} + +struct SettingsView: View { + @AppStorage("appearanceMode") private var appearanceMode = AppearanceMode.dark.rawValue + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Theme") + .font(.headline) + + Picker("Theme", selection: $appearanceMode) { + Text("Auto").tag(AppearanceMode.auto.rawValue) + Text("System").tag(AppearanceMode.system.rawValue) + Text("Dark").tag(AppearanceMode.dark.rawValue) + } + .pickerStyle(.radioGroup) + } + .padding(20) + .frame(minWidth: 360, minHeight: 180) + } +} + +private struct SettingsRootView: View { + var body: some View { + SettingsView() + .background(WindowAccessor { window in + window.identifier = NSUserInterfaceItemIdentifier("cmux.settings") + }) + } } diff --git a/scripts/reload.sh b/scripts/reload.sh index 7046cdba..cfbea96b 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -1,7 +1,10 @@ #!/usr/bin/env bash set -euo pipefail +APP_PATH="/Users/lawrencechen/Library/Developer/Xcode/DerivedData/GhosttyTabs-cbjivvtpirygxbbgqlpdpiiyjnwh/Build/Products/Debug/cmuxterm DEV.app" + xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination 'platform=macOS' build pkill -x "cmuxterm DEV" || true sleep 0.2 -open /Users/lawrencechen/Library/Developer/Xcode/DerivedData/GhosttyTabs-cbjivvtpirygxbbgqlpdpiiyjnwh/Build/Products/Debug/cmuxterm\ DEV.app +open "$APP_PATH" +osascript -e 'tell application "cmuxterm DEV" to activate' || true diff --git a/scripts/reload2.sh b/scripts/reload2.sh index 160a5f05..5787abb3 100755 --- a/scripts/reload2.sh +++ b/scripts/reload2.sh @@ -1,6 +1,5 @@ #!/usr/bin/env bash set -euo pipefail -./scripts/reload.sh & -./scripts/reloadp.sh & -wait +./scripts/reload.sh +./scripts/reloadp.sh diff --git a/scripts/reloadp.sh b/scripts/reloadp.sh index 7d82691c..aa248f05 100755 --- a/scripts/reloadp.sh +++ b/scripts/reloadp.sh @@ -1,7 +1,10 @@ #!/usr/bin/env bash set -euo pipefail +APP_PATH="/Users/lawrencechen/Library/Developer/Xcode/DerivedData/GhosttyTabs-cbjivvtpirygxbbgqlpdpiiyjnwh/Build/Products/Release/cmuxterm.app" + xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Release -destination 'platform=macOS' build pkill -x cmuxterm || true sleep 0.2 -open /Users/lawrencechen/Library/Developer/Xcode/DerivedData/GhosttyTabs-cbjivvtpirygxbbgqlpdpiiyjnwh/Build/Products/Release/cmuxterm.app +open "$APP_PATH" +osascript -e 'tell application "cmuxterm" to activate' || true diff --git a/tests/cmux.py b/tests/cmux.py index c4d5f39d..f08565ad 100755 --- a/tests/cmux.py +++ b/tests/cmux.py @@ -29,6 +29,7 @@ Usage: """ import socket +import select import os from typing import Optional, List, Tuple @@ -46,6 +47,7 @@ class cmux: def __init__(self, socket_path: str = None): self.socket_path = socket_path or self.DEFAULT_SOCKET_PATH self._socket: Optional[socket.socket] = None + self._recv_buffer: str = "" def connect(self) -> None: """Connect to the cmux socket""" @@ -87,8 +89,25 @@ class cmux: try: self._socket.sendall((command + "\n").encode()) - response = self._socket.recv(8192).decode().strip() - return response + data = self._recv_buffer + self._recv_buffer = "" + while True: + if "\n" not in data: + chunk = self._socket.recv(8192) + if not chunk: + break + data += chunk.decode() + continue + ready, _, _ = select.select([self._socket], [], [], 0.01) + if not ready: + break + chunk = self._socket.recv(8192) + if not chunk: + break + data += chunk.decode() + if data.endswith("\n"): + data = data[:-1] + return data except socket.timeout: raise cmuxError("Command timed out") except socket.error as e: diff --git a/tests/test_notifications.py b/tests/test_notifications.py index 8b23bb19..f0b5fd36 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -52,6 +52,23 @@ def ensure_two_surfaces(client: cmux) -> list[tuple[int, str, bool]]: return surfaces +def focused_surface_index(client: cmux) -> int: + surfaces = client.list_surfaces() + focused = next((s for s in surfaces if s[2]), None) + if focused is None: + raise RuntimeError("No focused surface") + return focused[0] + + +def send_osc(client: cmux, sequence: str, surface: int | None = None) -> None: + """Send an OSC sequence by printing it in the shell.""" + command = f"printf '{sequence}'\\n" + if surface is None: + client.send(command) + else: + client.send_surface(surface, command) + + def test_clear_prior_notifications(client: cmux) -> TestResult: result = TestResult("Clear Prior Panel Notifications") try: @@ -106,10 +123,60 @@ def test_not_suppressed_when_inactive(client: cmux) -> TestResult: return result +def test_kitty_notification_simple(client: cmux) -> TestResult: + result = TestResult("Kitty OSC 99 Simple") + try: + client.clear_notifications() + client.set_app_focus(False) + surface = focused_surface_index(client) + send_osc(client, "\\x1b]99;;Kitty Simple\\x1b\\\\", surface) + items = wait_for_notifications(client, 1) + if len(items) != 1: + result.failure(f"Expected 1 notification, got {len(items)}") + elif items[0]["title"] != "Kitty Simple": + result.failure(f"Expected title 'Kitty Simple', got '{items[0]['title']}'") + else: + result.success("OSC 99 simple notification received") + except Exception as e: + result.failure(f"Exception: {e}") + return result + + +def test_kitty_notification_chunked(client: cmux) -> TestResult: + result = TestResult("Kitty OSC 99 Chunked Title/Body") + try: + client.clear_notifications() + client.set_app_focus(False) + # Avoid Ghostty's 1s desktop notification rate limit. + time.sleep(1.1) + surface = focused_surface_index(client) + send_osc(client, "\\x1b]99;i=kitty:d=0:p=title;Kitty Title\\x1b\\\\", surface) + time.sleep(0.1) + items = client.list_notifications() + if items: + result.failure("Expected no notification before final chunk") + return result + send_osc(client, "\\x1b]99;i=kitty:p=body;Kitty Body\\x1b\\\\", surface) + items = wait_for_notifications(client, 1) + if len(items) != 1: + result.failure(f"Expected 1 notification, got {len(items)}") + elif items[0]["title"] != "Kitty Title" or items[0]["body"] != "Kitty Body": + result.failure( + f"Expected title/body 'Kitty Title'/'Kitty Body', got " + f"'{items[0]['title']}'/'{items[0]['body']}'" + ) + else: + result.success("OSC 99 chunked notification received") + except Exception as e: + result.failure(f"Exception: {e}") + return result + + def test_mark_read_on_focus_change(client: cmux) -> TestResult: result = TestResult("Mark Read On Panel Focus") try: client.clear_notifications() + client.reset_flash_counts() surfaces = ensure_two_surfaces(client) focused = next((s for s in surfaces if s[2]), None) other = next((s for s in surfaces if not s[2]), None) @@ -131,6 +198,8 @@ def test_mark_read_on_focus_change(client: cmux) -> TestResult: result.failure("Expected notification for target surface") elif not target["is_read"]: result.failure("Expected notification to be marked read on focus") + elif client.flash_count(other[1]) < 1: + result.failure("Expected flash on panel focus dismissal") else: result.success("Notification marked read on focus") except Exception as e: @@ -195,8 +264,8 @@ def test_mark_read_on_tab_switch(client: cmux) -> TestResult: return result -def test_no_flash_on_tab_switch(client: cmux) -> TestResult: - result = TestResult("No Flash On Tab Switch") +def test_flash_on_tab_switch(client: cmux) -> TestResult: + result = TestResult("Flash On Tab Switch") try: client.clear_notifications() client.reset_flash_counts() @@ -220,10 +289,10 @@ def test_no_flash_on_tab_switch(client: cmux) -> TestResult: time.sleep(0.2) count = client.flash_count(focused[1]) - if count != 0: - result.failure(f"Expected flash count 0, got {count}") + if count < 1: + result.failure(f"Expected flash count >= 1, got {count}") else: - result.success("No flash triggered on tab switch") + result.success("Flash triggered on tab switch dismissal") except Exception as e: result.failure(f"Exception: {e}") return result @@ -303,18 +372,50 @@ def test_restore_focus_on_tab_switch(client: cmux) -> TestResult: return result +def test_clear_on_tab_close(client: cmux) -> TestResult: + result = TestResult("Clear On Tab Close") + try: + client.clear_notifications() + client.set_app_focus(False) + tab1 = client.current_tab() + client.notify("closetab") + time.sleep(0.1) + + items = wait_for_notifications(client, 1) + if len(items) != 1: + result.failure(f"Expected 1 notification, got {len(items)}") + return result + + client.new_tab() + time.sleep(0.1) + client.close_tab(tab1) + time.sleep(0.2) + + items = client.list_notifications() + if items: + result.failure(f"Expected 0 notifications after tab close, got {len(items)}") + else: + result.success("Notifications cleared when tab closed") + except Exception as e: + result.failure(f"Exception: {e}") + return result + + def run_tests() -> int: results = [] with cmux() as client: results.append(test_clear_prior_notifications(client)) results.append(test_suppress_when_focused(client)) results.append(test_not_suppressed_when_inactive(client)) + results.append(test_kitty_notification_simple(client)) + results.append(test_kitty_notification_chunked(client)) results.append(test_mark_read_on_focus_change(client)) results.append(test_mark_read_on_app_active(client)) results.append(test_mark_read_on_tab_switch(client)) - results.append(test_no_flash_on_tab_switch(client)) + results.append(test_flash_on_tab_switch(client)) results.append(test_focus_on_notification_click(client)) results.append(test_restore_focus_on_tab_switch(client)) + results.append(test_clear_on_tab_close(client)) client.set_app_focus(None) client.clear_notifications()