Refine titlebar controls and clear notifications on close
This commit is contained in:
parent
4c7005f54d
commit
f0e2efe8e4
13 changed files with 559 additions and 110 deletions
|
|
@ -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?) {
|
||||
|
|
|
|||
|
|
@ -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<UUID>
|
||||
@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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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<TitlebarControlsView>
|
||||
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<TitlebarAccessoryView>
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
./scripts/reload.sh &
|
||||
./scripts/reloadp.sh &
|
||||
wait
|
||||
./scripts/reload.sh
|
||||
./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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue