Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
32 changes: 16 additions & 16 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1322,6 +1322,9 @@ private final class WindowTmuxWorkspacePaneOverlayController: NSObject {
)
)
super.init()
model.onStateChange = { [weak self] in
self?.renderModelState()
}
containerView.translatesAutoresizingMaskIntoConstraints = false
containerView.wantsLayer = true
containerView.layer?.backgroundColor = NSColor.clear.cgColor
Expand Down Expand Up @@ -1368,25 +1371,22 @@ private final class WindowTmuxWorkspacePaneOverlayController: NSObject {
guard ensureInstalled() else { return }
if let state {
model.apply(state)
hostingView.rootView = TmuxWorkspacePaneOverlayView(
unreadRects: model.unreadRects,
flashRect: model.flashRect,
flashStartedAt: model.flashStartedAt,
flashReason: model.flashReason
)
containerView.alphaValue = 1
containerView.isHidden = false
} else {
model.clear()
hostingView.rootView = TmuxWorkspacePaneOverlayView(
unreadRects: [],
flashRect: nil,
flashStartedAt: nil,
flashReason: nil
)
containerView.alphaValue = 0
containerView.isHidden = true
}
renderModelState()
}

private func renderModelState() {
guard ensureInstalled() else { return }
hostingView.rootView = TmuxWorkspacePaneOverlayView(
unreadRects: model.unreadRects,
flashRect: model.flashRect,
flashStartedAt: model.flashStartedAt,
flashReason: model.flashReason
)
containerView.alphaValue = model.hasVisibleContent ? 1 : 0
containerView.isHidden = !model.hasVisibleContent
}
}

