Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@ public struct SettingsCardRow<Trailing: View>: View {
let controlWidth: CGFloat?
@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 derived from this row's declared config paths, used to
/// make the row `scrollTo`-addressable and eligible for the
/// search-result highlight pulse. Empty for action/settings-only rows
/// or when no index is injected.
private var searchAnchorIDs: [String] {
guard let searchIndex else { return [] }
return configurationReview.paths.compactMap(searchIndex.anchorID(forSettingsPath:))
}

public init(
configurationReview: SettingsConfigurationReview = .action,
_ title: String,
Expand Down Expand Up @@ -59,5 +76,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
@@ -0,0 +1,146 @@
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 {
/// Tags this view with a single search anchor. Convenience over
/// ``settingsSearchAnchors(_:)`` for the common one-path row.
@ViewBuilder
func settingsSearchAnchor(_ anchorID: String?) -> some View {
if let anchorID {
settingsSearchAnchors([anchorID])
} else {
self
}
}

/// 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
}
}
}

/// Renders the pulsing accent border behind a row while it is the
/// active search-navigation target. A `TimelineView(.animation)` drives
/// the fade curve from `startedAt` so the highlight ramps in, holds,
/// then fades out without any timer or `Task.sleep` in app code.
private struct SettingsSearchHighlightModifier: ViewModifier {
@Environment(\.settingsSearchHighlightState) private var highlightState
let anchorIDs: [String]

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) {
TimelineView(.animation) { context in
let opacity = highlightOpacity(at: context.date, for: highlightState)
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 `startedAt` 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)

}

