Skip to content
Open
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
66 changes: 66 additions & 0 deletions Sources/SettingsWindowContentVisibility.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/// Decides whether the Settings window renders its full content or a lightweight
/// placeholder, based on the lifecycle of the underlying `NSWindow`.
///
/// SwiftUI reuses the Settings `Window` scene across close / reopen. The
/// `WindowAccessor` that supplies the window dedupes by the reused `NSWindow`,
/// so it does not fire again on the second open, and the scene's view state
/// persists. Without re-adoption, a window that was forgotten on close (to stop
/// observing it) can never flip back to rendering content — producing a blank
/// window on every open after the first. This value type centralizes that
/// state machine so the close → reopen path can be exercised in isolation.
@MainActor
struct SettingsWindowContentVisibility: Equatable {
Comment on lines +11 to +12

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 SettingsWindowContentVisibility is a pure value type whose fields are Bool and ObjectIdentifier? — it holds no UI state and has no dependency on the main actor. Marking it @MainActor couples every declaration and mutating method to the main actor unnecessarily, which is why the test suite also has to carry @MainActor. Per the actor-isolation guideline, pure value model types should be nonisolated (no annotation); callers on the main actor can still create and mutate them freely, and future non-UI callsites won't be forced onto the main thread.

Suggested change
@MainActor
struct SettingsWindowContentVisibility: Equatable {
struct SettingsWindowContentVisibility: Equatable {

Rule Used: Flag new or materially worsened Swift 6 actor isol... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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 on a pure value type

SettingsWindowContentVisibility holds only value-typed fields (Bool, ObjectIdentifier?) with no AppKit objects and no shared mutable reference semantics. The @MainActor annotation couples the type to the main actor unnecessarily — it cannot be called from non-@MainActor contexts (e.g., a future actor-isolated store, or a plain XCTest target without @MainActor). The cmux actor-isolation rule's preferred shape for pure value types is nonisolated struct; the @MainActor should live on the call sites (already the case — SettingsWindowRootView is @MainActor), not on the model itself.

Rule Used: Flag new or materially worsened Swift 6 actor isol... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

/// Whether the settings content should render. When `false`, callers show a
/// cheap placeholder instead of the full settings hierarchy.
private(set) var shouldRenderContent: Bool

/// Identity of the window currently being observed, if any. Cleared on close.
private var observedWindow: ObjectIdentifier?

/// Creates a coordinator that renders content until a lifecycle event hides it.
init() {
shouldRenderContent = true
observedWindow = nil
}

/// Adopts the window the scene attached to.
///
/// - Parameters:
/// - window: Identity of the attached window.
/// - isMiniaturized: Whether that window is currently miniaturized; content
/// stays hidden while miniaturized to avoid wasted rendering.
mutating func windowConfigured(_ window: ObjectIdentifier, isMiniaturized: Bool) {
observedWindow = window
shouldRenderContent = !isMiniaturized
}
Comment on lines +32 to +35

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 The old setContentVisibility had a guard shouldRenderSettingsContent != isVisible else { return } dedup to prevent no-op @State mutations (which still trigger a SwiftUI re-render even when the value doesn't change). windowConfigured unconditionally writes both observedWindow and shouldRenderContent on every call. WindowAccessor typically fires once, but on a window-moved-between-screens event or any future path that re-triggers the accessor, this will cause an avoidable render. Adding an early exit when the values are already correct restores the prior optimization.

Suggested change
mutating func windowConfigured(_ window: ObjectIdentifier, isMiniaturized: Bool) {
observedWindow = window
shouldRenderContent = !isMiniaturized
}
mutating func windowConfigured(_ window: ObjectIdentifier, isMiniaturized: Bool) {
let newShouldRender = !isMiniaturized
guard observedWindow != window || shouldRenderContent != newShouldRender else { return }
observedWindow = window
shouldRenderContent = newShouldRender
}

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


/// Hides content while the observed window is miniaturized.
mutating func windowDidMiniaturize(_ window: ObjectIdentifier) {
guard window == observedWindow else { return }
shouldRenderContent = false
}

/// Restores content when the window becomes visible again.
///
/// Re-adopts the window when the previous observation was cleared on close
/// (the reused-scene case where `WindowAccessor` never fires a second time).
///
/// - Parameters:
/// - window: Identity of the window that became visible.
/// - isSettingsWindow: Whether that window is the Settings window, used to
/// re-adopt it after a prior close cleared the reference.
mutating func windowDidBecomeVisible(_ window: ObjectIdentifier, isSettingsWindow: Bool) {
if observedWindow == nil, isSettingsWindow {
observedWindow = window
}
guard window == observedWindow else { return }
shouldRenderContent = true
}
Comment on lines +52 to +58

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 Re-adoption relies on an implicit ordering dependency with SettingsWindowPresenter

windowDidBecomeVisible re-adopts the Settings window by checking isSettingsWindow, which the caller resolves via window.identifier?.rawValue == SettingsWindowPresenter.windowIdentifier. This check only returns true because SettingsWindowPresenter.configure(window:) has already stamped the identifier on the reused NSWindow during the first open. Nothing in SettingsWindowContentVisibility's interface encodes that precondition, so a future refactor that delays or skips configure(window:) would silently break re-adoption: isSettingsWindow would be false, observedWindow would stay nil, and the window would go blank again — exactly the bug this PR fixed — without any compile-time or runtime signal.

Rule Used: Flag Swift fixes that patch symptoms while leaving... (source)


/// Hides content and forgets the window when it closes.
mutating func windowWillClose(_ window: ObjectIdentifier) {
guard window == observedWindow else { return }
shouldRenderContent = false
observedWindow = nil
}
}
54 changes: 20 additions & 34 deletions Sources/cmuxApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9008,12 +9008,11 @@ private struct GlobalHotkeySection: View {

private struct SettingsWindowRootView: View {
@State private var draftState = SettingsDraftState()
@State private var windowReference = WeakSettingsWindowReference()
@State private var shouldRenderSettingsContent = true
@State private var visibility = SettingsWindowContentVisibility()

var body: some View {
Group {
if shouldRenderSettingsContent {
if visibility.shouldRenderContent {
SettingsRootView(draftState: draftState)
} else {
Color.clear
Expand All @@ -9024,54 +9023,41 @@ private struct SettingsWindowRootView: View {
}
}
.background(WindowAccessor { window in
windowReference.window = window
SettingsWindowPresenter.configure(window: window)
setContentVisibility(!window.isMiniaturized)
visibility.windowConfigured(ObjectIdentifier(window), isMiniaturized: window.isMiniaturized)
})
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didMiniaturizeNotification)) { notification in
guard isObservedWindow(notification.object) else { return }
setContentVisibility(false)
guard let window = notification.object as? NSWindow else { return }
visibility.windowDidMiniaturize(ObjectIdentifier(window))
}
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didDeminiaturizeNotification)) { notification in
guard isObservedWindow(notification.object) else { return }
setContentVisibility(true)
handleWindowDidBecomeVisible(notification.object)
}
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { notification in
guard isObservedWindow(notification.object) else { return }
setContentVisibility(true)
handleWindowDidBecomeVisible(notification.object)
}
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeMainNotification)) { notification in
guard isObservedWindow(notification.object) else { return }
setContentVisibility(true)
handleWindowDidBecomeVisible(notification.object)
}
.onReceive(NotificationCenter.default.publisher(for: NSWindow.willCloseNotification)) { notification in
guard isObservedWindow(notification.object) else { return }
setContentVisibility(false)
windowReference.window = nil
guard let window = notification.object as? NSWindow else { return }
visibility.windowWillClose(ObjectIdentifier(window))
}
}

private func isObservedWindow(_ object: Any?) -> Bool {
guard
let notificationWindow = object as? NSWindow,
let window = windowReference.window
else {
return false
}
return notificationWindow === window
}

private func setContentVisibility(_ isVisible: Bool) {
guard shouldRenderSettingsContent != isVisible else { return }
shouldRenderSettingsContent = isVisible
/// Forwards a "window became visible" notification to the visibility model,
/// re-adopting the Settings window by its stable identifier when a prior
/// close cleared the observed reference (the reused-scene case where
/// ``WindowAccessor`` does not fire again on the second open).
private func handleWindowDidBecomeVisible(_ object: Any?) {
guard let window = object as? NSWindow else { return }
visibility.windowDidBecomeVisible(
ObjectIdentifier(window),
isSettingsWindow: window.identifier?.rawValue == SettingsWindowPresenter.windowIdentifier
)
}
}

@MainActor
private final class WeakSettingsWindowReference {
weak var window: NSWindow?
}

@MainActor
@Observable
final class SettingsDraftState {
Expand Down
8 changes: 8 additions & 0 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,8 @@
A50019A0 /* SettingsNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50019A1 /* SettingsNavigation.swift */; };
A50019B0 /* SettingsSearchAliases.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50019B1 /* SettingsSearchAliases.swift */; };
A50019B2 /* SettingsSearchIndexTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50019B3 /* SettingsSearchIndexTests.swift */; };
A11EAB100000000000000000 /* SettingsWindowContentVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11EAB100000000000000001 /* SettingsWindowContentVisibility.swift */; };
A11EAB200000000000000000 /* SettingsWindowContentVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11EAB200000000000000001 /* SettingsWindowContentVisibilityTests.swift */; };
D36090010000000000000001 /* SettingsWindowPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36090010000000000000002 /* SettingsWindowPresenter.swift */; };
D36090010000000000000003 /* SettingsWindowPresenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36090010000000000000004 /* SettingsWindowPresenterTests.swift */; };
1521D55DC63D5E5FC4955E31 /* ShortcutAndCommandPaletteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */; };
Expand Down Expand Up @@ -961,6 +963,8 @@
A50019A1 /* SettingsNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsNavigation.swift; sourceTree = "<group>"; };
A50019B1 /* SettingsSearchAliases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSearchAliases.swift; sourceTree = "<group>"; };
A50019B3 /* SettingsSearchIndexTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSearchIndexTests.swift; sourceTree = "<group>"; };
A11EAB100000000000000001 /* SettingsWindowContentVisibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowContentVisibility.swift; sourceTree = "<group>"; };
A11EAB200000000000000001 /* SettingsWindowContentVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowContentVisibilityTests.swift; sourceTree = "<group>"; };
D36090010000000000000002 /* SettingsWindowPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/SettingsWindowPresenter.swift; sourceTree = "<group>"; };
D36090010000000000000004 /* SettingsWindowPresenterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowPresenterTests.swift; sourceTree = "<group>"; };
6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutAndCommandPaletteTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1276,6 +1280,7 @@
3023B1003023B1003023B100 /* ConfigSource.swift */,
3023B1013023B1013023B101 /* ConfigSettingsView.swift */,
A11EAB000000000000000001 /* AppearanceSettings.swift */,
A11EAB100000000000000001 /* SettingsWindowContentVisibility.swift */,
A11EAC000000000000000001 /* GhosttyTerminalAppearance.swift */,
A11EAD000000000000000001 /* WorkspaceAppearanceResolution.swift */,
A5001011 /* cmuxApp.swift */,
Expand Down Expand Up @@ -1704,6 +1709,7 @@
D7AB00000000000000000010 /* AppDelegateMoveTabToNewWorkspaceTests.swift */,
17C9F5BA0DD14EDC8C3E5002 /* MainWindowVisibilityControllerTests.swift */,
A11EAA000000000000000001 /* AppearanceSettingsTests.swift */,
A11EAB200000000000000001 /* SettingsWindowContentVisibilityTests.swift */,
6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */,
C3408A000000000000000004 /* RightSidebarCommandPaletteTests.swift */,
C0DEF4120000000000000002 /* CommandPaletteSettingsToggleTests.swift */,
Expand Down Expand Up @@ -2260,6 +2266,7 @@
D9CCF002D9CCF002D9CCF002 /* SettingsLazyLoadHelpers.swift in Sources */,
A50019A0 /* SettingsNavigation.swift in Sources */,
A50019B0 /* SettingsSearchAliases.swift in Sources */,
A11EAB100000000000000000 /* SettingsWindowContentVisibility.swift in Sources */,
D36090010000000000000001 /* SettingsWindowPresenter.swift in Sources */,
76027A12C93B4538BF22C71E /* ShortcutBareStartRouting.swift in Sources */,
125BAEF2FE3B454547B964D1 /* ShortcutHintPill.swift in Sources */,
Expand Down Expand Up @@ -2527,6 +2534,7 @@
8A3392FE64E0605D942213D1 /* SessionIndexViewTests.swift in Sources */,
F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */,
A50019B2 /* SettingsSearchIndexTests.swift in Sources */,
A11EAB200000000000000000 /* SettingsWindowContentVisibilityTests.swift in Sources */,
D36090010000000000000003 /* SettingsWindowPresenterTests.swift in Sources */,
1521D55DC63D5E5FC4955E31 /* ShortcutAndCommandPaletteTests.swift in Sources */,
F6001000A1B2C3D4E5F60718 /* ShortcutUnbindingTests.swift in Sources */,
Expand Down
97 changes: 97 additions & 0 deletions cmuxTests/SettingsWindowContentVisibilityTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import Testing

