Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
2152bad
Restore search-result row highlight in CmuxSettingsUI
azooz2003-bit May 30, 2026
bcce4a9
Make highlight anchor/state symbols internal to avoid app-target clash
azooz2003-bit May 30, 2026
e606214
Address review: terminate highlight schedule, consolidate state
azooz2003-bit May 30, 2026
18fb6a2
Highlight section headers; fix Sidebar Branch Layout anchor
azooz2003-bit May 30, 2026
643af42
Search lists only real rows; make every result scroll+highlight
azooz2003-bit May 30, 2026
5d657f7
Restore 4 searchable settings the SPM table dropped
azooz2003-bit May 30, 2026
018e325
Match search-result titles to their row labels
azooz2003-bit May 30, 2026
1b53f90
Merge remote-tracking branch 'origin/main' into feat-settings-search-…
azooz2003-bit May 30, 2026
fc863e0
Pin scrolled-to section header to the top reliably
azooz2003-bit May 30, 2026
5381781
Fix search-parity gaps found by independent audit
azooz2003-bit May 30, 2026
07fbec5
Fix duplicate-anchor collisions; guard with a uniqueness test
azooz2003-bit May 30, 2026
1747d3a
Make hibernation/custom-search sub-rows independently searchable
azooz2003-bit May 30, 2026
ff0ef46
Address round-4 code-quality findings
azooz2003-bit May 30, 2026
08b3464
Drop dead-by-default conditional sub-field search results
azooz2003-bit May 30, 2026
bfa4124
Remove accidentally-committed dogfood screenshots
azooz2003-bit May 30, 2026
1b5d319
Fix deep-row search hits scrolling to section top instead of the row
azooz2003-bit May 30, 2026
46533bf
Realize the target section before scrolling to a deep row
azooz2003-bit May 30, 2026
07a11b5
Eager detail stack + single scrollTo for search navigation
azooz2003-bit May 30, 2026
0eceb3b
Add DEBUG :all search sentinel to enumerate every settings row
azooz2003-bit May 30, 2026
956d0bb
Lazy-load the keyboard-shortcuts recorder list
azooz2003-bit May 30, 2026
e639f06
Add DEBUG window-state logging to SettingsWindowPresenter
azooz2003-bit May 30, 2026
b953994
Fix Settings window not reopening after close
azooz2003-bit May 30, 2026
beb34cc
Remove temporary window-state debug probes
azooz2003-bit May 30, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,6 @@ web/public/pagefind/
tmp/
tmp-*/
.iter-logs/*.png

# Local dogfood scratch (screenshots, recordings) — never commit
artifacts/
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,40 @@ public struct SettingsCardRow<Trailing: View>: View {
let title: String
let subtitle: String?
let controlWidth: CGFloat?
let searchAnchorID: String?
@ViewBuilder let trailing: Trailing

// The settings root injects the built search index so each row can
// map the cmux.json path(s) it declares via `configurationReview`
// into the sidebar/search anchor id(s) the navigation layer scrolls
// to and highlights. `nil` outside the settings window (previews,
// host embedding without the index), in which case the row simply
// doesn't participate in search navigation.
@Environment(\.settingsSearchIndex) private var searchIndex

/// Anchor ids that make the row `scrollTo`-addressable and eligible
/// for the search-result highlight pulse. An explicit
/// ``searchAnchorID`` wins (used by `.action` / `.settingsOnly` /
/// custom-control rows that don't write a single cmux.json key);
/// otherwise the row resolves the path(s) it declares via
/// `configurationReview` through the injected index. Empty when no
/// index is injected and no explicit anchor is set.
private var searchAnchorIDs: [String] {
if let searchAnchorID { return [searchAnchorID] }
guard let searchIndex else { return [] }
return configurationReview.paths.compactMap(searchIndex.anchorID(forSettingsPath:))
}

public init(
configurationReview: SettingsConfigurationReview = .action,
searchAnchorID: String? = nil,
_ title: String,
subtitle: String? = nil,
controlWidth: CGFloat? = nil,
@ViewBuilder trailing: () -> Trailing
) {
self.configurationReview = configurationReview
self.searchAnchorID = searchAnchorID
self.title = title
self.subtitle = subtitle
self.controlWidth = controlWidth
Expand Down Expand Up @@ -59,5 +83,6 @@ public struct SettingsCardRow<Trailing: View>: View {
.padding(.horizontal, 14)
.padding(.vertical, 9)
.frame(maxWidth: .infinity, alignment: .leading)
.settingsSearchAnchors(searchAnchorIDs)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,21 @@ import SwiftUI
///
/// Mirrors the legacy in-app chrome: small secondary-colored title
/// nudged 2pt right of the card, intentionally tucked close to the
/// card below it. Use ``settingsSearchAnchor(_:)`` on the header
/// when callers should be able to scroll-to it from the sidebar
/// or the search hit list.
/// card below it.
///
/// Pass the owning ``SettingsSectionID`` so the header pulses the
/// search-result highlight when the user clicks that section's hit in
/// the sidebar search (matching the per-row highlight). The scroll
/// `.id` stays on the enclosing section, so the header only takes the
/// highlight overlay, never a duplicate `.id`.
@MainActor
public struct SettingsSectionHeader: View {
let title: String
let section: SettingsSectionID?

public init(_ title: String) {
public init(_ title: String, section: SettingsSectionID? = nil) {
self.title = title
self.section = section
}

public var body: some View {
Expand All @@ -21,5 +27,6 @@ public struct SettingsSectionHeader: View {
.foregroundColor(.secondary)
.padding(.leading, 2)
.padding(.bottom, -2)
.settingsSearchHighlight(section.map { ["section:\($0.rawValue)"] } ?? [])
}
}

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import SwiftUI

/// Drives the "flash the navigated-to row" affordance that the legacy
/// in-app settings window had and the SPM package initially dropped.
///
/// When the user clicks a search hit in the sidebar, the detail scroll
/// snaps the matching row to the vertical center *and* the row pulses a
/// rounded accent-colored border for a few seconds so the eye can find
/// it. The pulse is owned by ``SettingsSearchHighlightState`` (set on
/// the settings root) and read by every ``SettingsCardRow`` through the
/// environment; a row participates by tagging itself with
/// ``SwiftUI/View/settingsSearchAnchors(_:)``.

/// The currently-highlighted anchor plus a monotonic token and the
/// instant the pulse began. `token` changes on every navigation so
/// re-navigating to the same row restarts the animation even when the
/// anchor id is unchanged; `startedAt` seeds the `TimelineView` fade.
// Intentionally `internal`, not `public`: the legacy in-app settings
// (`Sources/SettingsNavigation.swift`) declares a same-named
// `SettingsSearchHighlightState` plus matching `View` /
// `EnvironmentValues` extensions. The app target imports this package, so
// a `public` surface here collides with the legacy one and makes every
// unqualified use ambiguous. Nothing outside this package consumes these
// symbols, so keeping them internal removes them from the host namespace.
struct SettingsSearchHighlightState: Equatable, Sendable {
let anchorID: String?
let token: Int
let startedAt: Date?

init(anchorID: String?, token: Int, startedAt: Date?) {
self.anchorID = anchorID
self.token = token
self.startedAt = startedAt
}
}

private struct SettingsSearchHighlightStateKey: EnvironmentKey {
static let defaultValue = SettingsSearchHighlightState(anchorID: nil, token: 0, startedAt: nil)
}

extension EnvironmentValues {
/// The active search-result highlight. Defaults to "nothing
/// highlighted" so rows render inert outside the settings window.
var settingsSearchHighlightState: SettingsSearchHighlightState {
get { self[SettingsSearchHighlightStateKey.self] }
set { self[SettingsSearchHighlightStateKey.self] = newValue }
}
}

/// Resolves a row's dotted cmux.json path (declared via
/// ``SettingsConfigurationReview``) to the stable sidebar/search anchor
/// id the navigation layer scrolls to and highlights. Injected from the
/// settings root, which owns the built ``SettingsSearchIndex``.
private struct SettingsSearchIndexKey: EnvironmentKey {
static let defaultValue: SettingsSearchIndex? = nil
}

extension EnvironmentValues {
/// The settings search index, used by ``SettingsCardRow`` to map its
/// declared config paths to scroll/highlight anchor ids. `nil` when
/// a row is rendered outside the settings window (e.g. previews), in
/// which case the row simply doesn't anchor.
var settingsSearchIndex: SettingsSearchIndex? {
get { self[SettingsSearchIndexKey.self] }
set { self[SettingsSearchIndexKey.self] = newValue }
}
}

extension View {
/// Makes this view both `scrollTo`-addressable (via `.id` on the
/// first anchor) and eligible for the search-result highlight pulse
/// when any of `anchorIDs` matches the active highlight state.
@ViewBuilder
func settingsSearchAnchors(_ anchorIDs: [String]) -> some View {
let filteredAnchorIDs = anchorIDs.filter { !$0.isEmpty }
if let primaryAnchorID = filteredAnchorIDs.first {
self
.id(primaryAnchorID)
.modifier(SettingsSearchHighlightModifier(anchorIDs: filteredAnchorIDs))
} else {
self
}
}

/// Eligible for the highlight pulse without claiming a `scrollTo`
/// `.id`. Used by section headers, whose enclosing section already
/// owns the `section:<raw>` scroll anchor — applying `.id` here too
/// would create a duplicate id and break scroll resolution.
@ViewBuilder
func settingsSearchHighlight(_ anchorIDs: [String]) -> some View {
let filteredAnchorIDs = anchorIDs.filter { !$0.isEmpty }
if filteredAnchorIDs.isEmpty {
self
} else {
self.modifier(SettingsSearchHighlightModifier(anchorIDs: filteredAnchorIDs))
}
}
}

/// Renders the pulsing accent border behind a row while it is the
/// active search-navigation target. A `TimelineView` with a finite
/// `.explicit` schedule drives the fade curve from `startedAt` so the
/// highlight ramps in, holds, then fades out without any timer or
/// `Task.sleep` in app code.
///
/// The schedule is deliberately finite: it covers only the highlight
/// window (`pulseDuration`), so after the last frame the `TimelineView`
/// stops requesting updates and its display link goes idle. A plain
/// `.animation` schedule would keep firing at the display refresh rate
/// forever (drawing an invisible opacity-0 shape every frame) because
/// the highlight state isn't cleared until the next navigation.
private struct SettingsSearchHighlightModifier: ViewModifier {
@Environment(\.settingsSearchHighlightState) private var highlightState
let anchorIDs: [String]

/// Total length of the ramp-in + hold + fade-out pulse, in seconds.
private static let pulseDuration: TimeInterval = 5.9
private static let frameInterval: TimeInterval = 1.0 / 60.0

private func matches(_ state: SettingsSearchHighlightState) -> Bool {
guard let anchorID = state.anchorID else { return false }
return anchorIDs.contains(anchorID)
}

func body(content: Content) -> some View {
content
.background {
if matches(highlightState), let startedAt = highlightState.startedAt {
TimelineView(.explicit(frames(from: startedAt))) { context in
let opacity = highlightOpacity(at: context.date, startedAt: startedAt)
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color.accentColor.opacity(opacity * 0.24))
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(Color.accentColor.opacity(opacity), lineWidth: 2.5)
)
.shadow(color: Color.accentColor.opacity(opacity * 0.24), radius: 8, x: 0, y: 0)
}
// Restart the animation when the user re-navigates to
// the same anchor: a changing token forces a fresh
// TimelineView identity so the schedule re-seeds.
.id(highlightState.token)
}
}
Comment on lines +125 to +144
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 TimelineView(.animation) keeps a live display-link after fade-out

Once elapsed >= 5.9 the highlightOpacity function always returns 0, yet highlightedSearchAnchorID is never cleared after the animation completes — only on the next navigation. So matches(highlightState) stays true and the TimelineView(.animation) keeps firing at display-refresh rate (~120fps on ProMotion), calling highlightOpacity every frame and drawing RoundedRectangle shapes with opacity(0) indefinitely. The display link for the targeted row remains live until the user clicks another search result or closes the window. Consider clearing the anchor id after the animation window via a one-shot Task { try? await Task.sleep(for: .seconds(6)); highlightedSearchAnchorID = nil } gated by the same token, or by deriving visibility from elapsed in a TimelineSchedule.explicit schedule that terminates after the last keyframe.

Rule Used: Flag new blocking or timing-based synchronization ... (source)

}

/// One frame per display tick across the pulse window, ending after
/// the fade so the `TimelineView` schedule terminates. A row that
/// scrolls into view mid-pulse still renders the correct frame for
/// "now"; a row whose pulse already elapsed renders the final
/// (invisible) frame once and never schedules another update.
private func frames(from start: Date) -> [Date] {
let count = Int(Self.pulseDuration / Self.frameInterval)
return (0...count).map { start.addingTimeInterval(Double($0) * Self.frameInterval) }
}

private func highlightOpacity(at date: Date, startedAt: Date) -> Double {
let elapsed = date.timeIntervalSince(startedAt)
if elapsed < 0.14 {
return max(0, min(1, elapsed / 0.14))
}
if elapsed < 5 {
return 1
}
if elapsed < Self.pulseDuration {
return max(0, 1 - ((elapsed - 5) / 0.9))
}
return 0
}
}
Loading
Loading