private func highlightOpacity(at date: Date, for state: SettingsSearchHighlightState) -> Double {
guard matches(state), let startedAt = state.startedAt else { return 0 }
let elapsed = date.timeIntervalSince(startedAt)
if elapsed < 0.14 {
return max(0, min(1, elapsed / 0.14))
}
if elapsed < 5 {
return 1
}
if elapsed < 5.9 {
return max(0, 1 - ((elapsed - 5) / 0.9))
}
return 0
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,20 @@ public struct SettingsSearchIndex: Sendable {

public let entries: [Entry]

/// Maps a dotted cmux.json path (e.g. `sidebar.showBranchDirectory`)
/// to the stable anchor id of the entry that owns it. Lets a
/// ``SettingsCardRow`` resolve the config path it already declares
/// via ``SettingsConfigurationReview`` into the scroll/highlight
/// target the navigation layer posts, without a second
/// hand-maintained id table. Built from the curated entries' dotted
/// synonym tokens.
private let pathAnchorIDs: [String: String]

/// The set of every entry id in ``entries``, used to validate the
/// `setting:<path>` fallback so a row never anchors to an id that no
/// search hit can navigate to.
private let entryIDs: Set<String>

/// Builds an index from the section list, the supplied curated
/// entries, and any remaining ``SettingCatalog/all`` keys not
/// already covered by the curated table.
Expand Down Expand Up @@ -97,6 +111,40 @@ public struct SettingsSearchIndex: Sendable {
}

self.entries = built
self.entryIDs = Set(built.map(\.id))

// Curated synonym strings lead with the setting's dotted
// cmux.json path (e.g. "sidebar.showBranchDirectory git …"),
// which is exactly what a row declares via its
// configurationReview. Index every dotted token to the curated
// entry's anchor id so a row can map its path to a scroll target.
// First writer wins: a dotted path is owned by one setting.
var pathAnchors: [String: String] = [:]
for entry in curatedEntries {
let anchorID = "setting:\(entry.section.rawValue):\(entry.id)"
for token in entry.synonyms.split(separator: " ") where token.contains(".") {
let path = String(token)
if pathAnchors[path] == nil { pathAnchors[path] = anchorID }
}
}
self.pathAnchorIDs = pathAnchors
}

/// Resolves a dotted cmux.json path to the anchor id the
/// sidebar/search navigation scrolls to and highlights.
///
/// Returns the curated entry's anchor id when the path is covered by
/// a curated synonym, otherwise the `setting:<path>` fallback id when
/// such a raw catalog entry exists, otherwise `nil` (the path has no
/// navigable search hit).
///
/// - Parameter path: A dotted cmux.json path, e.g. `terminal.copyOnSelect`.
/// - Returns: The stable entry id to use as a `scrollTo` / highlight
/// anchor, or `nil` when nothing in the index targets `path`.
public func anchorID(forSettingsPath path: String) -> String? {
if let curated = pathAnchorIDs[path] { return curated }
let fallback = "setting:\(path)"
return entryIDs.contains(fallback) ? fallback : nil
}

public func match(_ query: String) -> [Entry] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ public struct SettingsWindowRoot: View {
// `applyScrollNavigation` and re-checked inside `DispatchQueue.main.async`,
// so only the most recent request actually scrolls.
@State private var settingsNavigationGeneration: Int = 0
// Drives the "flash the navigated-to row" affordance the legacy
// settings window had. When the user clicks a search hit, the target
// row pulses an accent border for a few seconds so the eye can find
// it after the scroll. `token` changes on every highlight so
// re-navigating to the same row restarts the pulse; `startedAt`
// seeds the row's `TimelineView` fade. Read by every
// `SettingsCardRow` through `\.settingsSearchHighlightState`.
@State private var highlightedSearchAnchorID: String?
@State private var searchHighlightToken: Int = 0
@State private var searchHighlightStartedAt: Date?
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

private var defaultsStore: UserDefaultsSettingsStore { runtime.userDefaultsStore }
private var jsonStore: JSONConfigStore { runtime.jsonStore }
Expand Down Expand Up @@ -117,6 +127,18 @@ public struct SettingsWindowRoot: View {
detailScroll
}
.navigationSplitViewStyle(.balanced)
// Inject the built search index so each SettingsCardRow can map
// its declared cmux.json paths to scroll/highlight anchor ids,
// and publish the active highlight so the matching row pulses.
.environment(\.settingsSearchIndex, searchIndex)
.environment(
\.settingsSearchHighlightState,
SettingsSearchHighlightState(
anchorID: highlightedSearchAnchorID,
token: searchHighlightToken,
startedAt: searchHighlightStartedAt
)
)
// Legacy SettingsRootView pins the window minimum to
// SettingsWindowPresenter.minimumSize (820 x 540); mirror that
// so the package window can shrink to the same lower bound.
Expand Down Expand Up @@ -378,6 +400,17 @@ public struct SettingsWindowRoot: View {
let sectionID = self.anchorID(for: target)
settingsNavigationGeneration += 1
let navigationGeneration = settingsNavigationGeneration
// Arm (or clear) the row highlight before the scroll so the
// pulse is already live when the target row lands in view.
// Mirrors legacy SettingsView.applySettingsNavigation.
if shouldHighlight && anchorID != sectionID {
highlightedSearchAnchorID = anchorID
searchHighlightStartedAt = Date()
searchHighlightToken += 1
} else {
highlightedSearchAnchorID = nil
searchHighlightStartedAt = nil
}
DispatchQueue.main.async {
guard navigationGeneration == settingsNavigationGeneration else { return }
proxy.scrollTo(sectionID, anchor: .top)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,29 @@ struct SettingsSearchIndexTests {
let withDiacritics = index.match("autómation")
#expect(plain.count == withDiacritics.count)
}

/// The search-result highlight depends on a row being able to map
/// the dotted cmux.json path it declares (e.g. the "Show Branch +
/// Directory in Sidebar" row's `sidebar.showBranchDirectory`) to the
/// same anchor id the sidebar search hit carries. This is the bridge
/// that lets `scrollTo` + the pulse find the row.
@Test func resolvesCuratedPathToSidebarHitAnchor() {
let index = SettingsSearchIndex(catalog: SettingCatalog())
let anchor = index.anchorID(forSettingsPath: "sidebar.showBranchDirectory")
#expect(anchor == "setting:sidebarAppearance:show-branch-directory")
}

/// A resolved anchor must correspond to a real indexed entry,
/// otherwise the navigation layer would scroll to / highlight an id
/// no row carries.
@Test func resolvedAnchorMatchesAnIndexedEntry() throws {
let index = SettingsSearchIndex(catalog: SettingCatalog())
let anchor = try #require(index.anchorID(forSettingsPath: "terminal.copyOnSelect"))
#expect(index.entries.contains { $0.id == anchor })
}

@Test func unknownPathHasNoAnchor() {
let index = SettingsSearchIndex(catalog: SettingCatalog())
#expect(index.anchorID(forSettingsPath: "totally.bogus.path") == nil)
}
}
Loading