Skip to content

fix(profiles): prompt before applying display spacing#621

Merged
stonerl merged 1 commit into
developmentfrom
fix/display-spacing-apply-saves
May 26, 2026
Merged

fix(profiles): prompt before applying display spacing#621
stonerl merged 1 commit into
developmentfrom
fix/display-spacing-apply-saves

Conversation

@nightah
Copy link
Copy Markdown
Collaborator

@nightah nightah commented May 26, 2026

What does this PR do?

Two related fixes to the Displays > Menu Bar Item Spacing flow that together stop Thaw from breaking pre-existing user setups:

  1. A confirmation alert in front of the Apply (and inline reset) buttons that doubles as a destructive-action warning for the relaunch wave and as the persistence channel that writes the new spacing into the active profile (or every profile) before the reapply pass fires.
  2. A first-launch seed so that an externally configured NSStatusItemSpacing (set via defaults write in Terminal) is adopted as the implicit baseline instead of overwritten by Thaw's default of 0.

PR Type

  • Bugfix
  • CI/CD related changes
  • Code style update (formatting, renaming)
  • Documentation
  • Feature
  • Refactor
  • Performance improvement
  • Test addition or update
  • Other (please describe):

Does this PR introduce a breaking change?

  • Yes
  • No

What is the current behavior?

Issue Number: #598, #602, #619

With a profile active, dragging the Menu Bar Item Spacing slider for the active display and clicking Apply triggered a relaunch wave for all menu bar apps. Once the wave settled, DisplaySettingsManager.applyActiveDisplaySpacing called ProfileManager.reapplyActiveProfile(), which re-applied the active profile's stored displayConfigurations. Those still held the previous offset, so the just-applied value was silently overwritten, and a second relaunch wave fired to apply the reverted value. The Reset () button next to Apply had the same problem because it also called updateConfiguration immediately. The user's documented workaround in #619 was to export the profile, delete it, change the setting, reimport, and re-save.

Separately, users who had set NSStatusItemSpacing (and/or NSStatusItemSelectionPadding) externally via defaults write saw Thaw fire a relaunch wave on every startup. applyActiveDisplaySpacing read the per-display default offset of 0, computed target = 16, found the on-disk value at e.g. 6, and rewrote it to 16, restarting every menu bar app in the process. The same wave fired again on screen-parameter changes (sleep/wake, monitor connect/disconnect, Sidecar handshake), giving the recurring app-restart symptom reported in #602 and #598.

What is the new behavior?

Confirmation gate in DisplaySettingsPane. The Apply button and the inline reset button now route through a single requestSpacingApply helper. When the change targets the active menu bar display, an alert names the relaunch wave; when a profile is active, the alert offers Update Active Profile and Update All Profiles alongside Cancel.

  • Update Active Profile calls displaySettings.updateConfiguration and then profileManager.updateProfile(scope: .configurationOnly) synchronously, so the profile file is on disk before the Combine sink dispatches applyActiveDisplaySpacing. The subsequent reapplyActiveProfile loads the new spacing instead of the old one.
  • Update All Profiles calls a new ProfileManager.updateAllProfilesItemSpacingOffset(displayUUID:offset:) helper that walks every profile and writes the new offset into its displayConfigurations[displayUUID] via withItemSpacingOffset, inserting a defaultConfiguration entry where the profile has no prior entry for that display.
  • With no active profile and a non-active display the change still applies immediately, preserving the prior fast path.
  • Cancel snaps the slider draft back to the saved value.
  • New DisplaySettingsManager.activeMenuBarDisplayUUID accessor wraps Bridging.getActiveMenuBarDisplayUUID() so the pane can decide whether a change targets the active display.

First-launch seed in DisplaySettingsManager.loadInitialState. When no per-display configurations have been persisted yet and NSStatusItemSpacing differs from the macOS default of 16, the manager reads the on-disk value, computes offset = onDisk - 16, and seeds every connected display's itemSpacingOffset with that offset. The seeded dictionary is written to Defaults.displayIceBarConfigurations inline (the Combine persistence sink is not wired yet at that point), so adoption is one-shot. The next time applyActiveDisplaySpacing runs, its target matches on-disk and the no-op guard skips the relaunch wave. NSStatusItemSelectionPadding is not consulted because Thaw drives both keys from a single offset; users whose padding diverges from spacing see one normalising relaunch on first launch and none thereafter.