#if canImport(cmux_DEV)
@testable import cmux_DEV
#elseif canImport(cmux)
@testable import cmux
#endif

/// Regression coverage for the Settings window rendering a blank placeholder on
/// every open after the first within a session.
///
/// SwiftUI reuses the Settings `Window` scene across close / reopen, so the
/// scene's content-visibility state persists and the `WindowAccessor` that
/// supplies the window does not fire a second time. The original implementation
/// cleared the observed window on close and then rejected every subsequent
/// "became visible" notification, leaving the body stuck on the placeholder.
@MainActor
@Suite struct SettingsWindowContentVisibilityTests {
/// Distinct object identities standing in for `NSWindow` instances.
private final class WindowToken {}

@Test func rendersContentOnFirstConfigure() {
var visibility = SettingsWindowContentVisibility()
let window = WindowToken()

visibility.windowConfigured(ObjectIdentifier(window), isMiniaturized: false)

#expect(visibility.shouldRenderContent)
}
Comment on lines +22 to +29

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 Missing test: windowConfigured(isMiniaturized: true)

The suite has no case that calls windowConfigured(id, isMiniaturized: true) and verifies shouldRenderContent == false. If a future refactor accidentally flips the negation (e.g. shouldRenderContent = isMiniaturized), none of the six existing tests would catch it — they all configure with isMiniaturized: false.


@Test func hidesContentWhileMiniaturizedAndRestoresOnDeminiaturize() {
var visibility = SettingsWindowContentVisibility()
let window = WindowToken()
let id = ObjectIdentifier(window)
visibility.windowConfigured(id, isMiniaturized: false)

visibility.windowDidMiniaturize(id)
#expect(!visibility.shouldRenderContent)

visibility.windowDidBecomeVisible(id, isSettingsWindow: true)
#expect(visibility.shouldRenderContent)
}

/// The core regression: close then reopen the reused window. Because the
/// reopen arrives only as a "became visible" notification (the accessor does
/// not fire again), the model must re-adopt the Settings window and restore
/// content. Before the fix this stayed `false` — a permanently blank window.
@Test func restoresContentWhenReusedWindowReopensAfterClose() {
var visibility = SettingsWindowContentVisibility()
let window = WindowToken()
let id = ObjectIdentifier(window)
visibility.windowConfigured(id, isMiniaturized: false)

visibility.windowWillClose(id)
#expect(!visibility.shouldRenderContent)

// Reopen: same reused NSWindow, only a become-visible notification.
visibility.windowDidBecomeVisible(id, isSettingsWindow: true)
#expect(visibility.shouldRenderContent)
}

@Test func survivesRepeatedCloseReopenCycles() {
var visibility = SettingsWindowContentVisibility()
let window = WindowToken()
let id = ObjectIdentifier(window)
visibility.windowConfigured(id, isMiniaturized: false)

for _ in 0..<5 {
visibility.windowWillClose(id)
#expect(!visibility.shouldRenderContent)
visibility.windowDidBecomeVisible(id, isSettingsWindow: true)
#expect(visibility.shouldRenderContent)
}
}

@Test func ignoresUnrelatedWindowsAfterClose() {
var visibility = SettingsWindowContentVisibility()
let settingsWindow = WindowToken()
let otherWindow = WindowToken()
visibility.windowConfigured(ObjectIdentifier(settingsWindow), isMiniaturized: false)
visibility.windowWillClose(ObjectIdentifier(settingsWindow))

// A non-settings window becoming key must not flip content back on.
visibility.windowDidBecomeVisible(ObjectIdentifier(otherWindow), isSettingsWindow: false)
#expect(!visibility.shouldRenderContent)
}

@Test func miniaturizeNotificationForUnrelatedWindowIsIgnored() {
var visibility = SettingsWindowContentVisibility()
let settingsWindow = WindowToken()
let otherWindow = WindowToken()
visibility.windowConfigured(ObjectIdentifier(settingsWindow), isMiniaturized: false)

visibility.windowDidMiniaturize(ObjectIdentifier(otherWindow))
#expect(visibility.shouldRenderContent)
}
}