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
15 changes: 8 additions & 7 deletions Assets.xcassets/AppIconDark.imageset/Contents.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
{
"images" : [
"images": [
{
"filename" : "AppIconDark.png",
"idiom" : "universal"
"filename": "AppIconDark.png",
"idiom": "universal",
"scale": "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
"info": {
"author": "xcode",
"version": 1
}
}
}
15 changes: 8 additions & 7 deletions Assets.xcassets/AppIconLight.imageset/Contents.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
{
"images" : [
"images": [
{
"filename" : "AppIconLight.png",
"idiom" : "universal"
"filename": "AppIconLight.png",
"idiom": "universal",
"scale": "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
"info": {
"author": "xcode",
"version": 1
}
}
}
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p
- Each ship now deletes promoted release candidates older than the two most recent, so the releases page stops accumulating 110 MB prereleases.

### Fixed
- Idle CPU with an open window dropped from roughly 12 to 20 percent of a core to about 1 percent. The workspace pane overlay kept a display-rate animation timeline running for the life of every window; it now mounts one only while an attention flash is animating.
- The dock icon no longer costs 32 MB of resident memory. The light and dark icon assets are declared as 512pt @2x, so AppKit decodes them at 1024 pixels instead of rasterizing a 2048 pixel copy.
- Closing a workspace no longer leaves its Workspace object alive through the sidebar row's hover closure.
- Pending review comments survive session restore and remain available for retry or copying when the source terminal cannot accept them.
- Incoming notifications no longer reset a valid keyboard selection in the Notifications page.
- Socket and typing-lag CI jobs cache the DerivedData paths they actually build into.
Expand Down
23 changes: 20 additions & 3 deletions Sources/TabItemView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -804,9 +804,12 @@ struct TabItemView: View, Equatable {
.onTapGesture {
updateSelection()
}
.onHover { hovering in
isHovering = hovering
}
// Hover tracking lives in a leaf view that captures only the state
// binding. An `.onHover` closure here captures the whole row value,
// including `tab`, and SwiftUI's context-menu responder tree kept
// those closures alive after the row was gone: closed Workspace
// objects stayed resident (7 live for 1 open workspace in a heap dump).
.background(SidebarRowHoverProbe(isHovering: $isHovering))
.accessibilityElement(children: .combine)
.accessibilityLabel(Text(accessibilityTitle))
.accessibilityHint(Text(accessibilityHintText))
Expand Down Expand Up @@ -2151,3 +2154,17 @@ private struct SidebarMetadataMarkdownBlockRow: View {
)
}
}

/// Leaf view whose only captured state is a `Binding<Bool>`, so the hover
/// closure never retains the sidebar row (and its `Workspace`) after removal.
private struct SidebarRowHoverProbe: View {
@Binding var isHovering: Bool

var body: some View {
Color.clear
.contentShape(Rectangle())
.onHover { hovering in
isHovering = hovering
}
}
}
51 changes: 44 additions & 7 deletions Sources/WindowOverlayControllers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,9 @@ final class WindowTmuxWorkspacePaneOverlayController: NSObject {
private let model = TmuxWorkspacePaneOverlayModel()
private let hostingView: NSHostingView<TmuxWorkspacePaneOverlayView>
private var installConstraints: [NSLayoutConstraint] = []
/// Identifies the flash whose end-of-animation re-render is pending, so a
/// newer flash or a clear does not get overwritten by a stale callback.
private var flashSettleGeneration: UInt64 = 0

init(window: NSWindow) {
self.window = window
Expand All @@ -599,7 +602,8 @@ final class WindowTmuxWorkspacePaneOverlayController: NSObject {
unreadRects: [],
flashRect: nil,
flashStartedAt: nil,
flashReason: nil
flashReason: nil,
isFlashActive: false
)
)
super.init()
Expand Down Expand Up @@ -652,26 +656,59 @@ final class WindowTmuxWorkspacePaneOverlayController: NSObject {
guard ensureInstalled() else { return }
if let state {
model.apply(state)
hostingView.rootView = TmuxWorkspacePaneOverlayView(
unreadRects: model.unreadRects,
renderModel(flashActive: TmuxWorkspacePaneOverlayView.isFlashActive(
flashRect: model.flashRect,
flashStartedAt: model.flashStartedAt,
flashReason: model.flashReason
)
flashStartedAt: model.flashStartedAt
))
containerView.alphaValue = 1
containerView.isHidden = false
scheduleFlashSettleIfNeeded()
} else {
flashSettleGeneration &+= 1
model.clear()
hostingView.rootView = TmuxWorkspacePaneOverlayView(
unreadRects: [],
flashRect: nil,
flashStartedAt: nil,
flashReason: nil
flashReason: nil,
isFlashActive: false
)
containerView.alphaValue = 0
containerView.isHidden = true
}
}

private func renderModel(flashActive: Bool) {
hostingView.rootView = TmuxWorkspacePaneOverlayView(
unreadRects: model.unreadRects,
flashRect: model.flashRect,
flashStartedAt: model.flashStartedAt,
flashReason: model.flashReason,
isFlashActive: flashActive
)
}

/// The overlay view only mounts its animation timeline while a flash is
/// active, and SwiftUI does not re-evaluate the root view on its own when
/// the flash window elapses. Re-render once just after the flash ends so
/// the timeline is torn down instead of running for the life of the window.
private func scheduleFlashSettleIfNeeded() {
guard let flashStartedAt = model.flashStartedAt,
TmuxWorkspacePaneOverlayView.isFlashActive(
flashRect: model.flashRect,
flashStartedAt: flashStartedAt
) else { return }
flashSettleGeneration &+= 1
let generation = flashSettleGeneration
let remaining = FocusFlashPattern.duration - Date().timeIntervalSince(flashStartedAt)
DispatchQueue.main.asyncAfter(deadline: .now() + max(0, remaining) + 0.05) { [weak self] in
guard let self, self.flashSettleGeneration == generation else { return }
// Explicitly inactive: the flash window has elapsed on the monotonic
// dispatch clock, so do not re-read the wall clock here (a backwards
// clock step would otherwise leave the timeline mounted).
self.renderModel(flashActive: false)
}
}
}

@MainActor
Expand Down
60 changes: 44 additions & 16 deletions Sources/WorkspaceContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,31 +150,59 @@ struct TmuxWorkspacePaneOverlayView: View {
let flashRect: CGRect?
let flashStartedAt: Date?
let flashReason: WorkspaceAttentionFlashReason?
/// Stored, not derived from `Date()` in `body`: the controller sets it when a
/// flash starts and clears it when the flash window ends. A stored input that
/// changes is what makes SwiftUI re-evaluate the body and drop the timeline;
/// a body that only re-reads the clock would not be re-run for an otherwise
/// identical root view.
let isFlashActive: Bool

/// True only while a flash is still within its animation window. The
/// display-rate `TimelineView(.animation)` below is mounted only in that
/// state: an always-on animation timeline kept every window's overlay
/// hosting view re-laying out on every frame while idle (measured 12 to
/// 22 percent CPU with one idle terminal). Unread rings are static and
/// draw once without a timeline.
static func isFlashActive(flashRect: CGRect?, flashStartedAt: Date?, now: Date = Date()) -> Bool {
guard flashRect != nil, let flashStartedAt else { return false }
return now.timeIntervalSince(flashStartedAt) < FocusFlashPattern.duration
}

var body: some View {
TimelineView(.animation) { timeline in
Canvas { context, _ in
for rect in unreadRects {
drawUnreadRing(in: &context, rect: rect)
Group {
if isFlashActive {
TimelineView(.animation) { timeline in
canvas(at: 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 {
canvas(at: nil)
}
}
.allowsHitTesting(false)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}

private func canvas(at date: Date?) -> some View {
Canvas { context, _ in
for rect in unreadRects {
drawUnreadRing(in: &context, rect: rect)
}

guard let date,
let flashRect,
let flashStartedAt else { return }
let elapsed = 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
)
}
}

private func drawUnreadRing(in context: inout GraphicsContext, rect: CGRect) {
guard let path = ringPath(for: rect) else { return }
var glowContext = context
Expand Down
39 changes: 39 additions & 0 deletions programaTests/WindowAndDragTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1697,6 +1697,45 @@ final class MarkdownPanelPointerObserverViewTests: XCTestCase {

@MainActor
final class TmuxWorkspacePaneOverlayTests: XCTestCase {
func testOverlayMountsAnimationTimelineOnlyWhileFlashIsWithinItsDuration() {
let start = Date(timeIntervalSince1970: 1_000)
let rect = CGRect(x: 0, y: 0, width: 10, height: 10)
XCTAssertFalse(
TmuxWorkspacePaneOverlayView.isFlashActive(flashRect: nil, flashStartedAt: nil, now: start),
"No flash: the overlay must render statically, never with a display-rate timeline"
)
XCTAssertFalse(
TmuxWorkspacePaneOverlayView.isFlashActive(flashRect: rect, flashStartedAt: nil, now: start),
"A flash rect without a start time is not an active flash"
)
XCTAssertTrue(
TmuxWorkspacePaneOverlayView.isFlashActive(
flashRect: rect, flashStartedAt: start,
now: start.addingTimeInterval(FocusFlashPattern.duration / 2)
)
)
XCTAssertFalse(
TmuxWorkspacePaneOverlayView.isFlashActive(
flashRect: rect, flashStartedAt: start,
now: start.addingTimeInterval(FocusFlashPattern.duration + 0.01)
),
"Once the flash pattern has finished, the timeline must be torn down"
)
}

func testAppearanceAppIconsDecodeAtRetinaDockSizeNotDoubleIt() {
for name in ["AppIconDark", "AppIconLight"] {
guard let icon = NSImage(named: name) else {
XCTFail("missing \(name) in the built bundle")
continue
}
let maxPixels = icon.representations.map { max($0.pixelsWide, $0.pixelsHigh) }.max() ?? 0
XCTAssertEqual(maxPixels, 1024, "\(name) largest representation")
XCTAssertEqual(icon.size.width, 512, accuracy: 0.5,
"\(name) must be a 512pt @2x image so AppKit does not rasterize a 2048px Dock icon")
}
}

func testTmuxWorkspacePaneOverlayModelTracksFlashReason() {
let model = TmuxWorkspacePaneOverlayModel()
let initialState = TmuxWorkspacePaneOverlayRenderState(
Expand Down
Loading