New strings (Apply spacing change?, two profile-aware message variants, the relaunch-only message, Update Active Profile, Update All Profiles) are added to Localizable.xcstrings with empty localizations for Crowdin pickup.

PR Checklist

  • I’ve built and run the app locally
  • I’ve checked for console errors or crashes
  • I've run relevant tests and they pass
  • I've added or updated tests (if applicable)
  • I've updated documentation as needed

Other information

The confirmation-gate fix relies on a timing invariant: the Combine sink that drives applyActiveDisplaySpacing is .receive(on: DispatchQueue.main).sink { ... }, so it runs on the next main-runloop turn. The confirmation action calls updateConfiguration and then updateProfile/updateAllProfilesItemSpacingOffset synchronously on the main thread before yielding, which guarantees the profile file(s) are written before the sink fires and reapplyActiveProfile reads them back.

The seeding fix persists inline rather than relying on the standard Combine persistence sink, because the sink's .dropFirst() would discard the seeded emission since it is set up after loadInitialState. Without inline persistence the seed would re-run on every launch and never make it into Defaults.displayIceBarConfigurations.

Fixes #598, #602, #619

Summary by CodeRabbit

  • New Features

    • Added a draft/confirm flow for menu bar item spacing changes with a confirmation dialog offering “Update Active Profile”, “Update All Profiles”, or apply to the current display.
    • Confirmation messages are dynamically localized and reflect active profile context.
    • App now detects and preserves existing system spacing on first launch.
  • Bug Fixes

    • Improved error handling and rollback when profile updates fail, with user-facing error alerts.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 26, 2026

📝 Walkthrough

Walkthrough

This PR adds a draft/confirm/error flow for menu-bar item spacing changes, seeds per-display spacing from system NSStatusItemSpacing on first load, exposes the active menu-bar display UUID, and provides a ProfileManager API to apply spacing offsets across all profiles.

Changes

Spacing Confirmation Flow

Layer / File(s) Summary
System spacing detection and seeding
Thaw/Settings/Models/DisplaySettingsManager.swift
activeMenuBarDisplayUUID added. systemSpacingDefault baseline (16); currentSystemSpacing() reads NSStatusItemSpacing via CFPreferences and seedConfigurationsFromSystemSpacing() computes offset and seeds missing per-display configurations, persisting them to Defaults.
Bulk profile spacing update
Thaw/Settings/Models/ProfileManager.swift
updateAllProfilesItemSpacingOffset(displayUUID:offset:) loads each profile, updates/creates the display configuration with the offset, sets a shared modifiedAt, persists profile JSON files, updates in-memory metadata, and writes the manifest.
Confirmation UI and state management
Thaw/Settings/SettingsPanes/DisplaySettingsPane.swift
Adds draftSpacing, pendingSpacingApply, errorMessage/showingError, and PendingSpacingApply payload. requestSpacingApply(for:offset:) stages or commits spacing changes based on active profile/active display. Two alerts present confirmation and errors; actions support Update Active, Update All, Apply, and Cancel with rollback on profile-save failures.
Confirmation alert localization
Thaw/Resources/Localizable.xcstrings
New localization keys for the confirmation alert title, message variants (including an interpolated active profile name), and confirmation button labels.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~40 minutes

Possibly related PRs

  • stonerl/Thaw#569: Related changes gating spacing apply behavior using the active menu-bar display UUID.
  • stonerl/Thaw#533: Also adds spacing-related localization entries.
  • stonerl/Thaw#331: Related profile persistence and profile update machinery used by the new bulk update.

Suggested reviewers

  • stonerl

Poem

🐰 I nudged the menus, small and neat,
A careful prompt before they meet.
Profiles sway in tidy rows,
One confirm, and balance grows. 🎋

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(profiles): prompt before applying display spacing' clearly summarizes the main change: adding a confirmation prompt for spacing adjustments.
Description check ✅ Passed The PR description comprehensively covers all required sections: clear summary of changes, PR type selected, breaking change status, detailed current and new behavior explanations, checklist completion, and linked issue references.
Linked Issues check ✅ Passed The code changes implement both objectives from issue #598: preventing destructive relaunch waves via a confirmation prompt and seeding NSStatusItemSpacing baseline on first launch.
Out of Scope Changes check ✅ Passed All changes are directly related to the spacing change confirmation flow and first-launch seeding fixes described in the PR objectives.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/display-spacing-apply-saves

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Thaw/Settings/Models/DisplaySettingsManager.swift (1)

