diff --git a/.gitignore b/.gitignore index 47f7e6e539..4f95fc7c37 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,6 @@ web/public/pagefind/ tmp/ tmp-*/ .iter-logs/*.png + +# Local dogfood scratch (screenshots, recordings) — never commit +artifacts/ diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Chrome/SettingsCardRow.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Chrome/SettingsCardRow.swift index 811cbfe193..c2380109fc 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Chrome/SettingsCardRow.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Chrome/SettingsCardRow.swift @@ -17,16 +17,40 @@ public struct SettingsCardRow: 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 @@ -59,5 +83,6 @@ public struct SettingsCardRow: View { .padding(.horizontal, 14) .padding(.vertical, 9) .frame(maxWidth: .infinity, alignment: .leading) + .settingsSearchAnchors(searchAnchorIDs) } } diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Chrome/SettingsSectionHeader.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Chrome/SettingsSectionHeader.swift index cd1c81fb6c..5bf565a648 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Chrome/SettingsSectionHeader.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Chrome/SettingsSectionHeader.swift @@ -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 { @@ -21,5 +27,6 @@ public struct SettingsSectionHeader: View { .foregroundColor(.secondary) .padding(.leading, 2) .padding(.bottom, -2) + .settingsSearchHighlight(section.map { ["section:\($0.rawValue)"] } ?? []) } } diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift index d9dc3a9f42..3b024cc267 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift @@ -21,49 +21,53 @@ extension Array where Element == CuratedSettingEntry { [ // Account / integrations .init(section: .account, id: "account", title: "Account", synonyms: "auth authentication login logout signin sign-in signout sign-out email user profile stack team"), - .init(section: .account, id: "claude-code", title: "Claude Code Integration", synonyms: "automation.claudeCodeIntegration claude code hooks agent integration status notifications"), - .init(section: .account, id: "claude-path", title: "Claude Binary Path", synonyms: "automation.claudeBinaryPath claude binary executable path cli command custom"), - .init(section: .account, id: "ripgrep-path", title: "Ripgrep Binary Path", synonyms: "ripgrep rg binary executable path search find nix custom"), - .init(section: .account, id: "subagent-notifications", title: "Suppress Subagent Notifications", synonyms: "subagent nested child agent codex claude hooks notifications"), - .init(section: .account, id: "cursor", title: "Cursor Integration", synonyms: "cursor ide agent hooks notifications"), - .init(section: .account, id: "gemini", title: "Gemini CLI Integration", synonyms: "gemini cli google agent hooks notifications"), + .init(section: .automation, id: "claude-code", title: "Claude Code Integration", synonyms: "automation.claudeCodeIntegration claude code hooks agent integration status notifications"), + .init(section: .automation, id: "claude-path", title: "Claude Binary Path", synonyms: "automation.claudeBinaryPath claude binary executable path cli command custom"), + .init(section: .automation, id: "ripgrep-path", title: "Ripgrep Binary Path", synonyms: "automation.ripgrepBinaryPath ripgrep rg binary executable path search find nix custom"), + .init(section: .automation, id: "subagent-notifications", title: "Suppress Subagent Notifications", synonyms: "automation.suppressSubagentNotifications subagent nested child agent codex claude hooks notifications"), + .init(section: .automation, id: "cursor", title: "Cursor Integration", synonyms: "automation.cursorIntegration cursor ide agent hooks notifications"), + .init(section: .automation, id: "gemini", title: "Gemini CLI Integration", synonyms: "automation.geminiIntegration gemini cli google agent hooks notifications"), // App .init(section: .app, id: "language", title: "Language", synonyms: "app.language locale l10n localization translation japanese english ja en nihongo restart"), .init(section: .app, id: "appearance", title: "Appearance", synonyms: "app.appearance theme color scheme light mode dark mode system mode"), .init(section: .app, id: "app-icon", title: "App Icon", synonyms: "app.appIcon dock icon application icon app switcher alternate icon"), .init(section: .app, id: "new-workspace-placement", title: "New Workspace Placement", synonyms: "app.newWorkspacePlacement new tab insert position order top bottom end"), - .init(section: .app, id: "workspace-inherit-working-directory", title: "Inherit Workspace Working Directory", synonyms: "workspace cwd directory inherit current focused working-directory"), - .init(section: .app, id: "minimal-mode", title: "Workspace Presentation Mode", synonyms: "presentation compact chrome layout simple titlebar controls"), - .init(section: .app, id: "keep-workspace-open", title: "Keep Workspace Open After Last Pane Closes", synonyms: "close last pane surface keep tab workspace"), - .init(section: .app, id: "focus-pane-first-click", title: "Focus Pane on First Click", synonyms: "click to focus focus follows mouse first click mouse activation"), - .init(section: .app, id: "file-drops", title: "File Drops Default Behavior", synonyms: "drag drop files finder path text terminal editor split preview shift"), - .init(section: .app, id: "preferred-editor", title: "Preferred Editor Command", synonyms: "editor open file code vscode visual studio zed sublime subl cursor"), - .init(section: .app, id: "supported-file-previews", title: "Open Supported Files in cmux", synonyms: "cmd click file preview pdf image video audio quicklook quick look editor external"), - .init(section: .app, id: "markdown-viewer", title: "Open Markdown in cmux Viewer", synonyms: "md markdown mdx viewer preview readme"), - .init(section: .app, id: "imessage-mode", title: "iMessage Mode", synonyms: "imessage message messages chat prompt prompts submitted texting reorder move workspace top agent send"), - .init(section: .app, id: "reorder-notification", title: "Reorder Workspaces on Notification", synonyms: "notification reorder move workspace top unread sort"), - .init(section: .app, id: "menu-bar-only", title: "Menu Bar Only", synonyms: "menubar menu bar dockless hide dock app switcher cmd-tab command-tab"), - .init(section: .app, id: "telemetry", title: "Send Anonymous Telemetry", synonyms: "analytics crash reports sentry posthog usage anonymous privacy"), - .init(section: .app, id: "warn-before-quit", title: "Warn Before Quitting", synonyms: "quit confirmation command-q cmd-q exit close app"), - .init(section: .app, id: "warn-before-closing-tab", title: "Warn Before Closing Tab", synonyms: "close tab confirmation command-w cmd-w terminal surface"), - .init(section: .app, id: "warn-before-closing-tab-x-button", title: "Warn Before Closing Tab via X Button", synonyms: "x button close tab confirmation terminal surface"), - .init(section: .app, id: "hide-tab-close-button", title: "Hide Tab Close Button", synonyms: "hide x button close tab terminal surface"), - .init(section: .app, id: "rename-selects-name", title: "Rename Selects Existing Name", synonyms: "rename select all existing title command palette workspace name"), - .init(section: .app, id: "palette-search-all", title: "Command Palette Searches All Surfaces", synonyms: "command palette search all surfaces cmd-p terminal browser markdown"), + .init(section: .app, id: "workspace-inherit-working-directory", title: "Inherit Workspace Working Directory", synonyms: "app.workspaceInheritWorkingDirectory workspace cwd directory inherit current focused working-directory"), + .init(section: .app, id: "minimal-mode", title: "Minimal Mode", synonyms: "app.minimalMode presentation compact chrome layout simple titlebar controls"), + .init(section: .app, id: "keep-workspace-open", title: "Keep Workspace Open When Closing Last Surface", synonyms: "app.keepWorkspaceOpenWhenClosingLastSurface close last pane surface keep tab workspace"), + .init(section: .app, id: "focus-pane-first-click", title: "Focus Pane on First Click", synonyms: "app.focusPaneOnFirstClick click to focus focus follows mouse first click mouse activation"), + .init(section: .app, id: "file-drops", title: "File Drops", synonyms: "drag drop files finder path text terminal editor split preview shift"), + .init(section: .app, id: "preferred-editor", title: "Open Files With", synonyms: "app.preferredEditor editor open file code vscode visual studio zed sublime subl cursor"), + .init(section: .app, id: "supported-file-previews", title: "Open Supported Files in cmux", synonyms: "app.openSupportedFilesInCmux cmd click file preview pdf image video audio quicklook quick look editor external"), + .init(section: .app, id: "markdown-viewer", title: "Open Markdown in cmux Viewer", synonyms: "app.openMarkdownInCmuxViewer md markdown mdx viewer preview readme"), + .init(section: .app, id: "terminal-config", title: "Terminal Config", synonyms: "ghostty config merged generated preview terminal configuration window open config"), + .init(section: .app, id: "imessage-mode", title: "iMessage Mode", synonyms: "app.iMessageMode imessage message messages chat prompt prompts submitted texting reorder move workspace top agent send"), + .init(section: .app, id: "reorder-notification", title: "Reorder on Notification", synonyms: "app.reorderOnNotification notification reorder move workspace top unread sort"), + .init(section: .app, id: "menu-bar-only", title: "Menu Bar Only", synonyms: "app.menuBarOnly menubar menu bar dockless hide dock app switcher cmd-tab command-tab"), + .init(section: .app, id: "telemetry", title: "Send anonymous telemetry", synonyms: "app.sendAnonymousTelemetry analytics crash reports sentry posthog usage anonymous privacy"), + .init(section: .app, id: "warn-before-quit", title: "Warn Before Quit", synonyms: "app.confirmQuit quit confirmation command-q cmd-q exit close app"), + .init(section: .app, id: "warn-before-closing-tab", title: "Warn Before Closing Tab", synonyms: "app.warnBeforeClosingTab close tab confirmation command-w cmd-w terminal surface"), + .init(section: .app, id: "warn-before-closing-tab-x-button", title: "Warn Before Tab Close Button", synonyms: "app.warnBeforeClosingTabXButton x button close tab confirmation terminal surface"), + .init(section: .app, id: "hide-tab-close-button", title: "Hide Tab Close Button", synonyms: "app.hideTabCloseButton hide x button close tab terminal surface"), + .init(section: .app, id: "rename-selects-name", title: "Rename Selects Existing Name", synonyms: "app.renameSelectsExistingName rename select all existing title command palette workspace name"), + .init(section: .app, id: "palette-search-all", title: "Command Palette Searches All Surfaces", synonyms: "app.commandPaletteSearchesAllSurfaces command palette search all surfaces cmd-p terminal browser markdown"), .init(section: .app, id: "dock-badge", title: "Dock Badge", synonyms: "notifications.dockBadge badge dock unread count icon notifications red bubble"), - .init(section: .app, id: "show-menu-bar", title: "Show Menu Bar Extra", synonyms: "menubar menu bar status item tray extra"), + .init(section: .app, id: "show-menu-bar", title: "Show in Menu Bar", synonyms: "notifications.showInMenuBar menubar menu bar status item tray extra"), .init(section: .app, id: "unread-pane-ring", title: "Unread Pane Ring", synonyms: "notifications.unreadPaneRing blue border unread ring notification pane outline"), .init(section: .app, id: "pane-flash", title: "Pane Flash", synonyms: "notifications.paneFlash flash blink highlight pane notification pulse"), .init(section: .app, id: "notification-sound", title: "Notification Sound", synonyms: "notifications.sound sound audio alert chime beep custom file wav mp3 caf aiff"), .init(section: .app, id: "notification-command", title: "Notification Command", synonyms: "notifications.command shell command hook script env environment variable done agent"), + .init(section: .app, id: "desktop-notifications", title: "Desktop Notifications", synonyms: "desktop notifications permission authorize enable alerts banners send test notification center"), // Terminal .init(section: .terminal, id: "scrollbar", title: "Show Terminal Scroll Bar", synonyms: "terminal.showScrollBar scrollback scrollbar scroll bar right edge alternate screen tui"), .init(section: .terminal, id: "copy-on-select", title: "Copy on Selection", synonyms: "terminal.copyOnSelect copy on selection select clipboard mouse double click triple click iterm"), .init(section: .terminal, id: "agent-auto-resume", title: "Resume Agent Sessions on Reopen", synonyms: "terminal.autoResumeAgentSessions auto resume restore reopen relaunch quit sessions agents claude code codex opencode rovo dev rovodev toggle"), - .init(section: .terminal, id: "agent-hibernation", title: "Agent Hibernation", synonyms: "terminal.agentHibernation idle hibernate suspend background agents claude code codex opencode live terminals"), - .init(section: .terminal, id: "resume-commands", title: "Resume Commands", synonyms: "surface resume command approvals prefixes auto restore prompt manual tmux hibernation"), + .init(section: .terminal, id: "agent-hibernation", title: "Agent Hibernation", synonyms: "terminal.agentHibernation.enabled idle hibernate suspend background agents claude code codex opencode live terminals"), + .init(section: .terminal, id: "agent-hibernation-idle", title: "Hibernate After Idle Seconds", synonyms: "terminal.agentHibernation.idleSeconds idle seconds timeout delay hibernate suspend"), + .init(section: .terminal, id: "agent-hibernation-max", title: "Max Live Agent Terminals", synonyms: "terminal.agentHibernation.maxLiveTerminals max live agent terminals limit count hibernate"), + .init(section: .terminal, id: "resume-commands", title: "Resume Commands", synonyms: "terminal.resumeCommands surface resume command approvals prefixes auto restore prompt manual tmux hibernation"), // TextBox .init(section: .textBox, id: "show-textbox-new-terminals", title: "Show TextBox on New Terminals", synonyms: "terminal.showTextBoxOnNewTerminals show textbox text box rich input prompt default new terminal workspace split tab beta"), @@ -75,7 +79,7 @@ extension Array where Element == CuratedSettingEntry { .init(section: .sidebarAppearance, id: "hide-sidebar-details", title: "Hide All Sidebar Details", synonyms: "sidebar.hideAllDetails compact sidebar hide details only title minimal left rail"), .init(section: .sidebarAppearance, id: "wrap-workspace-titles", title: "Wrap Workspace Titles in Sidebar", synonyms: "sidebar.wrapWorkspaceTitles workspace title wrap multiline pr pull request"), .init(section: .sidebarAppearance, id: "show-workspace-description", title: "Show Workspace Description in Sidebar", synonyms: "sidebar.showWorkspaceDescription workspace description notes markdown sidebar"), - .init(section: .sidebarAppearance, id: "sidebar-branch-layout", title: "Sidebar Branch Layout", synonyms: "sidebar.branchVerticalLayout git branch layout vertical inline cwd directory"), + .init(section: .sidebarAppearance, id: "sidebar-branch-layout", title: "Sidebar Branch Layout", synonyms: "sidebar.branchLayout sidebar.branchVerticalLayout git branch layout vertical inline cwd directory"), .init(section: .sidebarAppearance, id: "stack-branch-directory", title: "Stack Branch and Directory", synonyms: "sidebar.stackBranchDirectory git branch directory cwd path stack stacked separate lines two rows"), .init(section: .sidebarAppearance, id: "path-last-segment-only", title: "Truncate Path From Start", synonyms: "sidebar.pathLastSegmentOnly cwd path directory last segment basename short truncate folder repo"), .init(section: .sidebarAppearance, id: "show-notification-message", title: "Show Notification Message in Sidebar", synonyms: "sidebar.showNotificationMessage latest message unread notification text sidebar"), @@ -92,35 +96,35 @@ extension Array where Element == CuratedSettingEntry { .init(section: .sidebarAppearance, id: "show-metadata", title: "Show Custom Metadata in Sidebar", synonyms: "sidebar.showCustomMetadata metadata meta report_meta status custom block"), // Beta - .init(section: .betaFeatures, id: "dock", title: "Right-Sidebar Dock (Beta)", synonyms: "dock right sidebar terminal controls tui beta unstable"), + .init(section: .betaFeatures, id: "dock", title: "Dock", synonyms: "dock right sidebar terminal controls tui beta unstable"), // Automation .init(section: .automation, id: "socket-mode", title: "Socket Control Mode", synonyms: "automation.socketControlMode api socket unix domain control server auth allow password disabled"), - .init(section: .automation, id: "socket-password", title: "Socket Password", synonyms: "automation.socketPassword auth token credential secret password access key"), .init(section: .automation, id: "port-base", title: "Port Base", synonyms: "automation.portBase cmux_port start first base env environment variable"), .init(section: .automation, id: "port-range", title: "Port Range Size", synonyms: "automation.portRange cmux_port_end range size count env ports"), // Browser - .init(section: .browser, id: "enable-browser", title: "Disable cmux Browser", synonyms: "browser.disabled enable disable webview embedded browser tabs links"), - .init(section: .browser, id: "search-engine", title: "Default Search Engine", synonyms: "browser.defaultSearchEngine omnibar address bar google duckduckgo bing kagi brave startpage perplexity exa yahoo ecosia qwant mojeek wikipedia github baidu yandex custom search provider"), + .init(section: .browser, id: "enable-browser", title: "Enable cmux Browser", synonyms: "browser.disabled enable disable webview embedded browser tabs links"), + .init(section: .browser, id: "search-engine", title: "Default Search Engine", synonyms: "browser.defaultSearchEngine omnibar address bar google duckduckgo bing kagi brave startpage perplexity exa yahoo ecosia qwant mojeek wikipedia github baidu yandex custom search provider engine name url template"), .init(section: .browser, id: "search-suggestions", title: "Show Search Suggestions", synonyms: "browser.showSearchSuggestions suggest autocomplete address bar search suggestions"), .init(section: .browser, id: "theme", title: "Browser Theme", synonyms: "browser.theme web page theme color scheme light dark system"), - .init(section: .browser, id: "hidden-webview-discard", title: "Discard Hidden Browser WebViews", synonyms: "browser.discardHiddenWebViews memory hidden tabs webview discard unload reclaim"), - .init(section: .browser, id: "hidden-webview-discard-delay", title: "Hidden WebView Discard Delay", synonyms: "browser.hiddenWebViewDiscardDelaySeconds memory hidden tabs delay seconds discard unload"), + .init(section: .browser, id: "hidden-webview-discard", title: "Browser Memory Saver", synonyms: "browser.discardHiddenWebViews memory hidden tabs webview discard unload reclaim"), + .init(section: .browser, id: "hidden-webview-discard-delay", title: "Memory Saver Delay", synonyms: "browser.hiddenWebViewDiscardDelaySeconds memory hidden tabs delay seconds discard unload"), .init(section: .browser, id: "terminal-links", title: "Open Terminal Links in cmux Browser", synonyms: "browser.openTerminalLinksInCmuxBrowser click url terminal links open in browser href"), .init(section: .browser, id: "intercept-open", title: "Intercept open http(s) in Terminal", synonyms: "browser.interceptTerminalOpenCommandInCmuxBrowser open command http https url terminal intercept"), .init(section: .browser, id: "host-whitelist", title: "Hosts to Open in Embedded Browser", synonyms: "browser.hostsToOpenInEmbeddedBrowser allowlist whitelist host wildcard domain embedded browser"), .init(section: .browser, id: "external-patterns", title: "URLs to Always Open Externally", synonyms: "browser.urlsToAlwaysOpenExternally denylist blocklist regex rules external default browser"), .init(section: .browser, id: "http-allowlist", title: "HTTP Hosts Allowed in Embedded Browser", synonyms: "browser.insecureHttpHostsAllowedInEmbeddedBrowser insecure http allowlist localhost localtest non-https warning"), .init(section: .browser, id: "react-grab", title: "React Grab Version", synonyms: "browser.reactGrabVersion react grab npm version toolbar cmd-shift-g inspect component"), + .init(section: .browser, id: "history", title: "Browsing History", synonyms: "browsing history clear visited pages omnibar suggestions delete"), // Browser import .init(section: .browserImport, id: "import-data", title: "Import Browser Data", synonyms: "chrome safari firefox brave edge arc bookmarks history cookies profiles migration"), - .init(section: .browserImport, id: "import-hint", title: "Show Import Hint on Blank Tabs", synonyms: "browser.showImportHintOnBlankTabs blank tab onboarding hint import prompt dismiss"), + .init(section: .browserImport, id: "import-hint", title: "Show import hint on blank browser tabs", synonyms: "browser.showImportHintOnBlankTabs blank tab onboarding hint import prompt dismiss"), // Global hotkey .init(section: .globalHotkey, id: "enable-hotkey", title: "Enable System-Wide Hotkey", synonyms: "app.systemWideHotkeyEnabled global hotkey enable system wide show hide all windows"), - .init(section: .globalHotkey, id: "shortcut", title: "Global Hotkey", synonyms: "global hotkey shortcut recorder key command option control"), + .init(section: .globalHotkey, id: "shortcut", title: "Show/Hide All Windows", synonyms: "global hotkey shortcut recorder key command option control"), // Keyboard shortcuts .init(section: .keyboardShortcuts, id: "shortcuts", title: "Keyboard Shortcuts", synonyms: "shortcuts.bindings hotkeys keybindings key bindings commands keyboard accelerators chords cmux json"), @@ -130,11 +134,12 @@ extension Array where Element == CuratedSettingEntry { // Workspace colors .init(section: .workspaceColors, id: "indicator", title: "Workspace Color Indicator", synonyms: "workspaceColors.indicatorStyle tab indicator active workspace style color stripe dot"), .init(section: .workspaceColors, id: "selection", title: "Selection Highlight", synonyms: "workspaceColors.selectionColor selected workspace color highlight background active tab"), - .init(section: .workspaceColors, id: "badge", title: "Notification Badge Color", synonyms: "workspaceColors.notificationBadgeColor unread notification badge color dot count"), + .init(section: .workspaceColors, id: "badge", title: "Notification Badge", synonyms: "workspaceColors.notificationBadgeColor unread notification badge color dot count"), + .init(section: .workspaceColors, id: "palette", title: "Reset Palette", synonyms: "reset palette named colors restore built-in custom remove default"), // cmux.json - .init(section: .settingsJSON, id: "open-file", title: "Open Config File", synonyms: "open config file json jsonc config editor ~/.config cmux preferences"), - .init(section: .settingsJSON, id: "documentation", title: "Configuration Documentation", synonyms: "docs documentation schema reference cmux json keys configuration"), + .init(section: .settingsJSON, id: "open-file", title: "User config file", synonyms: "open config file json jsonc config editor ~/.config cmux preferences"), + .init(section: .settingsJSON, id: "documentation", title: "Documentation", synonyms: "docs documentation schema reference cmux json keys configuration"), // Reset .init(section: .reset, id: "reset-all", title: "Reset All Settings", synonyms: "factory reset restore defaults clear preferences"), diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSearchHighlight.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSearchHighlight.swift new file mode 100644 index 0000000000..ee837e26ac --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSearchHighlight.swift @@ -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:` 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) + } + } + } + + /// 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 + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSearchIndex.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSearchIndex.swift index 3ba1af6c25..4c66c48571 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSearchIndex.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSearchIndex.swift @@ -4,19 +4,21 @@ import Foundation /// Fuzzy-match index over ``SettingsSectionID`` titles and the curated /// per-setting entries in ``CuratedSettingEntries``. /// -/// Three classes of entries are indexed: +/// Two classes of entries are indexed: /// /// 1. Section entries — one per ``SettingsSectionID`` case — surfaced /// in the sidebar by default (empty query). /// 2. Curated setting entries from ``CuratedSettingEntries/entries`` — -/// one per high-signal setting, with the user-facing localized -/// title plus a synonym string mined from the legacy index. This is -/// what makes search useful: typing "copy on select" finds the +/// one per setting *that has a row in the detail pane*, with the +/// user-facing localized title plus a synonym string. This is what +/// makes search useful: typing "copy on select" finds the /// `terminal.copyOnSelect` row even though that's an internal id. -/// 3. Fallback dotted-id entries built from ``SettingCatalog/all`` for -/// catalog keys that are *not* covered by the curated table. They -/// only match on the dotted id, which is a usable last-resort path -/// for power users who know the underlying key. +/// +/// The index deliberately does **not** back-fill raw ``SettingCatalog`` +/// keys. Catalog keys without a curated entry have no UI row, so +/// surfacing them as search hits (e.g. `account.welcomeShown`) would +/// navigate to nothing. Search results stay limited to what actually +/// appears in the settings detail. /// /// Diacritic-insensitive matching via /// `String.folding(options:locale:)`. Matching is per-token AND: every @@ -38,23 +40,35 @@ public struct SettingsSearchIndex: Sendable { public let entries: [Entry] - /// Builds an index from the section list, the supplied curated - /// entries, and any remaining ``SettingCatalog/all`` keys not - /// already covered by the curated table. + /// 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] + + /// Builds an index from the section list and the supplied curated + /// entries. Raw ``SettingCatalog`` keys are intentionally not + /// indexed (see the type doc): only settings with a real detail-pane + /// row are searchable. /// /// - Parameters: - /// - catalog: The settings catalog whose dotted-id keys back-fill - /// the index for entries not in ``curatedEntries``. - /// - curatedEntries: High-signal entries with curated titles + - /// synonyms. Defaults to ``Swift/Array/cmuxDefault`` — the - /// table the cmux app ships with. Tests pass an empty array - /// or a focused subset to exercise specific behavior; hosts - /// can append their own entries to the default to add new - /// searchable surfaces without forking. + /// - catalog: Accepted for API symmetry and possible future + /// scoping; the index is built only from sections and curated + /// entries, so passing the app's full catalog does not widen the + /// searchable surface. + /// - curatedEntries: One entry per searchable setting row, with a + /// localized title + synonyms. Defaults to + /// ``Swift/Array/cmuxDefault`` — the table the cmux app ships + /// with. Tests pass an empty array or a focused subset; hosts + /// can append their own entries to expose additional rows. public init( catalog: SettingCatalog, curatedEntries: [CuratedSettingEntry] = .cmuxDefault ) { + _ = catalog var built: [Entry] = [] for section in SettingsSectionID.allCases { @@ -81,25 +95,52 @@ public struct SettingsSearchIndex: Sendable { )) } - for key in catalog.all - where !Self.isCovered(by: curatedEntries, keyID: key.id) { - // Catalog keys that the curated table already covers - // surface there with their natural title; this back-fill - // is for keys nobody has written a curated entry for yet. - let parent = Self.inferParent(fromKeyID: key.id) ?? .app - built.append(Entry( - id: "setting:\(key.id)", - kind: .setting(parent: parent), - title: key.id, - symbolName: parent.symbolName, - normalizedSearchText: Self.normalize(key.id) - )) + self.entries = built + + // 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 + } - self.entries = built + /// Resolves a dotted cmux.json path to the curated entry id the + /// sidebar/search navigation scrolls to and highlights, so a row can + /// tag itself with the exact id its search hit posts. + /// + /// Returns `nil` when no curated entry claims `path`. Every settings + /// row's `configurationReview` path must resolve here, or its search + /// hit scrolls and pulses nothing — `SettingsRowAnchorResolutionTests` + /// enforces that across all rows. + /// + /// - Parameter path: A dotted cmux.json path, e.g. `terminal.copyOnSelect`. + /// - Returns: The curated entry id to use as a `scrollTo` / highlight + /// anchor, or `nil` when no curated entry owns `path`. + public func anchorID(forSettingsPath path: String) -> String? { + pathAnchorIDs[path] } public func match(_ query: String) -> [Entry] { + #if DEBUG + // Debug-only escape hatch: typing the sentinel surfaces *every* + // indexed entry (sections + settings) at once, so search/scroll/ + // highlight can be walked end to end by tapping each result. The + // raw query is compared before tokenization so the sentinel's + // punctuation isn't stripped. Compiled out of Release builds. + if Self.normalize(query).trimmingCharacters(in: .whitespacesAndNewlines) == Self.debugShowAllQuery { + return entries + } + #endif let tokens = Self.tokens(in: query) if tokens.isEmpty { return entries.filter { if case .section = $0.kind { return true } else { return false } } @@ -109,6 +150,13 @@ public struct SettingsSearchIndex: Sendable { } } + #if DEBUG + /// Sentinel search query that, in DEBUG builds only, makes + /// ``match(_:)`` return every indexed entry so the full search → + /// scroll → highlight path can be exercised one row at a time. + static let debugShowAllQuery = ":all" + #endif + private static func normalize(_ text: String) -> String { text.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) } @@ -124,34 +172,4 @@ public struct SettingsSearchIndex: Sendable { .map(String.init) } - /// A curated entry covers a catalog key when the entry's synonym - /// string contains the catalog key's dotted id. Avoids surfacing - /// the same setting twice (once with a curated title, once with - /// the raw dotted id). - private static func isCovered( - by curatedEntries: [CuratedSettingEntry], - keyID: String - ) -> Bool { - for entry in curatedEntries where entry.synonyms.contains(keyID) { - return true - } - return false - } - - private static func inferParent(fromKeyID id: String) -> SettingsSectionID? { - guard let prefix = id.split(separator: ".").first else { return nil } - switch String(prefix) { - case "app": return .app - case "terminal": return .terminal - case "sidebar", "sidebarAppearance": return .sidebarAppearance - case "workspaceColors": return .workspaceColors - case "automation": return .automation - case "browser": return .browser - case "notifications": return .app - case "shortcuts": return .keyboardShortcuts - case "integrations": return .account - case "rightSidebar", "betaFeatures": return .betaFeatures - default: return nil - } - } } diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift index 2b77de7242..2bbaf27fe3 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift @@ -63,13 +63,21 @@ public struct SettingsWindowRoot: View { // Mirrors legacy SettingsView.settingsNavigationGeneration. When // multiple navigation requests fire in quick succession (e.g. the // sidebar selection changes plus an external app.cmux.settings - // navigation post), each scheduled `proxy.scrollTo(...)` runs on - // the next main-queue tick. Without a generation guard, a stale - // earlier request can win and snap the scroll back to a section - // the user has already moved past. The counter is incremented in - // `applyScrollNavigation` and re-checked inside `DispatchQueue.main.async`, + // navigation post), each `proxy.scrollTo(...)` runs one main-actor + // hop later. Without a generation guard, a stale earlier request can + // win and snap the scroll back to a section the user has already + // moved past. The counter is incremented in `applyScrollNavigation` + // and re-checked inside the scheduled `Task { @MainActor in ... }`, // 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 searchHighlight = SettingsSearchHighlightState(anchorID: nil, token: 0, startedAt: nil) private var defaultsStore: UserDefaultsSettingsStore { runtime.userDefaultsStore } private var jsonStore: JSONConfigStore { runtime.jsonStore } @@ -117,6 +125,11 @@ 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, searchHighlight) // 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. @@ -307,15 +320,15 @@ public struct SettingsWindowRoot: View { GeometryReader { _ in ScrollViewReader { proxy in ScrollView { - // LazyVStack so the ~12 settings sections build their - // bodies on demand as they scroll into view instead of - // eagerly on first render. The eager plain VStack made - // opening the window and the first scroll janky because - // every section (and its nested cards/controls) was - // instantiated up front. Each section keeps its - // `.id(anchorID(for:))` so `proxy.scrollTo(...)` from - // the sidebar/search navigation still resolves anchors. - LazyVStack(alignment: .leading, spacing: 14) { + // Eager VStack (not LazyVStack) on purpose: search + // navigation must `scrollTo` any row, including ones in + // a section currently off-screen. A LazyVStack only + // registers a row's `.id` once its section is realized, + // so `scrollTo(deepRow)` silently no-ops while that + // section is scrolled away, stranding the user at the + // top. Building all ~14 sections up front keeps every + // anchor addressable for a single, reliable scroll. + VStack(alignment: .leading, spacing: 14) { sectionStack } // Legacy SettingsView only pads the inner VStack; it @@ -365,7 +378,7 @@ public struct SettingsWindowRoot: View { /// A monotonically increasing `settingsNavigationGeneration` /// guards against stale scrolls when navigation requests pile up: /// each call captures the current generation, increments it, and - /// the dispatched scroll only runs if the captured generation is + /// the scheduled scroll only runs if the captured generation is /// still the latest — otherwise an earlier request would clobber /// the user's most recent navigation. private func applyScrollNavigation(_ notification: Notification, proxy: ScrollViewProxy) { @@ -378,12 +391,36 @@ public struct SettingsWindowRoot: View { let sectionID = self.anchorID(for: target) settingsNavigationGeneration += 1 let navigationGeneration = settingsNavigationGeneration - DispatchQueue.main.async { + // Arm (or clear) the highlight before the scroll so the pulse is + // already live when the target lands in view. A section hit + // (anchorID == sectionID) highlights the section header; a row + // hit highlights that row. Mirrors legacy applySettingsNavigation. + if shouldHighlight { + searchHighlight = SettingsSearchHighlightState( + anchorID: anchorID, + token: searchHighlight.token + 1, + startedAt: Date() + ) + } else { + searchHighlight = SettingsSearchHighlightState( + anchorID: nil, + token: searchHighlight.token, + startedAt: nil + ) + } + // One scroll, one target. The detail stack is eager (see + // `detailScroll`), so every row's `.id` is always registered and a + // single `scrollTo` resolves any anchor regardless of where the + // viewport currently sits — no "realize the section first" dance. + // A section hit pins its header to the top; a row hit centers the + // row. The hop off the current update is a main-actor `Task` (not + // `DispatchQueue.main.async`, which package policy forbids): it + // lets the highlight-state mutation above commit before the scroll + // and is generation-guarded so a newer navigation still wins. + let anchor: UnitPoint = anchorID == sectionID ? .top : .center + Task { @MainActor in guard navigationGeneration == settingsNavigationGeneration else { return } - proxy.scrollTo(sectionID, anchor: .top) - if shouldHighlight && anchorID != sectionID { - proxy.scrollTo(anchorID, anchor: .center) - } + proxy.scrollTo(anchorID, anchor: anchor) } } diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AccountSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AccountSection.swift index 77a5ff0630..860a6ed2f0 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AccountSection.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AccountSection.swift @@ -26,10 +26,11 @@ public struct AccountSection: View { public var body: some View { Group { - SettingsSectionHeader(String(localized: "settings.section.account", defaultValue: "Account")) + SettingsSectionHeader(String(localized: "settings.section.account", defaultValue: "Account"), section: .account) SettingsCard { AccountIdentityCard(flow: accountFlow) } + .settingsSearchAnchors(["setting:account:account"]) } } } diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift index a8e845c600..31decb1a2c 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift @@ -109,7 +109,7 @@ public struct AppSection: View { public var body: some View { Group { - SettingsSectionHeader(String(localized: "settings.section.app", defaultValue: "App")) + SettingsSectionHeader(String(localized: "settings.section.app", defaultValue: "App"), section: .app) .accessibilityIdentifier("SettingsAppSection") mainCard } @@ -146,6 +146,7 @@ public struct AppSection: View { selectedMode: appearance.current, onSelect: { appearance.set($0) } ) + .settingsSearchAnchors(["setting:app:appearance"]) SettingsCardDivider() // App Icon — three-up visual picker mirroring legacy @@ -153,6 +154,7 @@ public struct AppSection: View { selectedMode: appIcon.current, onSelect: { appIcon.set($0) } ) + .settingsSearchAnchors(["setting:app:app-icon"]) SettingsCardDivider() // New Workspace Placement @@ -240,6 +242,7 @@ public struct AppSection: View { // File Drops SettingsCardRow( configurationReview: .settingsOnly, + searchAnchorID: "setting:app:file-drops", String(localized: "settings.app.fileDrop.defaultBehavior", defaultValue: "File Drops"), subtitle: fileDropSubtitle(fileDrop.current), controlWidth: Self.columnWidth @@ -283,6 +286,7 @@ public struct AppSection: View { // Terminal Config (host action) SettingsCardRow( configurationReview: .action, + searchAnchorID: "setting:app:terminal-config", String(localized: "settings.app.configWindow", defaultValue: "Terminal Config"), subtitle: String(localized: "settings.app.configWindow.subtitle", defaultValue: "Open the cmux terminal config and generated preview in one utility window."), controlWidth: Self.columnWidth @@ -403,6 +407,7 @@ public struct AppSection: View { SettingsCardDivider() SettingsCardRow( configurationReview: .action, + searchAnchorID: "setting:app:desktop-notifications", String(localized: "settings.notifications.desktop", defaultValue: "Desktop Notifications"), subtitle: String(localized: "settings.notifications.desktop.subtitle.notDetermined", defaultValue: "Desktop notifications are not enabled yet.") ) { diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AutomationSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AutomationSection.swift index f9b3a85d6f..99ec65bab3 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AutomationSection.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AutomationSection.swift @@ -59,7 +59,7 @@ public struct AutomationSection: View { public var body: some View { Group { - SettingsSectionHeader(String(localized: "settings.section.automation", defaultValue: "Automation")) + SettingsSectionHeader(String(localized: "settings.section.automation", defaultValue: "Automation"), section: .automation) socketControlCard claudeCodeCard diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BetaFeaturesSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BetaFeaturesSection.swift index 70dd806304..bf0a17f523 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BetaFeaturesSection.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BetaFeaturesSection.swift @@ -14,7 +14,7 @@ public struct BetaFeaturesSection: View { public var body: some View { Group { - SettingsSectionHeader(String(localized: "settings.section.betaFeatures", defaultValue: "Beta Features")) + SettingsSectionHeader(String(localized: "settings.section.betaFeatures", defaultValue: "Beta Features"), section: .betaFeatures) SettingsCard { BetaFeaturesWarningNote( String(localized: "settings.betaFeatures.warning", defaultValue: "Dock is unstable and may change or break. Enable it only when you are testing it.") @@ -29,6 +29,7 @@ public struct BetaFeaturesSection: View { private var dockRow: some View { SettingsCardRow( configurationReview: .settingsOnly, + searchAnchorID: "setting:betaFeatures:dock", String(localized: "settings.betaFeatures.dock", defaultValue: "Dock"), subtitle: dock.current ? String(localized: "settings.betaFeatures.dock.subtitleOn", defaultValue: "Shows Dock in the right sidebar mode switcher for custom terminal controls.") diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift index 0188ac6a45..334e422a49 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift @@ -67,7 +67,7 @@ public struct BrowserSection: View { public var body: some View { Group { - SettingsSectionHeader(String(localized: "settings.section.browser", defaultValue: "Browser")) + SettingsSectionHeader(String(localized: "settings.section.browser", defaultValue: "Browser"), section: .browser) .accessibilityIdentifier("SettingsBrowserSection") mainCard } @@ -91,6 +91,7 @@ public struct BrowserSection: View { // Enable cmux Browser SettingsCardRow( configurationReview: .settingsOnly, + searchAnchorID: "setting:browser:enable-browser", String(localized: "settings.browser.enabled", defaultValue: "Enable cmux Browser"), subtitle: !disabled.current ? String(localized: "settings.browser.enabled.subtitleOn", defaultValue: "Browser tabs, terminal link clicks, and intercepted open commands can use the embedded browser.") @@ -256,6 +257,7 @@ public struct BrowserSection: View { // HTTP Hosts Allowed in Embedded Browser httpAllowlistRow(model: httpAllowlist) + .settingsSearchAnchors(["setting:browser:http-allowlist"]) SettingsCardDivider() @@ -267,6 +269,7 @@ public struct BrowserSection: View { onImport: { hostActions.openBrowserImportFlow() } ) .id(importAnchorID ?? "section:browserImport.inline") + .settingsSearchHighlight([importAnchorID, "setting:browserImport:import-data"].compactMap { $0 }) SettingsCardDivider() // React Grab Version @@ -290,6 +293,7 @@ public struct BrowserSection: View { SettingsCardDivider() SettingsCardRow( configurationReview: .action, + searchAnchorID: "setting:browser:history", String(localized: "settings.browser.history", defaultValue: "Browsing History"), subtitle: historySubtitle(count: historyCount) ) { @@ -456,6 +460,7 @@ public struct BrowserSection: View { ) .controlSize(.small) .accessibilityIdentifier("SettingsBrowserImportHintToggle") + .settingsSearchAnchors(["setting:browserImport:import-hint"]) Text(String(localized: "settings.browser.import.hint.settingsNote", defaultValue: "Shown until you import or dismiss it on a blank tab.")) .font(.caption) .foregroundStyle(.secondary) diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/GlobalHotkeySection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/GlobalHotkeySection.swift index 8d88597289..35d57a779d 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/GlobalHotkeySection.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/GlobalHotkeySection.swift @@ -36,7 +36,7 @@ public struct GlobalHotkeySection: View { public var body: some View { Group { - SettingsSectionHeader(String(localized: "settings.section.globalHotkey", defaultValue: "Global Hotkey")) + SettingsSectionHeader(String(localized: "settings.section.globalHotkey", defaultValue: "Global Hotkey"), section: .globalHotkey) .accessibilityIdentifier("SettingsGlobalHotkeySection") mainCard SettingsCardNote( @@ -53,6 +53,7 @@ public struct GlobalHotkeySection: View { SettingsCard { SettingsCardRow( configurationReview: .settingsOnly, + searchAnchorID: "setting:globalHotkey:enable-hotkey", String(localized: "settings.globalHotkey.enable", defaultValue: "Enable System-Wide Hotkey"), subtitle: enabled.current ? String(localized: "settings.globalHotkey.enable.subtitleOn", defaultValue: "Press the shortcut from any app to show or hide all cmux windows.") @@ -65,6 +66,7 @@ public struct GlobalHotkeySection: View { } SettingsCardDivider() recorderRow + .settingsSearchAnchors(["setting:globalHotkey:shortcut"]) } } diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift index af33c2f67d..650082d4db 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift @@ -41,7 +41,7 @@ public struct KeyboardShortcutsSection: View { public var body: some View { Group { - SettingsSectionHeader(String(localized: "settings.section.keyboardShortcuts", defaultValue: "Keyboard Shortcuts")) + SettingsSectionHeader(String(localized: "settings.section.keyboardShortcuts", defaultValue: "Keyboard Shortcuts"), section: .keyboardShortcuts) .accessibilityIdentifier("SettingsKeyboardShortcutsSection") SettingsCard { chordsRow @@ -54,14 +54,25 @@ public struct KeyboardShortcutsSection: View { // sit next to the unread navigation actions — matches // legacy `KeyboardShortcutSettings.settingsVisibleActions` // / `orderedSettingsVisibleActions`. + // ~166 recorder rows, each AppKit-backed — the one heavy + // list in Settings. The detail stack is eager (so every + // search anchor stays scroll-addressable), which would + // otherwise build all of these on window open and cost + // ~2s. These per-shortcut rows aren't search anchors (only + // the enclosing card is), so a LazyVStack here defers them + // until the section scrolls into view without affecting any + // scroll/highlight target. let actions = Self.settingsVisibleActions - ForEach(Array(actions.enumerated()), id: \.element) { index, action in - actionRow(action) - if index < actions.count - 1 { - SettingsCardDivider() + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(Array(actions.enumerated()), id: \.element) { index, action in + actionRow(action) + if index < actions.count - 1 { + SettingsCardDivider() + } } } } + .settingsSearchAnchors(["setting:keyboardShortcuts:shortcuts"]) Text(String(localized: "settings.shortcuts.recordHint", defaultValue: "Click a shortcut value to record. Use X to unbind; it changes to restore after a clear.")) .font(.caption) .foregroundColor(.secondary) @@ -76,6 +87,7 @@ public struct KeyboardShortcutsSection: View { private var chordsRow: some View { SettingsCardRow( configurationReview: .action, + searchAnchorID: "setting:keyboardShortcuts:shortcut-chords", String(localized: "settings.shortcuts.chords", defaultValue: "Shortcut Chords"), subtitle: String(localized: "settings.shortcuts.chords.subtitle", defaultValue: "Add tmux-style multi-step shortcuts in cmux.json, for example [\"ctrl+b\", \"c\"].") ) { @@ -101,6 +113,7 @@ public struct KeyboardShortcutsSection: View { private var resetDefaultsRow: some View { SettingsCardRow( configurationReview: .settingsOnly, + searchAnchorID: "setting:keyboardShortcuts:reset-defaults", String(localized: "settings.shortcuts.resetDefaults", defaultValue: "Reset Default Shortcuts"), subtitle: String(localized: "settings.shortcuts.resetDefaults.subtitle", defaultValue: "Restore built-in shortcut values for shortcuts managed in app settings.") ) { diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ResetSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ResetSection.swift index 2459d8dcfc..8267b0db5a 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ResetSection.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ResetSection.swift @@ -19,7 +19,7 @@ public struct ResetSection: View { public var body: some View { Group { - SettingsSectionHeader(String(localized: "settings.section.reset", defaultValue: "Reset")) + SettingsSectionHeader(String(localized: "settings.section.reset", defaultValue: "Reset"), section: .reset) SettingsCard { HStack { Spacer(minLength: 0) @@ -33,6 +33,7 @@ public struct ResetSection: View { .padding(.horizontal, 14) .padding(.vertical, 10) } + .settingsSearchAnchors(["setting:reset:reset-all"]) } } diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SettingsJSONSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SettingsJSONSection.swift index 5d5a27ea1b..3f7fabbb25 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SettingsJSONSection.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SettingsJSONSection.swift @@ -16,7 +16,7 @@ public struct SettingsJSONSection: View { public var body: some View { Group { - SettingsSectionHeader(String(localized: "settings.section.settingsJSON", defaultValue: "cmux.json")) + SettingsSectionHeader(String(localized: "settings.section.settingsJSON", defaultValue: "cmux.json"), section: .settingsJSON) .accessibilityIdentifier("SettingsJSONSection") SettingsCard { userConfigFileRow @@ -30,6 +30,7 @@ public struct SettingsJSONSection: View { private var userConfigFileRow: some View { SettingsCardRow( configurationReview: .action, + searchAnchorID: "setting:settingsJSON:open-file", String(localized: "settings.settingsJSON.file", defaultValue: "User config file"), subtitle: String(localized: "settings.settingsJSON.file.subtitle", defaultValue: "Edit cmux-owned app settings, shortcuts, automation, sidebar, notifications, and browser behavior."), controlWidth: 330 @@ -57,6 +58,7 @@ public struct SettingsJSONSection: View { private var documentationRow: some View { SettingsCardRow( configurationReview: .action, + searchAnchorID: "setting:settingsJSON:documentation", String(localized: "settings.settingsJSON.documentation", defaultValue: "Documentation"), subtitle: String(localized: "settings.settingsJSON.documentation.subtitle", defaultValue: "View supported keys, file locations, schema, and reload behavior.") ) { diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection.swift index fb1e3b41d3..a6aae9ee60 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection.swift @@ -55,7 +55,7 @@ public struct SidebarSection: View { public var body: some View { Group { - SettingsSectionHeader(String(localized: "settings.section.sidebarAppearance", defaultValue: "Sidebar")) + SettingsSectionHeader(String(localized: "settings.section.sidebarAppearance", defaultValue: "Sidebar"), section: .sidebarAppearance) mainCard } } diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift index a84348d81a..59ab2a8958 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift @@ -37,7 +37,7 @@ public struct TerminalSection: View { public var body: some View { Group { - SettingsSectionHeader(String(localized: "settings.section.terminal", defaultValue: "Terminal")) + SettingsSectionHeader(String(localized: "settings.section.terminal", defaultValue: "Terminal"), section: .terminal) mainCard resumeCommandsCard } diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TextBoxSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TextBoxSection.swift index 113e182625..7e807cbc79 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TextBoxSection.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TextBoxSection.swift @@ -21,7 +21,7 @@ public struct TextBoxSection: View { public var body: some View { Group { - SettingsSectionHeader(String(localized: "settings.section.textBox", defaultValue: "TextBox (Beta)")) + SettingsSectionHeader(String(localized: "settings.section.textBox", defaultValue: "TextBox (Beta)"), section: .textBox) SettingsCard { TextBoxBetaWarningNote( String(localized: "settings.textBox.betaWarning", defaultValue: "TextBox is a beta feature. Its defaults and behavior may change while it is being tested.") diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/WorkspaceColorsSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/WorkspaceColorsSection.swift index f3a82c9450..322511779c 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/WorkspaceColorsSection.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/WorkspaceColorsSection.swift @@ -58,7 +58,7 @@ public struct WorkspaceColorsSection: View { public var body: some View { Group { - SettingsSectionHeader(String(localized: "settings.section.workspaceColors", defaultValue: "Workspace Colors")) + SettingsSectionHeader(String(localized: "settings.section.workspaceColors", defaultValue: "Workspace Colors"), section: .workspaceColors) mainCard } } @@ -118,6 +118,7 @@ public struct WorkspaceColorsSection: View { SettingsCardRow( configurationReview: .action, + searchAnchorID: "setting:workspaceColors:palette", String(localized: "settings.workspaceColors.resetPalette", defaultValue: "Reset Palette"), subtitle: String(localized: "settings.workspaceColors.resetPalette.subtitleV2", defaultValue: "Restore the built-in palette and remove extra named colors.") ) { diff --git a/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift b/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift new file mode 100644 index 0000000000..b45d574eb7 --- /dev/null +++ b/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift @@ -0,0 +1,212 @@ +import CmuxSettings +import Testing +@testable import CmuxSettingsUI + +/// Guards the search-result highlight bridge in both directions: +/// +/// - **Forward** — every cmux.json path a `SettingsCardRow` declares via +/// `configurationReview` resolves to a real indexed entry, so the row +/// carries the same `.id` its search hit posts. +/// - **Inverse** — every curated search result (the things that appear +/// in the sidebar search list) is reachable: some row anchor equals +/// its entry id, so clicking it scrolls to and highlights a row. +/// +/// `rowConfigPaths` mirrors the `.json(...)` annotations across +/// `Sections/*.swift`. `explicitlyAnchoredEntryIDs` lists the searchable +/// rows that carry an explicit `settingsSearchAnchors([...])` instead of +/// a cmux.json path (pickers and action buttons that don't write a +/// single key). Together they must cover every curated setting entry. +@Suite("SettingsRowAnchorResolution") +struct SettingsRowAnchorResolutionTests { + /// Every singular cmux.json path declared by an *unconditionally + /// rendered* settings row. Excludes rows that aren't standalone search + /// results: `workspaceColors.colors` (repeated per-palette rows) and + /// the conditional sub-fields that are hidden in the default state — + /// `automation.socketPassword` (only in password mode), + /// `browser.customSearchEngineName` / `customSearchEngineURLTemplate` + /// (only when the engine is Custom). Those would be dead search hits + /// when hidden, so their search terms fold into the always-visible + /// parent (Socket Control Mode / Default Search Engine) instead. + /// + /// This is a deliberately hand-maintained contract list: the rows live + /// in SwiftUI view bodies (`Sections/*.swift`) that can't be reflected + /// from a test, so there's no `Mirror`-style seam to derive it. Adding + /// a settings row means adding its path here. Two companion tests bound + /// the drift this can't catch on its own: ``everyCuratedSettingEntryIsReachable`` + /// fails if a curated search result has no backing anchor, and + /// ``rowAnchorsAreUniqueAcrossRows`` fails if two rows collide on one id. + static let rowConfigPaths: [String] = [ + "app.commandPaletteSearchesAllSurfaces", + "app.confirmQuit", + "app.focusPaneOnFirstClick", + "app.hideTabCloseButton", + "app.iMessageMode", + "app.keepWorkspaceOpenWhenClosingLastSurface", + "app.language", + "app.menuBarOnly", + "app.minimalMode", + "app.newWorkspacePlacement", + "app.openMarkdownInCmuxViewer", + "app.openSupportedFilesInCmux", + "app.preferredEditor", + "app.renameSelectsExistingName", + "app.reorderOnNotification", + "app.sendAnonymousTelemetry", + "app.warnBeforeClosingTab", + "app.warnBeforeClosingTabXButton", + "app.workspaceInheritWorkingDirectory", + "automation.claudeBinaryPath", + "automation.claudeCodeIntegration", + "automation.cursorIntegration", + "automation.geminiIntegration", + "automation.portBase", + "automation.portRange", + "automation.ripgrepBinaryPath", + "automation.socketControlMode", + "automation.suppressSubagentNotifications", + "browser.defaultSearchEngine", + "browser.discardHiddenWebViews", + "browser.hiddenWebViewDiscardDelaySeconds", + "browser.hostsToOpenInEmbeddedBrowser", + "browser.interceptTerminalOpenCommandInCmuxBrowser", + "browser.openTerminalLinksInCmuxBrowser", + "browser.reactGrabVersion", + "browser.showSearchSuggestions", + "browser.theme", + "browser.urlsToAlwaysOpenExternally", + "notifications.command", + "notifications.dockBadge", + "notifications.paneFlash", + "notifications.showInMenuBar", + "notifications.sound", + "notifications.unreadPaneRing", + "sidebar.branchLayout", + "sidebar.hideAllDetails", + "sidebar.makePullRequestsClickable", + "sidebar.openPortLinksInCmuxBrowser", + "sidebar.openPullRequestLinksInCmuxBrowser", + "sidebar.pathLastSegmentOnly", + "sidebar.showBranchDirectory", + "sidebar.showCustomMetadata", + "sidebar.showLog", + "sidebar.showNotificationMessage", + "sidebar.showPorts", + "sidebar.showProgress", + "sidebar.showPullRequests", + "sidebar.showSSH", + "sidebar.showWorkspaceDescription", + "sidebar.stackBranchDirectory", + "sidebar.watchGitStatus", + "sidebar.wrapWorkspaceTitles", + "sidebarAppearance.matchTerminalBackground", + "terminal.agentHibernation.enabled", + "terminal.agentHibernation.idleSeconds", + "terminal.agentHibernation.maxLiveTerminals", + "terminal.autoResumeAgentSessions", + "terminal.copyOnSelect", + "terminal.resumeCommands", + "terminal.focusTextBoxOnNewTerminals", + "terminal.showScrollBar", + "terminal.showTextBoxOnNewTerminals", + "terminal.textBoxMaxLines", + "workspaceColors.indicatorStyle", + "workspaceColors.notificationBadgeColor", + "workspaceColors.selectionColor", + ] + + /// Searchable rows anchored with an explicit `settingsSearchAnchors` + /// (no single cmux.json path): pickers and action buttons. Each must + /// match the corresponding curated entry id verbatim. + static let explicitlyAnchoredEntryIDs: Set = [ + "setting:app:appearance", + "setting:app:app-icon", + "setting:app:file-drops", + "setting:app:terminal-config", + "setting:app:desktop-notifications", + "setting:account:account", + "setting:betaFeatures:dock", + "setting:browser:history", + "setting:browser:http-allowlist", + "setting:workspaceColors:palette", + "setting:browser:enable-browser", + "setting:browserImport:import-data", + "setting:browserImport:import-hint", + "setting:globalHotkey:enable-hotkey", + "setting:globalHotkey:shortcut", + "setting:keyboardShortcuts:shortcuts", + "setting:keyboardShortcuts:shortcut-chords", + "setting:keyboardShortcuts:reset-defaults", + "setting:settingsJSON:open-file", + "setting:settingsJSON:documentation", + "setting:reset:reset-all", + ] + + @Test(arguments: rowConfigPaths) + func everyRowPathResolvesToAnIndexedEntry(path: String) throws { + let index = SettingsSearchIndex(catalog: SettingCatalog()) + let anchor = try #require( + index.anchorID(forSettingsPath: path), + "no anchor for row path \(path) — its search hit won't scroll/highlight" + ) + #expect( + index.entries.contains { $0.id == anchor }, + "anchor \(anchor) for \(path) is not a real indexed entry" + ) + } + + /// The user-facing contract: every result in the sidebar search list + /// can be scrolled to and highlighted. A curated setting entry is + /// reachable when some row anchor equals its id — either a row whose + /// `configurationReview` path resolves to it, or a row explicitly + /// tagged with its id. + @Test + func everyCuratedSettingEntryIsReachable() { + let index = SettingsSearchIndex(catalog: SettingCatalog()) + let pathBackedIDs = Set(Self.rowConfigPaths.compactMap { index.anchorID(forSettingsPath: $0) }) + let reachable = pathBackedIDs.union(Self.explicitlyAnchoredEntryIDs) + + let unreachable = index.entries + .filter { if case .setting = $0.kind { return true } else { return false } } + .map(\.id) + .filter { !reachable.contains($0) } + + #expect( + unreachable.isEmpty, + "these search results have no row to scroll to / highlight: \(unreachable.sorted())" + ) + } + + /// No two distinct rows may resolve to the same anchor id. Each entry + /// in ``rowConfigPaths`` is one row's primary path; if two resolve to + /// the same id, both rows get that `.id`, making `proxy.scrollTo` + /// ambiguous and the highlight land on the wrong/multiple rows. This + /// guards the class of bug where a curated entry's synonyms carried + /// several sub-paths (e.g. agentHibernation.enabled/idleSeconds/...) + /// so every sub-row collided on one anchor. + /// The DEBUG `:all` sentinel surfaces every indexed entry so the + /// full search → scroll → highlight path can be walked row by row. + @Test + func debugSentinelReturnsEveryEntry() { + let index = SettingsSearchIndex(catalog: SettingCatalog()) + let all = index.match(":all") + #expect(all.count == index.entries.count) + #expect(Set(all.map(\.id)) == Set(index.entries.map(\.id))) + // A normal query is still filtered, so the sentinel isn't just + // "everything always". + #expect(index.match("copy on select").count < index.entries.count) + } + + @Test + func rowAnchorsAreUniqueAcrossRows() { + let index = SettingsSearchIndex(catalog: SettingCatalog()) + var firstPath: [String: String] = [:] + for path in Self.rowConfigPaths { + guard let anchor = index.anchorID(forSettingsPath: path) else { continue } + if let prior = firstPath[anchor] { + Issue.record("anchor \(anchor) is shared by rows '\(prior)' and '\(path)' — duplicate .id breaks scrollTo") + } else { + firstPath[anchor] = path + } + } + } +} diff --git a/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsSearchIndexTests.swift b/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsSearchIndexTests.swift index 6a0d0f5beb..8f51aa59e1 100644 --- a/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsSearchIndexTests.swift +++ b/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsSearchIndexTests.swift @@ -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) + } } diff --git a/Sources/App/SettingsWindowPresenter.swift b/Sources/App/SettingsWindowPresenter.swift index 2145756795..e4294b0839 100644 --- a/Sources/App/SettingsWindowPresenter.swift +++ b/Sources/App/SettingsWindowPresenter.swift @@ -133,11 +133,19 @@ enum SettingsWindowPresenter { #endif private static func existingWindow() -> NSWindow? { - if let settingsWindow, settingsWindow.isVisible || settingsWindow.isMiniaturized { + // Return the settings window whenever it still exists, even if it + // is currently ordered out (closed). SwiftUI's single `Window` + // scene does not destroy the window on close — it just hides it + // (isVisible == false) — and `openWindow(id:)` then no-ops because + // the scene still owns that window. So filtering by visibility here + // made every reopen-after-close fall through to a dead `openWindow` + // call and the window never came back. Reusing the hidden window + // lets `show()` re-front it via `makeKeyAndOrderFront`. + if let settingsWindow { return settingsWindow } return NSApp.windows.first { - $0.identifier?.rawValue == windowIdentifier && ($0.isVisible || $0.isMiniaturized) + $0.identifier?.rawValue == windowIdentifier } } diff --git a/vendor/bonsplit b/vendor/bonsplit index 72b91bd9de..9166c3639f 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 72b91bd9de10ecc08ecaf49b17a49c54583d2cae +Subproject commit 9166c3639fabb80bca050d57d7cbf3fe66248c2e