Expand Down
47 changes: 43 additions & 4 deletions Sources/TabManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -698,9 +698,11 @@ class TabManager: ObservableObject {
/// Static so port ranges don't overlap across multiple windows (each window has its own TabManager).
private static var nextPortOrdinal: Int = 0
private static let initialWorkspaceGitProbeDelays: [TimeInterval] = [0, 0.5, 1.5, 3.0, 6.0, 10.0]
private static let workspaceGitMetadataPollInterval: TimeInterval = 30
private static let selectedWorkspaceGitMetadataPollInterval: TimeInterval = 5
private nonisolated static let workspacePullRequestProbeTimeout: TimeInterval = 5.0
// Background PR metadata is best-effort. Keep it infrequent so idle windows
// don't repeatedly spawn `gh` when nothing is changing.
private static let workspaceGitMetadataPollInterval: TimeInterval = 5 * 60
private static let selectedWorkspaceGitMetadataPollInterval: TimeInterval = 2 * 60
private nonisolated static let workspacePullRequestProbeTimeout: TimeInterval = 2.0
@Published var selectedTabId: UUID? {
willSet {
#if DEBUG
Expand Down Expand Up @@ -761,6 +763,7 @@ class TabManager: ObservableObject {
if let selectedTabId = self.selectedTabId {
self.dismissFocusedPanelNotificationIfActive(tabId: selectedTabId)
}
self.scheduleSelectedWorkspaceGitMetadataRefresh(reason: "selectionChange")
#if DEBUG
let dtMs = self.debugWorkspaceSwitchStartTime > 0
? (CACurrentMediaTime() - self.debugWorkspaceSwitchStartTime) * 1000
Expand Down Expand Up @@ -944,7 +947,7 @@ class TabManager: ObservableObject {
let activeProbeKeys = Set(workspaceGitProbeGenerationByKey.keys)

for workspace in tabs {
for panelId in trackedWorkspaceGitMetadataPollCandidatePanelIds(
for panelId in trackedWorkspaceGitMetadataPeriodicPollCandidatePanelIds(
in: workspace,
activeProbeKeys: activeProbeKeys
) {
Expand Down Expand Up @@ -977,6 +980,19 @@ class TabManager: ObservableObject {
)
}

private func scheduleSelectedWorkspaceGitMetadataRefresh(reason: String) {
guard let workspace = selectedWorkspace,
let focusedPanelId = workspace.focusedPanelId else {
return
}

scheduleWorkspaceGitMetadataRefreshIfPossible(
workspaceId: workspace.id,
panelId: focusedPanelId,
reason: reason
)
}

func refreshTrackedWorkspaceGitMetadataForTesting() {
refreshTrackedWorkspaceGitMetadata()
}
Expand Down Expand Up @@ -1011,6 +1027,29 @@ class TabManager: ObservableObject {
})
}

private func trackedWorkspaceGitMetadataPeriodicPollCandidatePanelIds(
in workspace: Workspace,
activeProbeKeys: Set<WorkspaceGitProbeKey>
) -> Set<UUID> {
var candidatePanelIds = Set(
workspace.panelPullRequests.compactMap { panelId, pullRequest in
pullRequest.status == .open ? panelId : nil
}
)

if candidatePanelIds.isEmpty,
let focusedPanelId = workspace.focusedPanelId,
let pullRequest = workspace.pullRequest,
pullRequest.status == .open {
candidatePanelIds.insert(focusedPanelId)
}

return Set(candidatePanelIds.filter { panelId in
let probeKey = WorkspaceGitProbeKey(workspaceId: workspace.id, panelId: panelId)
return !activeProbeKeys.contains(probeKey)
})
}

private func sweepStaleAgentPIDs() {
for tab in tabs {
var keysToRemove: [String] = []
Expand Down
109 changes: 87 additions & 22 deletions Sources/WorkspaceContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,24 @@ struct TmuxWorkspacePaneOverlayRenderState: Equatable {
}

@MainActor
final class TmuxWorkspacePaneOverlayModel: ObservableObject {
@Published private(set) var unreadRects: [CGRect] = []
@Published private(set) var flashRect: CGRect?
@Published private(set) var flashStartedAt: Date?
@Published private(set) var flashReason: WorkspaceAttentionFlashReason?
final class TmuxWorkspacePaneOverlayModel {
private(set) var unreadRects: [CGRect] = []
private(set) var flashRect: CGRect?
private(set) var flashStartedAt: Date?
private(set) var flashReason: WorkspaceAttentionFlashReason?

private var lastWorkspaceId: UUID?
private var lastFlashToken: UInt64?
private var flashResetWorkItem: DispatchWorkItem?
var onStateChange: (() -> Void)?

var showsAnimatedFlash: Bool {
flashRect != nil && flashStartedAt != nil
}

var hasVisibleContent: Bool {
!unreadRects.isEmpty || showsAnimatedFlash
}

func apply(
_ state: TmuxWorkspacePaneOverlayRenderState,
Expand All @@ -123,26 +133,60 @@ final class TmuxWorkspacePaneOverlayModel: ObservableObject {
if didChangeWorkspace {
lastWorkspaceId = state.workspaceId
lastFlashToken = state.flashToken
cancelFlashReset()
flashStartedAt = nil
return
}

if state.flashRect == nil {
cancelFlashReset()
flashStartedAt = nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

if let lastFlashToken,
state.flashToken != lastFlashToken,
state.flashRect != nil {
flashStartedAt = now()
let startedAt = now()
flashStartedAt = startedAt
scheduleFlashReset(workspaceId: state.workspaceId, flashToken: state.flashToken)
}
self.lastFlashToken = state.flashToken
}

func clear() {
cancelFlashReset()
unreadRects = []
flashRect = nil
flashStartedAt = nil
flashReason = nil
lastWorkspaceId = nil
lastFlashToken = nil
}

private func scheduleFlashReset(workspaceId: UUID, flashToken: UInt64) {
cancelFlashReset()

let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
guard self.lastWorkspaceId == workspaceId,
self.lastFlashToken == flashToken else {
return
}

self.flashStartedAt = nil
self.onStateChange?()
}
flashResetWorkItem = workItem
DispatchQueue.main.asyncAfter(
deadline: .now() + FocusFlashPattern.duration,
execute: workItem
)
Comment on lines +195 to +212

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 @MainActor-isolated property access in @Sendable closure

DispatchWorkItem takes a @Sendable closure, so Swift Concurrency's type checker sees access to self.lastWorkspaceId, self.lastFlashToken, and self.flashStartedAt (all @MainActor-isolated) as potential data races. At runtime this is safe because the work item is dispatched to DispatchQueue.main, but the compiler cannot verify the equivalence between DispatchQueue.main and @MainActor in a @Sendable closure without an explicit annotation.

Wrapping the body with MainActor.assumeIsolated documents the intent and avoids warnings under -strict-concurrency=complete:

Suggested change
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
guard self.lastWorkspaceId == workspaceId,
self.lastFlashToken == flashToken else {
return
}
self.flashStartedAt = nil
}
flashResetWorkItem = workItem
DispatchQueue.main.asyncAfter(
deadline: .now() + FocusFlashPattern.duration,
execute: workItem
)
let workItem = DispatchWorkItem { [weak self] in
MainActor.assumeIsolated {
guard let self else { return }
guard self.lastWorkspaceId == workspaceId,
self.lastFlashToken == flashToken else {
return
}
self.flashStartedAt = nil
}
}

}

private func cancelFlashReset() {
flashResetWorkItem?.cancel()
flashResetWorkItem = nil
}
}

struct TmuxWorkspacePaneOverlayView: View {
Expand All @@ -152,29 +196,50 @@ struct TmuxWorkspacePaneOverlayView: View {
let flashReason: WorkspaceAttentionFlashReason?

var body: some View {
TimelineView(.animation) { timeline in
Canvas { context, _ in
for rect in unreadRects {
drawUnreadRing(in: &context, rect: rect)
Group {
if flashRect != nil && flashStartedAt != nil {
TimelineView(.animation) { timeline in
Canvas { context, _ in
renderOverlay(
in: &context,
currentDate: timeline.date
)
}
}

guard let flashRect,
let flashStartedAt else { return }
let elapsed = timeline.date.timeIntervalSince(flashStartedAt)
let opacity = FocusFlashPattern.opacity(at: elapsed)
guard opacity > 0.001 else { return }
drawFlashRing(
in: &context,
rect: flashRect,
opacity: opacity,
reason: flashReason ?? .notificationArrival
)
} else if !unreadRects.isEmpty {
Canvas { context, _ in
renderOverlay(in: &context, currentDate: nil)
}
} else {
Color.clear
}
}
.allowsHitTesting(false)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}

private func renderOverlay(
in context: inout GraphicsContext,
currentDate: Date?
) {
for rect in unreadRects {
drawUnreadRing(in: &context, rect: rect)
}

guard let flashRect,
let flashStartedAt,
let currentDate else { return }
let elapsed = currentDate.timeIntervalSince(flashStartedAt)
let opacity = FocusFlashPattern.opacity(at: elapsed)
guard opacity > 0.001 else { return }
drawFlashRing(
in: &context,
rect: flashRect,
opacity: opacity,
reason: flashReason ?? .notificationArrival
)
}

private func drawUnreadRing(in context: inout GraphicsContext, rect: CGRect) {
guard let path = ringPath(for: rect) else { return }
var glowContext = context
Expand Down
36 changes: 36 additions & 0 deletions cmuxTests/WindowAndDragTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1338,6 +1338,42 @@ final class TmuxWorkspacePaneOverlayTests: XCTestCase {
XCTAssertEqual(model.flashReason, .navigation)
}

func testTmuxWorkspacePaneOverlayModelClearsFlashAfterDuration() {
let clearExpectation = expectation(description: "flash auto clears")
let startedAt = Date()
let workspaceId = UUID()
let model = TmuxWorkspacePaneOverlayModel()
model.onStateChange = {
if model.flashStartedAt == nil {
clearExpectation.fulfill()
}
}

model.apply(
TmuxWorkspacePaneOverlayRenderState(
workspaceId: workspaceId,
unreadRects: [],
flashRect: CGRect(x: 10, y: 20, width: 300, height: 200),
flashToken: 1,
flashReason: .notificationArrival
)
)
model.apply(
TmuxWorkspacePaneOverlayRenderState(
workspaceId: workspaceId,
unreadRects: [],
flashRect: CGRect(x: 10, y: 20, width: 300, height: 200),
flashToken: 2,
flashReason: .notificationArrival
),
now: { startedAt }
)

XCTAssertEqual(model.flashStartedAt, startedAt)
wait(for: [clearExpectation], timeout: FocusFlashPattern.duration + 0.5)
XCTAssertNil(model.flashStartedAt)
}

func testNavigationFlashUsesNonNotificationPresentation() {
XCTAssertNotEqual(
WorkspaceAttentionCoordinator.flashStyle(for: .navigation),
Expand Down
Loading