107-118: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Gate the seed migration on missing persisted data, not on configurations.isEmpty.

Line 116 currently treats both a decode failure and a deliberately persisted empty {} as “first launch”. That lets this migration rerun later and can overwrite existing per-display settings with freshly seeded values. Only call seedConfigurationsFromSystemSpacing() when .displayIceBarConfigurations is absent.

Suggested fix
 private func loadInitialState() {
-        if let data = Defaults.data(forKey: .displayIceBarConfigurations) {
+        let persistedConfigurationsData = Defaults.data(forKey: .displayIceBarConfigurations)
+        if let data = persistedConfigurationsData {
             do {
                 configurations = try decoder.decode([String: DisplayIceBarConfiguration].self, from: data)
                 diagLog.info("Loaded per-display configurations for \(configurations.count) display(s)")
             } catch {
                 diagLog.error("Failed to decode per-display configurations: \(error)")
             }
         }
-        if configurations.isEmpty {
+        if persistedConfigurationsData == nil {
             seedConfigurationsFromSystemSpacing()
         }
         if let data = Defaults.data(forKey: .knownDisplays) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Thaw/Settings/Models/DisplaySettingsManager.swift` around lines 107 - 118,
The code currently calls seedConfigurationsFromSystemSpacing() when
configurations.isEmpty, which treats a persisted empty dictionary as missing and
can overwrite real user settings; change loadInitialState() to call
seedConfigurationsFromSystemSpacing() only when Defaults.data(forKey:
.displayIceBarConfigurations) is nil (i.e., no persisted value). Specifically,
in loadInitialState() use the presence/absence of Defaults.data(forKey:
.displayIceBarConfigurations) to decide whether to seed (call
seedConfigurationsFromSystemSpacing()) and keep the existing decode/empty
handling for configurations without triggering the migration when an empty
persisted value exists.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Thaw/Settings/Models/ProfileManager.swift`:
- Around line 676-684: Load and build the full batch of updated Profile objects
first (in updateAllProfilesItemSpacingOffset: iterate profiles and call
loadProfile(id:) and modify displayConfigurations with withItemSpacingOffset
into a temporary [Profile] array), so any load error aborts before writing; then
persist the changes: save each modified profile using a save method that does
NOT update the manifest (avoid calling saveProfileAndUpdateManifest in the
per-profile loop), and once all profile writes succeed, update/save the manifest
exactly once (call the existing manifest-update routine or a single
saveProfileAndUpdateManifest-like call for the manifest step).

In `@Thaw/Settings/SettingsPanes/DisplaySettingsPane.swift`:
- Around line 314-347: Before mutating live state with
commitSpacing(displayID:pending.displayID, offset:pending.offset), snapshot the
current offset (e.g., let previousOffset = currentConfiguration.offset or read
from the same source commitSpacing writes to) and then call commitSpacing only
after the profile write succeeds; alternatively keep the existing order but
restore the snapshot in each catch: in the catch blocks for
appState.profileManager.updateProfile(...) and
updateAllProfilesItemSpacingOffset(...) set the live config back to
previousOffset (via commitSpacing or the same internal updater) and then set
errorMessage/showingError as before. Ensure you reference commitSpacing,
pending.displayID, pending.offset, appState.profileManager.updateProfile, and
updateAllProfilesItemSpacingOffset when locating the places to snapshot and
restore.

---

Outside diff comments:
In `@Thaw/Settings/Models/DisplaySettingsManager.swift`:
- Around line 107-118: The code currently calls
seedConfigurationsFromSystemSpacing() when configurations.isEmpty, which treats
a persisted empty dictionary as missing and can overwrite real user settings;
change loadInitialState() to call seedConfigurationsFromSystemSpacing() only
when Defaults.data(forKey: .displayIceBarConfigurations) is nil (i.e., no
persisted value). Specifically, in loadInitialState() use the presence/absence
of Defaults.data(forKey: .displayIceBarConfigurations) to decide whether to seed
(call seedConfigurationsFromSystemSpacing()) and keep the existing decode/empty
handling for configurations without triggering the migration when an empty
persisted value exists.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9160d0b3-f7c3-46d1-ad52-d63b9ce1d2e7

📥 Commits

Reviewing files that changed from the base of the PR and between a47970c and 3182f42.

📒 Files selected for processing (4)
  • Thaw/Resources/Localizable.xcstrings
  • Thaw/Settings/Models/DisplaySettingsManager.swift
  • Thaw/Settings/Models/ProfileManager.swift
  • Thaw/Settings/SettingsPanes/DisplaySettingsPane.swift

Comment thread Thaw/Settings/Models/ProfileManager.swift
Comment thread Thaw/Settings/SettingsPanes/DisplaySettingsPane.swift
Clicking Apply on Displays > "Menu Bar Item Spacing" while a profile is active triggered a relaunch wave for menu bar apps, after which `DisplaySettingsManager.applyActiveDisplaySpacing` called `ProfileManager.reapplyActiveProfile()`. The reapplied profile's stored `displayConfigurations` still held the previous offset, so the just-applied spacing was silently reverted, then re-applied by a second relaunch wave once the value flipped back. Reported in #619.

The Apply and inline ↺ reset buttons now route through a single confirmation gate in `DisplaySettingsPane`. When the change targets the active menu bar display, the alert names the relaunch wave; when a profile is active, the alert lets the user pick `Update Active Profile` (saves into the active profile via `updateProfile(scope:.configurationOnly)`) or `Update All Profiles` (writes the spacing into every profile via the new `ProfileManager.updateAllProfilesItemSpacingOffset(displayUUID:offset:)` helper). The profile file(s) are written synchronously before the Combine sink dispatches `applyActiveDisplaySpacing`, so the subsequent `reapplyActiveProfile` loads the new spacing instead of the old one and the value sticks.

Cases without an active profile and a non-active display still apply immediately, preserving the prior fast path. Cancelling the alert snaps the slider back to the saved value. New strings are added to `Localizable.xcstrings` for Crowdin pickup.

`DisplaySettingsManager.loadInitialState` also now adopts externally configured system spacing on first launch. When no per-display configurations exist yet and the user has set `NSStatusItemSpacing` outside Thaw (e.g. via a `defaults write` in Terminal), the manager seeds each connected display's `itemSpacingOffset` from that on-disk value and persists the seed inline. Previously Thaw read its own default offset of 0, computed target = 16, and fired a relaunch wave on every startup that overwrote the user's manual setting. Addresses the recurring restart loops in #602 and #598.

Fixes #598, #602, #619

Signed-off-by: Amir Zarrinkafsh <3339418+nightah@users.noreply.github.com>
@nightah nightah force-pushed the fix/display-spacing-apply-saves branch from 3182f42 to 488ed67 Compare May 26, 2026 02:16
@sonarqubecloud
Copy link
Copy Markdown

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Thaw/Settings/SettingsPanes/DisplaySettingsPane.swift`:
- Around line 314-331: The current handler uses
appState.profileManager.updateProfile(id:scope:.configurationOnly) which
persists the entire profile and can unintentionally save unrelated live changes;
replace that call with a targeted updater that only patches
displayConfigurations[pending.displayID].itemSpacingOffset (e.g. add a new
method like updateProfileItemSpacingOffset(id: UUID, displayUUID: String,
offset: Double) on ProfileManager and call it instead of updateProfile(...)).
Keep the existing snapshot/rollback logic: capture previousOffset via
displaySettings.configuration(forUUID: pending.displayID).itemSpacingOffset,
call commitSpacing(displayID:pending.displayID, offset:pending.offset), then try
the new targeted update; on error restore the in-memory spacing by calling
commitSpacing(displayID:pending.displayID, offset: previousOffset) and
rethrow/log as before.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 278bc5e6-c229-4c0a-9005-aea9e3189f2e

📥 Commits

Reviewing files that changed from the base of the PR and between 3182f42 and 488ed67.

📒 Files selected for processing (4)
  • Thaw/Resources/Localizable.xcstrings
  • Thaw/Settings/Models/DisplaySettingsManager.swift
  • Thaw/Settings/Models/ProfileManager.swift
  • Thaw/Settings/SettingsPanes/DisplaySettingsPane.swift
✅ Files skipped from review due to trivial changes (1)
  • Thaw/Resources/Localizable.xcstrings

Comment thread Thaw/Settings/SettingsPanes/DisplaySettingsPane.swift
@stonerl stonerl merged commit 08dd256 into development May 26, 2026
8 checks passed
@stonerl stonerl deleted the fix/display-spacing-apply-saves branch May 26, 2026 07:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: all apps disappeared after i clicked to reset the layout.

2 participants