From a8543f1cdcb4fd56f30eea0fbd55f837457d47e5 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 12 Jun 2026 19:26:56 -0700 Subject: [PATCH 01/31] ContentView drain: lift command-palette domain into CmuxCommandPalette (stack E) Wave 2 of the ContentView god-file drain. Lifts the pure command-palette search/orchestration/value layer out of ContentView.swift and Sources/CommandPalette/ into a new CmuxCommandPalette domain package (no AppKit, fully testable). - New package CmuxCommandPalette (exemplar-shaped Package.swift: tools 6.0, macOS 14, v6 language mode, ExistentialAny/InternalImportsByDefault, test target). - ~22 nested CommandPalette* value/model types removed from ContentView.swift (CommandPaletteMode, PendingActivation, ResolvedActivation, RenameTarget, WorkspaceDescriptionTarget, etc.) and re-homed one-type-per-file under Values/Context/Handling/Orchestration/Policy/Search/. - CommandPaletteSearch/NucleoSearch/SearchOrchestrator moved verbatim from Sources/CommandPalette/ into the package; bodies byte-identical (machine-diffed vs base), only public/DocC added and grab-bag types split out. - Dropped the mergedSwiftFallbackMatchesForTests production shim; the real method is now internal and exercised directly by package tests (no test-only methods in source). - Nucleo dylib #filePath path walk adjusted for the deeper package location; nucleo FFI tests pass against it. - ContentView.swift 19265 -> ~18940 lines; ContentView+* palette extensions and app-side call sites import CmuxCommandPalette. 51 package tests pass (swift test). App compile + budget/lint gates run on rebase. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 6 + Packages/CmuxCommandPalette/Package.swift | 35 + .../CommandPaletteCommandsContext.swift | 12 + .../Context/CommandPaletteContextKeys.swift | 76 ++ .../CommandPaletteContextSnapshot.swift | 59 + .../CommandPaletteActionHandling.swift | 14 + .../CommandPaletteCommandContribution.swift | 44 + .../CommandPaletteHandlerRegistry.swift | 21 + .../CommandPaletteListScope.swift | 10 + .../CommandPaletteResolvedSearchMatch.swift | 19 + .../CommandPaletteSearchOrchestrator.swift | 89 +- .../CommandPaletteUsageEntry.swift | 15 + ...CommandPaletteOverlayPromotionPolicy.swift | 12 + .../Search/CommandPaletteFuzzyMatcher.swift | 569 ++------ .../CommandPaletteSearchCorpusEntry.swift | 57 + .../CommandPaletteSearchCorpusResult.swift | 15 + .../Search/CommandPaletteSearchEngine.swift | 238 ++++ .../CommandPaletteSwitcherSearchIndexer.swift | 124 ++ ...CommandPaletteSwitcherSearchMetadata.swift | 45 + .../CommandPaletteNucleoSearchIndex.swift | 105 ++ .../CommandPaletteNucleoSearchLibrary.swift | 125 +- .../CommandPaletteNucleoSearchResult.swift | 15 + .../Values/CommandPaletteCommand.swift | 52 + .../CommandPaletteInputFocusPolicy.swift | 24 + .../CommandPaletteInputFocusTarget.swift | 9 + .../Values/CommandPaletteMode.swift | 14 + .../CommandPalettePendingActivation.swift | 11 + ...ttePendingActivationResolutionResult.swift | 18 + .../Values/CommandPaletteRenameTarget.swift | 58 + .../CommandPaletteResolvedActivation.swift | 10 + .../Values/CommandPaletteSearchResult.swift | 22 + ...andPaletteSwitcherFingerprintContext.swift | 55 + ...andPaletteSwitcherFingerprintSurface.swift | 26 + ...dPaletteSwitcherFingerprintWorkspace.swift | 26 + .../CommandPaletteTextSelectionBehavior.swift | 9 + ...andPaletteWorkspaceDescriptionTarget.swift | 38 + ...ommandPaletteNucleoFFILibrarySupport.swift | 291 ++++ .../CommandPaletteNucleoFFITests.swift | 604 ++++++++ .../CommandPaletteNucleoFixtures.swift | 342 +++++ ...ndPaletteOverlayPromotionPolicyTests.swift | 12 + .../CommandPaletteSearchEngineTests.swift | 1216 +++++++++++++++++ .../App/TerminalDirectoryOpenSupport.swift | 10 + .../CommandPaletteSettingsToggle.swift | 1 + Sources/ContentView+AuthCommandPalette.swift | 1 + .../ContentView+MoveTabToNewWorkspace.swift | 1 + ...ntentView+RightSidebarCommandPalette.swift | 1 + Sources/ContentView+ViewCommandPalette.swift | 1 + Sources/ContentView.swift | 327 +---- .../ContentViewIdentifierCopyCommands.swift | 1 + Sources/TextBoxMentionCandidateIndex.swift | 1 + cmux.xcodeproj/project.pbxproj | 24 +- cmuxTests/WorkspaceManualUnreadTests.swift | 1 + 52 files changed, 3935 insertions(+), 976 deletions(-) create mode 100644 Packages/CmuxCommandPalette/Package.swift create mode 100644 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Context/CommandPaletteCommandsContext.swift create mode 100644 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Context/CommandPaletteContextKeys.swift create mode 100644 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Context/CommandPaletteContextSnapshot.swift create mode 100644 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Handling/CommandPaletteActionHandling.swift create mode 100644 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Handling/CommandPaletteCommandContribution.swift create mode 100644 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Handling/CommandPaletteHandlerRegistry.swift create mode 100644 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Orchestration/CommandPaletteListScope.swift create mode 100644 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Orchestration/CommandPaletteResolvedSearchMatch.swift rename {Sources/CommandPalette => Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Orchestration}/CommandPaletteSearchOrchestrator.swift (85%) create mode 100644 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Orchestration/CommandPaletteUsageEntry.swift create mode 100644 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Policy/CommandPaletteOverlayPromotionPolicy.swift rename Sources/CommandPalette/CommandPaletteSearch.swift => Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteFuzzyMatcher.swift (65%) create mode 100644 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchCorpusEntry.swift create mode 100644 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchCorpusResult.swift create mode 100644 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchEngine.swift create mode 100644 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSwitcherSearchIndexer.swift create mode 100644 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSwitcherSearchMetadata.swift create mode 100644 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/Nucleo/CommandPaletteNucleoSearchIndex.swift rename Sources/CommandPalette/CommandPaletteNucleoSearch.swift => Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/Nucleo/CommandPaletteNucleoSearchLibrary.swift (72%) create mode 100644 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/Nucleo/CommandPaletteNucleoSearchResult.swift create mode 100644 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteCommand.swift create mode 100644 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteInputFocusPolicy.swift create mode 100644 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteInputFocusTarget.swift create mode 100644 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteMode.swift create mode 100644 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPalettePendingActivation.swift create mode 100644 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPalettePendingActivationResolutionResult.swift create mode 100644 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteRenameTarget.swift create mode 100644 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteResolvedActivation.swift create mode 100644 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteSearchResult.swift create mode 100644 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteSwitcherFingerprintContext.swift create mode 100644 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteSwitcherFingerprintSurface.swift create mode 100644 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteSwitcherFingerprintWorkspace.swift create mode 100644 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteTextSelectionBehavior.swift create mode 100644 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteWorkspaceDescriptionTarget.swift create mode 100644 Packages/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteNucleoFFILibrarySupport.swift create mode 100644 Packages/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteNucleoFFITests.swift create mode 100644 Packages/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteNucleoFixtures.swift create mode 100644 Packages/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteOverlayPromotionPolicyTests.swift create mode 100644 Packages/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteSearchEngineTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a990f6624a5..8e6c6a61614 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -429,6 +429,11 @@ jobs: - name: Run Swift package unit tests run: | set -euo pipefail + # CmuxCommandPalette's nucleo FFI tests load the Rust dylib through + # CMUX_NUCLEO_FFI_LIB (they skip when it is absent, so build it here + # to keep the FFI parity suite a real gate). + cargo build --manifest-path Native/CommandPaletteNucleoFFI/Cargo.toml --release + export CMUX_NUCLEO_FFI_LIB="$PWD/Native/CommandPaletteNucleoFFI/target/release/libcmux_command_palette_nucleo_ffi.dylib" # The cmux-unit scheme only runs the cmuxTests app-host suite; it does # not execute the SPM package test targets. Run them here so package # tests (settings stores, secret-file migration, socket-control @@ -439,6 +444,7 @@ jobs: PACKAGES=( CMUXAuthCore CmuxAuthRuntime + CmuxCommandPalette CmuxControlSocket CmuxFileWatch CmuxFoundation diff --git a/Packages/CmuxCommandPalette/Package.swift b/Packages/CmuxCommandPalette/Package.swift new file mode 100644 index 00000000000..c990ede534b --- /dev/null +++ b/Packages/CmuxCommandPalette/Package.swift @@ -0,0 +1,35 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "CmuxCommandPalette", + platforms: [ + .macOS(.v14), + ], + products: [ + .library( + name: "CmuxCommandPalette", + targets: ["CmuxCommandPalette"] + ), + ], + targets: [ + .target( + name: "CmuxCommandPalette", + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + .testTarget( + name: "CmuxCommandPaletteTests", + dependencies: ["CmuxCommandPalette"], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + ] +) diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Context/CommandPaletteCommandsContext.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Context/CommandPaletteCommandsContext.swift new file mode 100644 index 00000000000..07b051a4b7f --- /dev/null +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Context/CommandPaletteCommandsContext.swift @@ -0,0 +1,12 @@ +import Foundation + +/// Context handed to command-list builders: the gating snapshot. +public struct CommandPaletteCommandsContext { + /// The context snapshot commands are gated on. + public let snapshot: CommandPaletteContextSnapshot + + /// Creates a commands context. + public init(snapshot: CommandPaletteContextSnapshot) { + self.snapshot = snapshot + } +} diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Context/CommandPaletteContextKeys.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Context/CommandPaletteContextKeys.swift new file mode 100644 index 00000000000..9006b761f07 --- /dev/null +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Context/CommandPaletteContextKeys.swift @@ -0,0 +1,76 @@ +import Foundation + +/// String keys for ``CommandPaletteContextSnapshot`` lookups. +public enum CommandPaletteContextKeys { + /// Whether a workspace is selected. + public static let hasWorkspace = "workspace.hasSelection" + /// Selected workspace display name. + public static let workspaceName = "workspace.name" + /// Whether the workspace has a custom name. + public static let workspaceHasCustomName = "workspace.hasCustomName" + /// Whether the workspace has a custom description. + public static let workspaceHasCustomDescription = "workspace.hasCustomDescription" + /// Whether minimal mode is enabled for the workspace. + public static let workspaceMinimalModeEnabled = "workspace.minimalModeEnabled" + /// Whether the workspace should offer pinning. + public static let workspaceShouldPin = "workspace.shouldPin" + /// Whether the workspace has pull requests. + public static let workspaceHasPullRequests = "workspace.hasPullRequests" + /// Whether the workspace has splits. + public static let workspaceHasSplits = "workspace.hasSplits" + /// Whether the workspace has sibling workspaces. + public static let workspaceHasPeers = "workspace.hasPeers" + /// Whether a workspace exists above the selection. + public static let workspaceHasAbove = "workspace.hasAbove" + /// Whether a workspace exists below the selection. + public static let workspaceHasBelow = "workspace.hasBelow" + /// Whether mark-read is available for the workspace. + public static let workspaceCanMarkRead = "workspace.canMarkRead" + /// Whether mark-unread is available for the workspace. + public static let workspaceCanMarkUnread = "workspace.canMarkUnread" + /// Whether the sidebar matches the terminal background. + public static let sidebarMatchTerminalBackground = "sidebar.matchTerminalBackground" + /// Whether a panel has focus. + public static let hasFocusedPanel = "panel.hasFocus" + /// Focused panel display name. + public static let panelName = "panel.name" + /// Whether the focused panel is a browser. + public static let panelIsBrowser = "panel.isBrowser" + /// Whether browser focus mode is active. + public static let panelBrowserFocusModeActive = "panel.browserFocusModeActive" + /// Whether the browser omnibar is visible. + public static let panelBrowserOmnibarVisible = "panel.browser.omnibarVisible" + /// Whether the focused panel is markdown. + public static let panelIsMarkdown = "panel.isMarkdown" + /// Whether the focused panel is a terminal. + public static let panelIsTerminal = "panel.isTerminal" + /// Whether the focused panel sits in a pane. + public static let panelHasPane = "panel.hasPane" + /// Whether the focused panel hosts a forkable agent. + public static let panelHasForkableAgent = "panel.hasForkableAgent" + /// Whether the focused panel has a custom name. + public static let panelHasCustomName = "panel.hasCustomName" + /// Whether the focused panel should offer pinning. + public static let panelShouldPin = "panel.shouldPin" + /// Whether the focused panel has unread state. + public static let panelHasUnread = "panel.hasUnread" + /// Whether the focused panel can move to a new workspace. + public static let panelCanMoveToNewWorkspace = "panel.canMoveToNewWorkspace" + /// Whether an app update is available. + public static let updateHasAvailable = "update.hasAvailable" + /// Whether the cmux CLI is installed in PATH. + public static let cliInstalledInPATH = "cli.installedInPATH" + /// Whether cmux is the default terminal. + public static let defaultTerminalIsDefault = "defaultTerminal.isDefault" + /// Whether the browser surface is disabled. + public static let browserDisabled = "browser.disabled" + /// Whether the user is signed in. + public static let authSignedIn = "auth.signedIn" + /// Whether an auth operation is in flight. + public static let authWorking = "auth.working" + /// Key for one terminal open-target's availability; `rawValue` is the + /// target's raw identifier (the app layers a typed overload on top). + public static func terminalOpenTargetAvailable(rawValue: String) -> String { + "terminal.openTarget.\(rawValue).available" + } +} diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Context/CommandPaletteContextSnapshot.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Context/CommandPaletteContextSnapshot.swift new file mode 100644 index 00000000000..61296a6b476 --- /dev/null +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Context/CommandPaletteContextSnapshot.swift @@ -0,0 +1,59 @@ +import Foundation + +/// Immutable snapshot of the bool/string context values that gate which +/// palette commands are visible and enabled. +public struct CommandPaletteContextSnapshot { + private var boolValues: [String: Bool] = [:] + private var stringValues: [String: String] = [:] + + /// Creates an empty snapshot. + public init() {} + + /// Sets a boolean context value. + public mutating func setBool(_ key: String, _ value: Bool) { + boolValues[key] = value + } + + /// Sets a string context value; nil or empty removes the key. + public mutating func setString(_ key: String, _ value: String?) { + guard let value, !value.isEmpty else { + stringValues.removeValue(forKey: key) + return + } + stringValues[key] = value + } + + /// Reads a boolean context value (false when absent). + public func bool(_ key: String) -> Bool { + boolValues[key] ?? false + } + + /// Reads a string context value. + public func string(_ key: String) -> String? { + stringValues[key] + } + + /// Order-insensitive fingerprint over all context values, used to detect + /// when the command list must be rebuilt. Hash values are only compared + /// within the current process. + public func fingerprint() -> Int { + Self.fingerprint(boolValues: boolValues, stringValues: stringValues) + } + + /// Fingerprints raw bool/string context dictionaries. + public static func fingerprint( + boolValues: [String: Bool], + stringValues: [String: String] + ) -> Int { + var hasher = Hasher() + for key in boolValues.keys.sorted() { + hasher.combine(key) + hasher.combine(boolValues[key] ?? false) + } + for key in stringValues.keys.sorted() { + hasher.combine(key) + hasher.combine(stringValues[key] ?? "") + } + return hasher.finalize() + } +} diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Handling/CommandPaletteActionHandling.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Handling/CommandPaletteActionHandling.swift new file mode 100644 index 00000000000..ade7780f7a7 --- /dev/null +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Handling/CommandPaletteActionHandling.swift @@ -0,0 +1,14 @@ +import Foundation + +/// Host seam through which palette command handlers are registered and +/// resolved. The host (the app's composition root) conforms, the palette +/// domain depends only on this protocol, so the package never reaches back +/// into app types. +@MainActor +public protocol CommandPaletteActionHandling { + /// Registers `handler` as the action for `commandId`. + func register(commandId: String, handler: @escaping () -> Void) + + /// The action registered for `commandId`, when any. + func handler(for commandId: String) -> (() -> Void)? +} diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Handling/CommandPaletteCommandContribution.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Handling/CommandPaletteCommandContribution.swift new file mode 100644 index 00000000000..f2bfc7a2829 --- /dev/null +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Handling/CommandPaletteCommandContribution.swift @@ -0,0 +1,44 @@ +import Foundation + +/// Declarative descriptor for one palette command: identity, context-derived +/// display strings, and the `when`/`enablement` gates. The runnable handler +/// is registered separately through ``CommandPaletteActionHandling``. +public struct CommandPaletteCommandContribution { + /// Stable command identifier. + public let commandId: String + /// Title derived from the context snapshot. + public let title: (CommandPaletteContextSnapshot) -> String + /// Subtitle derived from the context snapshot. + public let subtitle: (CommandPaletteContextSnapshot) -> String + /// Optional keyboard-shortcut hint. + public let shortcutHint: String? + /// Additional search keywords. + public let keywords: [String] + /// Whether activating the command dismisses the palette. + public let dismissOnRun: Bool + /// Whether the command appears at all in this context. + public let when: (CommandPaletteContextSnapshot) -> Bool + /// Whether the command is enabled in this context. + public let enablement: (CommandPaletteContextSnapshot) -> Bool + + /// Creates a contribution; `when` and `enablement` default to always-true. + public init( + commandId: String, + title: @escaping (CommandPaletteContextSnapshot) -> String, + subtitle: @escaping (CommandPaletteContextSnapshot) -> String, + shortcutHint: String? = nil, + keywords: [String] = [], + dismissOnRun: Bool = true, + when: @escaping (CommandPaletteContextSnapshot) -> Bool = { _ in true }, + enablement: @escaping (CommandPaletteContextSnapshot) -> Bool = { _ in true } + ) { + self.commandId = commandId + self.title = title + self.subtitle = subtitle + self.shortcutHint = shortcutHint + self.keywords = keywords + self.dismissOnRun = dismissOnRun + self.when = when + self.enablement = enablement + } +} diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Handling/CommandPaletteHandlerRegistry.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Handling/CommandPaletteHandlerRegistry.swift new file mode 100644 index 00000000000..7d2240aff75 --- /dev/null +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Handling/CommandPaletteHandlerRegistry.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Maps command identifiers to their runnable handlers. The palette resolves +/// activations through this registry so command declarations +/// (``CommandPaletteCommandContribution``) stay separate from host behavior. +public struct CommandPaletteHandlerRegistry { + private var handlers: [String: () -> Void] = [:] + + /// Creates an empty registry. + public init() {} + + /// Registers `handler` for `commandId`, replacing any existing handler. + public mutating func register(commandId: String, handler: @escaping () -> Void) { + handlers[commandId] = handler + } + + /// The handler registered for `commandId`, when any. + public func handler(for commandId: String) -> (() -> Void)? { + handlers[commandId] + } +} diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Orchestration/CommandPaletteListScope.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Orchestration/CommandPaletteListScope.swift new file mode 100644 index 00000000000..fb0dae51bf7 --- /dev/null +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Orchestration/CommandPaletteListScope.swift @@ -0,0 +1,10 @@ +import Foundation + +/// Which list the palette is showing: the `>`-prefixed command list or the +/// workspace/surface switcher. +public enum CommandPaletteListScope: String, Sendable { + /// The command list (query prefixed with `>`). + case commands + /// The workspace/surface switcher list. + case switcher +} diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Orchestration/CommandPaletteResolvedSearchMatch.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Orchestration/CommandPaletteResolvedSearchMatch.swift new file mode 100644 index 00000000000..fa8a09d96e0 --- /dev/null +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Orchestration/CommandPaletteResolvedSearchMatch.swift @@ -0,0 +1,19 @@ +import Foundation + +/// One resolved match produced by ``CommandPaletteSearchOrchestrator``, +/// merged across the nucleo and Swift engines. +public struct CommandPaletteResolvedSearchMatch: Sendable { + /// The matched command's identifier. + public let commandID: String + /// Final merged score. + public let score: Int + /// Title character indices to highlight. + public let titleMatchIndices: Set + + /// Creates a resolved match. + public init(commandID: String, score: Int, titleMatchIndices: Set) { + self.commandID = commandID + self.score = score + self.titleMatchIndices = titleMatchIndices + } +} diff --git a/Sources/CommandPalette/CommandPaletteSearchOrchestrator.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Orchestration/CommandPaletteSearchOrchestrator.swift similarity index 85% rename from Sources/CommandPalette/CommandPaletteSearchOrchestrator.swift rename to Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Orchestration/CommandPaletteSearchOrchestrator.swift index f35e93452b5..054dca3f6d8 100644 --- a/Sources/CommandPalette/CommandPaletteSearchOrchestrator.swift +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Orchestration/CommandPaletteSearchOrchestrator.swift @@ -1,26 +1,14 @@ -import Foundation +public import Foundation -enum CommandPaletteListScope: String, Sendable { - case commands - case switcher -} - -struct CommandPaletteUsageEntry: Codable, Sendable { - var useCount: Int - var lastUsedAt: TimeInterval -} - -struct CommandPaletteResolvedSearchMatch: Sendable { - let commandID: String - let score: Int - let titleMatchIndices: Set -} - -enum CommandPaletteSearchOrchestrator { +/// Orchestrates one palette search across both engines: prefers the nucleo +/// FFI index when available, falls back to the Swift engine, and merges in +/// Swift single-edit (typo) matches that nucleo cannot produce. +public enum CommandPaletteSearchOrchestrator { private static let synchronousSeedCorpusLimit = 256 private static let singleEditFallbackNucleoProbeLimit = 12 - static func firstValueDictionary( + /// Keys `values` by `key`, keeping the first element per key. + public static func firstValueDictionary( _ values: [Element], keyedBy key: (Element) -> Key ) -> [Key: Element] { @@ -32,7 +20,9 @@ enum CommandPaletteSearchOrchestrator { return dictionary } - static func resolvedSearchMatches( + /// Resolves matches for `query` over the corpus, merging nucleo results + /// with Swift single-edit fallback matches when needed. + public static func resolvedSearchMatches( searchIndex: CommandPaletteNucleoSearchIndex?, searchCorpus: [CommandPaletteSearchCorpusEntry], searchCorpusByID providedSearchCorpusByID: [String: CommandPaletteSearchCorpusEntry]? = nil, @@ -186,7 +176,8 @@ enum CommandPaletteSearchOrchestrator { } } - private static func mergedSwiftFallbackMatches( + /// Internal (not private) so package tests can exercise the merge directly. + static func mergedSwiftFallbackMatches( _ swiftMatches: [CommandPaletteResolvedSearchMatch], nucleoMatches: [CommandPaletteResolvedSearchMatch], searchCorpusByID: [String: CommandPaletteSearchCorpusEntry], @@ -242,21 +233,9 @@ enum CommandPaletteSearchOrchestrator { return lhs.commandID < rhs.commandID } - static func mergedSwiftFallbackMatchesForTests( - _ swiftMatches: [CommandPaletteResolvedSearchMatch], - nucleoMatches: [CommandPaletteResolvedSearchMatch], - searchCorpusByID: [String: CommandPaletteSearchCorpusEntry], - limit: Int - ) -> [CommandPaletteResolvedSearchMatch] { - mergedSwiftFallbackMatches( - swiftMatches, - nucleoMatches: nucleoMatches, - searchCorpusByID: searchCorpusByID, - limit: limit - ) - } - - static func previewSearchMatches( + /// Resolves preview matches: full-corpus search for the commands scope, + /// candidate-restricted Swift search for the switcher scope. + public static func previewSearchMatches( scope: CommandPaletteListScope, searchIndex: CommandPaletteNucleoSearchIndex?, searchCorpus: [CommandPaletteSearchCorpusEntry], @@ -312,30 +291,8 @@ enum CommandPaletteSearchOrchestrator { ) } - static func commandPreviewMatchCommandIDsForTests( - searchCorpus: [CommandPaletteSearchCorpusEntry], - searchIndex: CommandPaletteNucleoSearchIndex?, - candidateCommandIDs: [String], - searchCorpusByID: [String: CommandPaletteSearchCorpusEntry], - query: String, - resultLimit: Int - ) -> [String] { - let preparedQuery = CommandPaletteFuzzyMatcher.preparedQuery(query) - return previewSearchMatches( - scope: .commands, - searchIndex: searchIndex, - searchCorpus: searchCorpus, - candidateCommandIDs: candidateCommandIDs, - searchCorpusByID: searchCorpusByID, - query: query, - usageHistory: [:], - queryIsEmpty: preparedQuery.isEmpty, - historyTimestamp: 0, - resultLimit: resultLimit - ).map(\.commandID) - } - - static func previewCandidateCommandIDs( + /// Truncates `resultIDs` to `limit` preview candidates. + public static func previewCandidateCommandIDs( resultIDs: [String], limit: Int ) -> [String] { @@ -344,7 +301,9 @@ enum CommandPaletteSearchOrchestrator { return Array(resultIDs.prefix(limit)) } - static func shouldSynchronouslySeedResults( + /// Whether opening the palette should seed results synchronously instead + /// of waiting for the async search task. + public static func shouldSynchronouslySeedResults( hasVisibleResultsForScope: Bool, hasSearchIndex: Bool, corpusCount: Int @@ -352,7 +311,9 @@ enum CommandPaletteSearchOrchestrator { !hasVisibleResultsForScope && (hasSearchIndex || corpusCount <= synchronousSeedCorpusLimit) } - static func shouldPreserveEmptyStateWhileSearchPending( + /// Whether the visible empty state should be preserved while a search is + /// pending, to avoid flashing stale results. + public static func shouldPreserveEmptyStateWhileSearchPending( isSearchPending: Bool, visibleResultsScopeMatches: Bool, resolvedSearchScopeMatches: Bool, @@ -370,7 +331,9 @@ enum CommandPaletteSearchOrchestrator { return true } - static func historyBoost( + /// Recency/frequency boost for `commandId`; reduced to a third when the + /// query is non-empty. + public static func historyBoost( for commandId: String, queryIsEmpty: Bool, history: [String: CommandPaletteUsageEntry], diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Orchestration/CommandPaletteUsageEntry.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Orchestration/CommandPaletteUsageEntry.swift new file mode 100644 index 00000000000..ef6bcc2e876 --- /dev/null +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Orchestration/CommandPaletteUsageEntry.swift @@ -0,0 +1,15 @@ +public import Foundation + +/// Persisted per-command usage stats backing the recency/frequency boost. +public struct CommandPaletteUsageEntry: Codable, Sendable { + /// Total times the command was run. + public var useCount: Int + /// Unix timestamp of the most recent run. + public var lastUsedAt: TimeInterval + + /// Creates a usage entry. + public init(useCount: Int, lastUsedAt: TimeInterval) { + self.useCount = useCount + self.lastUsedAt = lastUsedAt + } +} diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Policy/CommandPaletteOverlayPromotionPolicy.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Policy/CommandPaletteOverlayPromotionPolicy.swift new file mode 100644 index 00000000000..29c09a6760b --- /dev/null +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Policy/CommandPaletteOverlayPromotionPolicy.swift @@ -0,0 +1,12 @@ +import Foundation + +/// Decides when the palette overlay container should be re-promoted above +/// sibling overlay views in the window's overlay container: exactly on the +/// hidden-to-visible transition, so an already-visible palette is not +/// reshuffled on every state update. +public enum CommandPaletteOverlayPromotionPolicy { + /// Whether the overlay should be promoted above its siblings. + public static func shouldPromote(previouslyVisible: Bool, isVisible: Bool) -> Bool { + isVisible && !previouslyVisible + } +} diff --git a/Sources/CommandPalette/CommandPaletteSearch.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteFuzzyMatcher.swift similarity index 65% rename from Sources/CommandPalette/CommandPaletteSearch.swift rename to Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteFuzzyMatcher.swift index 10c057b7d11..85168289106 100644 --- a/Sources/CommandPalette/CommandPaletteSearch.swift +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteFuzzyMatcher.swift @@ -1,157 +1,36 @@ import Foundation -struct CommandPaletteSwitcherSearchMetadata: Equatable, Sendable { - let directories: [String] - let branches: [String] - let ports: [Int] - let description: String? - - init( - directories: [String] = [], - branches: [String] = [], - ports: [Int] = [], - description: String? = nil - ) { - self.directories = directories - self.branches = branches - self.ports = ports - self.description = description - } -} -enum CommandPaletteSwitcherSearchIndexer { - enum MetadataDetail { - case workspace - case surface - } - - private static let metadataDelimiters = CharacterSet(charactersIn: "/\\.:_- ") - - static func keywords( - baseKeywords: [String], - metadata: CommandPaletteSwitcherSearchMetadata, - detail: MetadataDetail = .surface - ) -> [String] { - let metadataKeywords = metadataKeywordsForSearch(metadata, detail: detail) - return uniqueNormalizedPreservingOrder(baseKeywords + metadataKeywords) - } - - private static func metadataKeywordsForSearch( - _ metadata: CommandPaletteSwitcherSearchMetadata, - detail: MetadataDetail - ) -> [String] { - let directoryTokens = metadata.directories.flatMap { directoryTokensForSearch($0, detail: detail) } - let branchTokens = metadata.branches.flatMap { branchTokensForSearch($0, detail: detail) } - let portTokens = metadata.ports.flatMap(portTokensForSearch) - let descriptionTokens = descriptionTokensForSearch(metadata.description) - - var contextKeywords: [String] = [] - if !directoryTokens.isEmpty { - contextKeywords.append(contentsOf: ["directory", "dir", "cwd", "path"]) - } - if !branchTokens.isEmpty { - contextKeywords.append(contentsOf: ["branch", "git"]) - } - if !portTokens.isEmpty { - contextKeywords.append(contentsOf: ["port", "ports"]) - } - if !descriptionTokens.isEmpty { - contextKeywords.append(contentsOf: ["description", "descriptions", "notes", "note"]) - } - - return contextKeywords + directoryTokens + branchTokens + portTokens + descriptionTokens - } - - private static func directoryTokensForSearch( - _ rawDirectory: String, - detail: MetadataDetail - ) -> [String] { - let trimmed = rawDirectory.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return [] } - - let standardized = (trimmed as NSString).standardizingPath - let canonical = standardized.isEmpty ? trimmed : standardized - let abbreviated = (canonical as NSString).abbreviatingWithTildeInPath - switch detail { - case .workspace: - return uniqueNormalizedPreservingOrder([trimmed, canonical, abbreviated]) - case .surface: - let basename = URL(fileURLWithPath: canonical, isDirectory: true).lastPathComponent - let components = canonical.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } - return uniqueNormalizedPreservingOrder( - [trimmed, canonical, abbreviated, basename] + components - ) - } - } - - private static func branchTokensForSearch( - _ rawBranch: String, - detail: MetadataDetail - ) -> [String] { - let trimmed = rawBranch.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return [] } - switch detail { - case .workspace: - return [trimmed] - case .surface: - let components = trimmed.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } - return uniqueNormalizedPreservingOrder([trimmed] + components) - } - } - - private static func portTokensForSearch(_ port: Int) -> [String] { - guard (1...65535).contains(port) else { return [] } - let portText = String(port) - return [portText, ":\(portText)"] - } - - private static func descriptionTokensForSearch(_ rawDescription: String?) -> [String] { - let trimmed = rawDescription?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !trimmed.isEmpty else { return [] } - let normalizedWhitespace = trimmed.replacingOccurrences( - of: "\\s+", - with: " ", - options: .regularExpression - ) - let components = normalizedWhitespace.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } - return uniqueNormalizedPreservingOrder([trimmed, normalizedWhitespace] + components) - } - - private static func uniqueNormalizedPreservingOrder(_ values: [String]) -> [String] { - var result: [String] = [] - var seen: Set = [] - result.reserveCapacity(values.count) - - for value in values { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { continue } - let normalizedKey = trimmed - .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) - .lowercased() - guard seen.insert(normalizedKey).inserted else { continue } - result.append(trimmed) - } - return result - } -} - -enum CommandPaletteFuzzyMatcher { +/// The Swift fuzzy matcher behind command-palette ranking: token scoring over +/// word segments with exact/prefix/contains/initialism/stitched-prefix and +/// single-edit fallbacks. Pure logic; scores are deterministic for a given +/// query/candidate pair. +public enum CommandPaletteFuzzyMatcher { private static let tokenBoundaryChars: Set = [" ", "-", "_", "/", ".", ":"] - struct WordSegment: Hashable, Sendable { - let start: Int - let end: Int + /// Half-open `[start, end)` character range of one word in a candidate. + public struct WordSegment: Hashable, Sendable { + /// Index of the first character of the word. + public let start: Int + /// Index one past the last character of the word. + public let end: Int } - struct ASCIIScalarMask: Equatable, Sendable { - let low: UInt64 - let high: UInt64 + /// 128-bit presence mask of ASCII scalars used to cheaply prune + /// candidates that cannot contain a token's characters. + public struct ASCIIScalarMask: Equatable, Sendable { + /// Bits for scalars 0-63. + public let low: UInt64 + /// Bits for scalars 64-127. + public let high: UInt64 - init(low: UInt64, high: UInt64) { + /// Creates a mask from raw bit halves. + public init(low: UInt64, high: UInt64) { self.low = low self.high = high } - init(_ text: String) { + /// Builds the mask from the ASCII scalars of `text`. + public init(_ text: String) { var low: UInt64 = 0 var high: UInt64 = 0 for scalar in text.unicodeScalars where scalar.isASCII { @@ -166,21 +45,32 @@ enum CommandPaletteFuzzyMatcher { self.high = high } - func missingBitCount(from candidate: ASCIIScalarMask) -> Int { + /// Number of scalars present here but absent from `candidate`. + public func missingBitCount(from candidate: ASCIIScalarMask) -> Int { (low & ~candidate.low).nonzeroBitCount + (high & ~candidate.high).nonzeroBitCount } } - struct PreparedToken: Equatable, Sendable { - let normalizedText: String - let characters: [Character] - let asciiMask: ASCIIScalarMask - let allowsSingleEdit: Bool - let containsTokenBoundaryCharacter: Bool - let scoreUpperBound: Int - let scoreUpperBoundWithoutExactMatch: Int - - init(_ normalizedText: String) { + /// One normalized query token with precomputed characters, ASCII mask, + /// and score bounds. + public struct PreparedToken: Equatable, Sendable { + /// The normalized token text. + public let normalizedText: String + /// The token's characters in order. + public let characters: [Character] + /// ASCII presence mask for fast pruning. + public let asciiMask: ASCIIScalarMask + /// Whether single-edit (typo) fallback matching applies (length >= 4). + public let allowsSingleEdit: Bool + /// Whether the token contains a word-boundary character. + public let containsTokenBoundaryCharacter: Bool + /// Maximum achievable score for this token. + public let scoreUpperBound: Int + /// Maximum achievable score excluding an exact whole-text match. + public let scoreUpperBoundWithoutExactMatch: Int + + /// Prepares a normalized token for matching. + public init(_ normalizedText: String) { self.normalizedText = normalizedText self.characters = Array(normalizedText) self.asciiMask = ASCIIScalarMask(normalizedText) @@ -192,19 +82,28 @@ enum CommandPaletteFuzzyMatcher { self.scoreUpperBoundWithoutExactMatch = max(6799, 3500 + (characters.count * 300)) } - func couldMatch(_ candidate: PreparedCandidateText) -> Bool { + /// Fast pre-check: whether `candidate` could possibly match this token + /// within the allowed edit budget. + public func couldMatch(_ candidate: PreparedCandidateText) -> Bool { let missingCharacters = asciiMask.missingBitCount(from: candidate.asciiMask) return missingCharacters <= (allowsSingleEdit ? 1 : 0) } } - struct PreparedCandidateText: Sendable { - let normalizedText: String - let characters: [Character] - let wordSegments: [WordSegment] - let asciiMask: ASCIIScalarMask - - init(normalizedText: String) { + /// One normalized candidate string with precomputed characters, word + /// segments, and ASCII mask. + public struct PreparedCandidateText: Sendable { + /// The normalized candidate text. + public let normalizedText: String + /// The candidate's characters in order. + public let characters: [Character] + /// Word segments split on token-boundary characters. + public let wordSegments: [WordSegment] + /// ASCII presence mask for fast pruning. + public let asciiMask: ASCIIScalarMask + + /// Prepares a normalized candidate for matching. + public init(normalizedText: String) { self.normalizedText = normalizedText self.characters = Array(normalizedText) self.wordSegments = CommandPaletteFuzzyMatcher.wordSegments(characters) @@ -241,16 +140,21 @@ enum CommandPaletteFuzzyMatcher { let editKind: SingleEditWordPrefixEditKind } - struct PreparedQuery { - let normalizedText: String - let tokens: [PreparedToken] + /// A normalized query split into prepared tokens. + public struct PreparedQuery { + /// The full normalized query text. + public let normalizedText: String + /// The prepared tokens, in query order. + public let tokens: [PreparedToken] - var isEmpty: Bool { + /// Whether the query has no tokens. + public var isEmpty: Bool { tokens.isEmpty } } - static func preparedQuery(_ query: String) -> PreparedQuery { + /// Normalizes and tokenizes `query` for matching. + public static func preparedQuery(_ query: String) -> PreparedQuery { let normalizedQuery = normalizeForSearch(query) return PreparedQuery( normalizedText: normalizedQuery, @@ -262,29 +166,34 @@ enum CommandPaletteFuzzyMatcher { ) } - static func normalizeForSearch(_ text: String) -> String { + /// Canonical search normalization: trim, diacritic-fold, case-fold. + public static func normalizeForSearch(_ text: String) -> String { text .trimmingCharacters(in: .whitespacesAndNewlines) .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) .lowercased() } - static func prepareCandidateText(_ candidate: String) -> PreparedCandidateText? { + /// Normalizes and prepares `candidate`, or nil when it normalizes to empty. + public static func prepareCandidateText(_ candidate: String) -> PreparedCandidateText? { let normalizedCandidate = normalizeForSearch(candidate) guard !normalizedCandidate.isEmpty else { return nil } return PreparedCandidateText(normalizedText: normalizedCandidate) } - static func prepareNormalizedCandidateText(_ normalizedCandidate: String) -> PreparedCandidateText? { + /// Prepares an already-normalized candidate, or nil when empty. + public static func prepareNormalizedCandidateText(_ normalizedCandidate: String) -> PreparedCandidateText? { guard !normalizedCandidate.isEmpty else { return nil } return PreparedCandidateText(normalizedText: normalizedCandidate) } - static func score(query: String, candidate: String) -> Int? { + /// Scores `query` against a single candidate; nil when it does not match. + public static func score(query: String, candidate: String) -> Int? { score(query: query, candidates: [candidate]) } - static func score(query: String, candidates: [String]) -> Int? { + /// Scores `query` against multiple candidate texts; nil when any token fails. + public static func score(query: String, candidates: [String]) -> Int? { let preparedQuery = preparedQuery(query) var normalizedCandidates: [String] = [] normalizedCandidates.reserveCapacity(candidates.count) @@ -299,7 +208,8 @@ enum CommandPaletteFuzzyMatcher { ) } - static func score(preparedQuery: PreparedQuery, normalizedCandidates: [String]) -> Int? { + /// Scores a prepared query against normalized candidate texts. + public static func score(preparedQuery: PreparedQuery, normalizedCandidates: [String]) -> Int? { score( preparedQuery: preparedQuery, preparedCandidates: normalizedCandidates.compactMap(prepareNormalizedCandidateText), @@ -307,7 +217,8 @@ enum CommandPaletteFuzzyMatcher { ) } - static func score(preparedQuery: PreparedQuery, preparedCandidates: [PreparedCandidateText]) -> Int? { + /// Scores a prepared query against prepared candidates. + public static func score(preparedQuery: PreparedQuery, preparedCandidates: [PreparedCandidateText]) -> Int? { score( preparedQuery: preparedQuery, preparedCandidates: preparedCandidates, @@ -315,7 +226,8 @@ enum CommandPaletteFuzzyMatcher { ) } - static func score(preparedQuery: PreparedQuery, preparedCandidate: PreparedCandidateText) -> Int? { + /// Scores a prepared query against one prepared candidate. + public static func score(preparedQuery: PreparedQuery, preparedCandidate: PreparedCandidateText) -> Int? { guard !preparedQuery.isEmpty else { return 0 } var totalScore = 0 @@ -327,7 +239,8 @@ enum CommandPaletteFuzzyMatcher { return totalScore } - static func score( + /// Full scoring entry point with exact-text and prefix-score fast paths. + public static func score( preparedQuery: PreparedQuery, preparedCandidates: [PreparedCandidateText], exactCandidateTexts: Set?, @@ -379,7 +292,8 @@ enum CommandPaletteFuzzyMatcher { return bestScore } - static func wholeCandidatePrefixScoreByToken( + /// Precomputes best whole-candidate prefix scores keyed by prefix text. + public static func wholeCandidatePrefixScoreByToken( preparedCandidates: [PreparedCandidateText], maxPrefixLength: Int = 16 ) -> [String: Int] { @@ -399,18 +313,22 @@ enum CommandPaletteFuzzyMatcher { return scores } - static func matchCharacterIndices(query: String, candidate: String) -> Set { + /// Candidate character indices matched by `query`, for highlight rendering. + public static func matchCharacterIndices(query: String, candidate: String) -> Set { matchCharacterIndices(preparedQuery: preparedQuery(query), candidate: candidate) } - static func matchCharacterIndices(preparedQuery: PreparedQuery, candidate: String) -> Set { + /// Candidate character indices matched by a prepared query. + public static func matchCharacterIndices(preparedQuery: PreparedQuery, candidate: String) -> Set { guard !preparedQuery.isEmpty else { return [] } guard let preparedCandidate = prepareCandidateText(candidate) else { return [] } return matchCharacterIndices(preparedQuery: preparedQuery, preparedCandidate: preparedCandidate) } - static func matchCharacterIndices( + /// Candidate character indices matched by a prepared query against a + /// prepared candidate. + public static func matchCharacterIndices( preparedQuery: PreparedQuery, preparedCandidate: PreparedCandidateText ) -> Set { @@ -476,7 +394,8 @@ enum CommandPaletteFuzzyMatcher { return matched } - static func tokenCanMatchWithoutSingleEdit( + /// Whether `token` matches `candidate` through any non-typo strategy. + public static func tokenCanMatchWithoutSingleEdit( _ token: PreparedToken, preparedCandidate candidate: PreparedCandidateText ) -> Bool { @@ -512,7 +431,9 @@ enum CommandPaletteFuzzyMatcher { return false } - static func usesSingleEditWordPrefix( + /// Whether any token only matches these candidates via the single-edit + /// (typo) word-prefix fallback. + public static func usesSingleEditWordPrefix( preparedQuery: PreparedQuery, preparedCandidates: [PreparedCandidateText] ) -> Bool { @@ -1124,287 +1045,3 @@ enum CommandPaletteFuzzyMatcher { return matched } } - -struct CommandPaletteSearchCorpusEntry: Sendable where Payload: Sendable { - let payload: Payload - let rank: Int - let title: String - let preparedTitle: CommandPaletteFuzzyMatcher.PreparedCandidateText? - let preparedSearchableTexts: [CommandPaletteFuzzyMatcher.PreparedCandidateText] - let searchableTextSet: Set - let searchablePrefixScoreByToken: [String: Int] - let nucleoSearchText: String - - init(payload: Payload, rank: Int, title: String, searchableTexts: [String]) { - self.payload = payload - self.rank = rank - self.title = title - let normalizedTitle = CommandPaletteFuzzyMatcher.normalizeForSearch(title) - self.preparedTitle = CommandPaletteFuzzyMatcher.prepareNormalizedCandidateText(normalizedTitle) - - var nucleoSearchTexts: [String] = [] - var normalizedTexts: [String] = [] - var seen: Set = [] - normalizedTexts.reserveCapacity(searchableTexts.count) - nucleoSearchTexts.reserveCapacity(searchableTexts.count) - for text in searchableTexts { - let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmedText.isEmpty { - nucleoSearchTexts.append(trimmedText) - } - let normalizedText = CommandPaletteFuzzyMatcher.normalizeForSearch(text) - guard !normalizedText.isEmpty else { continue } - guard seen.insert(normalizedText).inserted else { continue } - normalizedTexts.append(normalizedText) - } - - let preparedSearchableTexts = normalizedTexts.compactMap( - CommandPaletteFuzzyMatcher.prepareNormalizedCandidateText - ) - self.preparedSearchableTexts = preparedSearchableTexts - self.searchableTextSet = Set(normalizedTexts) - self.searchablePrefixScoreByToken = CommandPaletteFuzzyMatcher.wholeCandidatePrefixScoreByToken( - preparedCandidates: preparedSearchableTexts - ) - self.nucleoSearchText = nucleoSearchTexts.joined(separator: "\n") - } -} - -struct CommandPaletteSearchCorpusResult: Sendable where Payload: Sendable { - let payload: Payload - let rank: Int - let title: String - let score: Int - let titleMatchIndices: Set -} - -enum CommandPaletteSearchEngine { - private static let titleMatchBonus = 2000 - - private struct ScoredEntry: Sendable where Payload: Sendable { - let entry: CommandPaletteSearchCorpusEntry - let index: Int - let score: Int - } - - private static func scoredEntryIsBetter( - _ lhs: ScoredEntry, - than rhs: ScoredEntry - ) -> Bool { - if lhs.score != rhs.score { return lhs.score > rhs.score } - if lhs.entry.rank != rhs.entry.rank { return lhs.entry.rank < rhs.entry.rank } - let titleComparison = lhs.entry.title.localizedCaseInsensitiveCompare(rhs.entry.title) - if titleComparison != .orderedSame { return titleComparison == .orderedAscending } - return lhs.index < rhs.index - } - - private static func scoredEntryIsWorse( - _ lhs: ScoredEntry, - than rhs: ScoredEntry - ) -> Bool { - scoredEntryIsBetter(rhs, than: lhs) - } - - private static func siftUpWorstScoredEntryHeap( - _ heap: inout [ScoredEntry], - from startIndex: Int - ) { - var child = startIndex - while child > 0 { - let parent = (child - 1) / 2 - guard scoredEntryIsWorse(heap[child], than: heap[parent]) else { break } - heap.swapAt(child, parent) - child = parent - } - } - - private static func siftDownWorstScoredEntryHeap( - _ heap: inout [ScoredEntry], - from startIndex: Int - ) { - var parent = startIndex - while true { - let leftChild = (parent * 2) + 1 - guard leftChild < heap.count else { return } - - let rightChild = leftChild + 1 - var worstChild = leftChild - if rightChild < heap.count, - scoredEntryIsWorse(heap[rightChild], than: heap[leftChild]) { - worstChild = rightChild - } - - guard scoredEntryIsWorse(heap[worstChild], than: heap[parent]) else { return } - heap.swapAt(parent, worstChild) - parent = worstChild - } - } - - private static func appendScoredEntry( - _ scoredEntry: ScoredEntry, - to scoredEntries: inout [ScoredEntry], - limit: Int? - ) { - guard let limit else { - scoredEntries.append(scoredEntry) - return - } - - if scoredEntries.count < limit { - scoredEntries.append(scoredEntry) - siftUpWorstScoredEntryHeap(&scoredEntries, from: scoredEntries.count - 1) - return - } - - guard let worstEntry = scoredEntries.first, - scoredEntryIsBetter(scoredEntry, than: worstEntry) else { - return - } - scoredEntries[0] = scoredEntry - siftDownWorstScoredEntryHeap(&scoredEntries, from: 0) - } - - static func search( - entries: [CommandPaletteSearchCorpusEntry], - query: String, - resultLimit: Int? = nil, - historyBoost: (Payload, Bool) -> Int - ) -> [CommandPaletteSearchCorpusResult] { - search( - entries: entries, - query: query, - resultLimit: resultLimit, - historyBoost: historyBoost, - shouldCancel: nil - ) - } - - static func search( - entries: [CommandPaletteSearchCorpusEntry], - query: String, - resultLimit: Int? = nil, - historyBoost: (Payload, Bool) -> Int, - shouldCancel: @escaping () -> Bool - ) -> [CommandPaletteSearchCorpusResult] { - search( - entries: entries, - query: query, - resultLimit: resultLimit, - historyBoost: historyBoost, - shouldCancel: Optional(shouldCancel) - ) - } - - private static func search( - entries: [CommandPaletteSearchCorpusEntry], - query: String, - resultLimit: Int?, - historyBoost: (Payload, Bool) -> Int, - shouldCancel: (() -> Bool)? - ) -> [CommandPaletteSearchCorpusResult] { - if let resultLimit, resultLimit <= 0 { - return [] - } - let preparedQuery = CommandPaletteFuzzyMatcher.preparedQuery(query) - let queryIsEmpty = preparedQuery.isEmpty - let limitedResultCount = resultLimit.map { min($0, entries.count) } - var scoredEntries: [ScoredEntry] = [] - scoredEntries.reserveCapacity(limitedResultCount ?? entries.count) - - func shouldCancelSearch(at index: Int) -> Bool { - guard let shouldCancel else { return false } - return index % 16 == 0 && shouldCancel() - } - - if queryIsEmpty { - for (index, entry) in entries.enumerated() { - if shouldCancelSearch(at: index) { return [] } - appendScoredEntry( - ScoredEntry( - entry: entry, - index: index, - score: historyBoost(entry.payload, true) - ), - to: &scoredEntries, - limit: limitedResultCount - ) - } - } else { - for (index, entry) in entries.enumerated() { - if shouldCancelSearch(at: index) { return [] } - guard let fuzzyScore = weightedScore( - preparedQuery: preparedQuery, - entry: entry - ) else { - continue - } - appendScoredEntry( - ScoredEntry( - entry: entry, - index: index, - score: fuzzyScore + historyBoost(entry.payload, false) - ), - to: &scoredEntries, - limit: limitedResultCount - ) - } - } - - if shouldCancel?() == true { return [] } - - scoredEntries.sort { scoredEntryIsBetter($0, than: $1) } - - let outputCount = resultLimit.map { min($0, scoredEntries.count) } ?? scoredEntries.count - var results: [CommandPaletteSearchCorpusResult] = [] - results.reserveCapacity(outputCount) - for index in 0.. - if queryIsEmpty { - titleMatchIndices = [] - } else { - titleMatchIndices = entry.preparedTitle.map { - CommandPaletteFuzzyMatcher.matchCharacterIndices( - preparedQuery: preparedQuery, - preparedCandidate: $0 - ) - } ?? [] - } - results.append( - CommandPaletteSearchCorpusResult( - payload: entry.payload, - rank: entry.rank, - title: entry.title, - score: scoredEntry.score, - titleMatchIndices: titleMatchIndices - ) - ) - } - return results - } - - private static func weightedScore( - preparedQuery: CommandPaletteFuzzyMatcher.PreparedQuery, - entry: CommandPaletteSearchCorpusEntry - ) -> Int? { - guard let fuzzyScore = CommandPaletteFuzzyMatcher.score( - preparedQuery: preparedQuery, - preparedCandidates: entry.preparedSearchableTexts, - exactCandidateTexts: entry.searchableTextSet, - wholeCandidatePrefixScoreByToken: entry.searchablePrefixScoreByToken - ) else { - return nil - } - if let preparedTitle = entry.preparedTitle, - preparedQuery.tokens.allSatisfy({ $0.couldMatch(preparedTitle) }), - let titleScore = CommandPaletteFuzzyMatcher.score( - preparedQuery: preparedQuery, - preparedCandidate: preparedTitle - ) { - return max(fuzzyScore, titleScore + titleMatchBonus) - } - return fuzzyScore - } -} diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchCorpusEntry.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchCorpusEntry.swift new file mode 100644 index 00000000000..526af36202f --- /dev/null +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchCorpusEntry.swift @@ -0,0 +1,57 @@ +import Foundation + +/// One searchable palette entry: a payload plus precomputed normalized +/// title/searchable texts, prefix scores, and the nucleo search blob. +public struct CommandPaletteSearchCorpusEntry: Sendable where Payload: Sendable { + /// Caller-supplied payload identifying the entry. + public let payload: Payload + /// Stable tie-break rank; lower wins. + public let rank: Int + /// Display title. + public let title: String + /// Prepared normalized title, or nil when the title normalizes to empty. + public let preparedTitle: CommandPaletteFuzzyMatcher.PreparedCandidateText? + /// Prepared normalized searchable texts (title, subtitle, keywords). + public let preparedSearchableTexts: [CommandPaletteFuzzyMatcher.PreparedCandidateText] + /// Set of normalized searchable texts for exact-match checks. + public let searchableTextSet: Set + /// Precomputed best prefix scores keyed by prefix text. + public let searchablePrefixScoreByToken: [String: Int] + /// Newline-joined trimmed searchable texts handed to the nucleo index. + public let nucleoSearchText: String + + /// Builds an entry, normalizing and preparing all searchable texts. + public init(payload: Payload, rank: Int, title: String, searchableTexts: [String]) { + self.payload = payload + self.rank = rank + self.title = title + let normalizedTitle = CommandPaletteFuzzyMatcher.normalizeForSearch(title) + self.preparedTitle = CommandPaletteFuzzyMatcher.prepareNormalizedCandidateText(normalizedTitle) + + var nucleoSearchTexts: [String] = [] + var normalizedTexts: [String] = [] + var seen: Set = [] + normalizedTexts.reserveCapacity(searchableTexts.count) + nucleoSearchTexts.reserveCapacity(searchableTexts.count) + for text in searchableTexts { + let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedText.isEmpty { + nucleoSearchTexts.append(trimmedText) + } + let normalizedText = CommandPaletteFuzzyMatcher.normalizeForSearch(text) + guard !normalizedText.isEmpty else { continue } + guard seen.insert(normalizedText).inserted else { continue } + normalizedTexts.append(normalizedText) + } + + let preparedSearchableTexts = normalizedTexts.compactMap( + CommandPaletteFuzzyMatcher.prepareNormalizedCandidateText + ) + self.preparedSearchableTexts = preparedSearchableTexts + self.searchableTextSet = Set(normalizedTexts) + self.searchablePrefixScoreByToken = CommandPaletteFuzzyMatcher.wholeCandidatePrefixScoreByToken( + preparedCandidates: preparedSearchableTexts + ) + self.nucleoSearchText = nucleoSearchTexts.joined(separator: "\n") + } +} diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchCorpusResult.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchCorpusResult.swift new file mode 100644 index 00000000000..f57391f608b --- /dev/null +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchCorpusResult.swift @@ -0,0 +1,15 @@ +import Foundation + +/// One scored search hit produced by ``CommandPaletteSearchEngine``. +public struct CommandPaletteSearchCorpusResult: Sendable where Payload: Sendable { + /// The matched entry's payload. + public let payload: Payload + /// The matched entry's rank. + public let rank: Int + /// The matched entry's title. + public let title: String + /// Final score including any history boost. + public let score: Int + /// Title character indices to highlight. + public let titleMatchIndices: Set +} diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchEngine.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchEngine.swift new file mode 100644 index 00000000000..8d341864f6e --- /dev/null +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchEngine.swift @@ -0,0 +1,238 @@ +import Foundation + +/// Pure Swift ranking engine over a prepared corpus: scores entries with +/// ``CommandPaletteFuzzyMatcher``, applies history boosts, and returns the +/// top results in deterministic order (score, rank, title, index). +public enum CommandPaletteSearchEngine { + private static let titleMatchBonus = 2000 + + private struct ScoredEntry: Sendable where Payload: Sendable { + let entry: CommandPaletteSearchCorpusEntry + let index: Int + let score: Int + } + + private static func scoredEntryIsBetter( + _ lhs: ScoredEntry, + than rhs: ScoredEntry + ) -> Bool { + if lhs.score != rhs.score { return lhs.score > rhs.score } + if lhs.entry.rank != rhs.entry.rank { return lhs.entry.rank < rhs.entry.rank } + let titleComparison = lhs.entry.title.localizedCaseInsensitiveCompare(rhs.entry.title) + if titleComparison != .orderedSame { return titleComparison == .orderedAscending } + return lhs.index < rhs.index + } + + private static func scoredEntryIsWorse( + _ lhs: ScoredEntry, + than rhs: ScoredEntry + ) -> Bool { + scoredEntryIsBetter(rhs, than: lhs) + } + + private static func siftUpWorstScoredEntryHeap( + _ heap: inout [ScoredEntry], + from startIndex: Int + ) { + var child = startIndex + while child > 0 { + let parent = (child - 1) / 2 + guard scoredEntryIsWorse(heap[child], than: heap[parent]) else { break } + heap.swapAt(child, parent) + child = parent + } + } + + private static func siftDownWorstScoredEntryHeap( + _ heap: inout [ScoredEntry], + from startIndex: Int + ) { + var parent = startIndex + while true { + let leftChild = (parent * 2) + 1 + guard leftChild < heap.count else { return } + + let rightChild = leftChild + 1 + var worstChild = leftChild + if rightChild < heap.count, + scoredEntryIsWorse(heap[rightChild], than: heap[leftChild]) { + worstChild = rightChild + } + + guard scoredEntryIsWorse(heap[worstChild], than: heap[parent]) else { return } + heap.swapAt(parent, worstChild) + parent = worstChild + } + } + + private static func appendScoredEntry( + _ scoredEntry: ScoredEntry, + to scoredEntries: inout [ScoredEntry], + limit: Int? + ) { + guard let limit else { + scoredEntries.append(scoredEntry) + return + } + + if scoredEntries.count < limit { + scoredEntries.append(scoredEntry) + siftUpWorstScoredEntryHeap(&scoredEntries, from: scoredEntries.count - 1) + return + } + + guard let worstEntry = scoredEntries.first, + scoredEntryIsBetter(scoredEntry, than: worstEntry) else { + return + } + scoredEntries[0] = scoredEntry + siftDownWorstScoredEntryHeap(&scoredEntries, from: 0) + } + + /// Searches `entries` for `query`, boosting each payload by + /// `historyBoost(payload, queryIsEmpty)`. + public static func search( + entries: [CommandPaletteSearchCorpusEntry], + query: String, + resultLimit: Int? = nil, + historyBoost: (Payload, Bool) -> Int + ) -> [CommandPaletteSearchCorpusResult] { + search( + entries: entries, + query: query, + resultLimit: resultLimit, + historyBoost: historyBoost, + shouldCancel: nil + ) + } + + /// Searches with a cooperative cancellation probe checked every 16 entries. + public static func search( + entries: [CommandPaletteSearchCorpusEntry], + query: String, + resultLimit: Int? = nil, + historyBoost: (Payload, Bool) -> Int, + shouldCancel: @escaping () -> Bool + ) -> [CommandPaletteSearchCorpusResult] { + search( + entries: entries, + query: query, + resultLimit: resultLimit, + historyBoost: historyBoost, + shouldCancel: Optional(shouldCancel) + ) + } + + private static func search( + entries: [CommandPaletteSearchCorpusEntry], + query: String, + resultLimit: Int?, + historyBoost: (Payload, Bool) -> Int, + shouldCancel: (() -> Bool)? + ) -> [CommandPaletteSearchCorpusResult] { + if let resultLimit, resultLimit <= 0 { + return [] + } + let preparedQuery = CommandPaletteFuzzyMatcher.preparedQuery(query) + let queryIsEmpty = preparedQuery.isEmpty + let limitedResultCount = resultLimit.map { min($0, entries.count) } + var scoredEntries: [ScoredEntry] = [] + scoredEntries.reserveCapacity(limitedResultCount ?? entries.count) + + func shouldCancelSearch(at index: Int) -> Bool { + guard let shouldCancel else { return false } + return index % 16 == 0 && shouldCancel() + } + + if queryIsEmpty { + for (index, entry) in entries.enumerated() { + if shouldCancelSearch(at: index) { return [] } + appendScoredEntry( + ScoredEntry( + entry: entry, + index: index, + score: historyBoost(entry.payload, true) + ), + to: &scoredEntries, + limit: limitedResultCount + ) + } + } else { + for (index, entry) in entries.enumerated() { + if shouldCancelSearch(at: index) { return [] } + guard let fuzzyScore = weightedScore( + preparedQuery: preparedQuery, + entry: entry + ) else { + continue + } + appendScoredEntry( + ScoredEntry( + entry: entry, + index: index, + score: fuzzyScore + historyBoost(entry.payload, false) + ), + to: &scoredEntries, + limit: limitedResultCount + ) + } + } + + if shouldCancel?() == true { return [] } + + scoredEntries.sort { scoredEntryIsBetter($0, than: $1) } + + let outputCount = resultLimit.map { min($0, scoredEntries.count) } ?? scoredEntries.count + var results: [CommandPaletteSearchCorpusResult] = [] + results.reserveCapacity(outputCount) + for index in 0.. + if queryIsEmpty { + titleMatchIndices = [] + } else { + titleMatchIndices = entry.preparedTitle.map { + CommandPaletteFuzzyMatcher.matchCharacterIndices( + preparedQuery: preparedQuery, + preparedCandidate: $0 + ) + } ?? [] + } + results.append( + CommandPaletteSearchCorpusResult( + payload: entry.payload, + rank: entry.rank, + title: entry.title, + score: scoredEntry.score, + titleMatchIndices: titleMatchIndices + ) + ) + } + return results + } + + private static func weightedScore( + preparedQuery: CommandPaletteFuzzyMatcher.PreparedQuery, + entry: CommandPaletteSearchCorpusEntry + ) -> Int? { + guard let fuzzyScore = CommandPaletteFuzzyMatcher.score( + preparedQuery: preparedQuery, + preparedCandidates: entry.preparedSearchableTexts, + exactCandidateTexts: entry.searchableTextSet, + wholeCandidatePrefixScoreByToken: entry.searchablePrefixScoreByToken + ) else { + return nil + } + if let preparedTitle = entry.preparedTitle, + preparedQuery.tokens.allSatisfy({ $0.couldMatch(preparedTitle) }), + let titleScore = CommandPaletteFuzzyMatcher.score( + preparedQuery: preparedQuery, + preparedCandidate: preparedTitle + ) { + return max(fuzzyScore, titleScore + titleMatchBonus) + } + return fuzzyScore + } +} diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSwitcherSearchIndexer.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSwitcherSearchIndexer.swift new file mode 100644 index 00000000000..f5865212461 --- /dev/null +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSwitcherSearchIndexer.swift @@ -0,0 +1,124 @@ +import Foundation + +/// Derives normalized, de-duplicated search keywords for switcher entries +/// from base keywords plus workspace/surface metadata. +public enum CommandPaletteSwitcherSearchIndexer { + /// How much metadata detail to tokenize: workspaces index whole paths, + /// surfaces additionally index path/branch components. + public enum MetadataDetail { + /// Workspace-level detail (whole values only). + case workspace + /// Surface-level detail (whole values plus components). + case surface + } + + private static let metadataDelimiters = CharacterSet(charactersIn: "/\\.:_- ") + + /// Returns the unique, order-preserving keyword list for one entry. + public static func keywords( + baseKeywords: [String], + metadata: CommandPaletteSwitcherSearchMetadata, + detail: MetadataDetail = .surface + ) -> [String] { + let metadataKeywords = metadataKeywordsForSearch(metadata, detail: detail) + return uniqueNormalizedPreservingOrder(baseKeywords + metadataKeywords) + } + + private static func metadataKeywordsForSearch( + _ metadata: CommandPaletteSwitcherSearchMetadata, + detail: MetadataDetail + ) -> [String] { + let directoryTokens = metadata.directories.flatMap { directoryTokensForSearch($0, detail: detail) } + let branchTokens = metadata.branches.flatMap { branchTokensForSearch($0, detail: detail) } + let portTokens = metadata.ports.flatMap(portTokensForSearch) + let descriptionTokens = descriptionTokensForSearch(metadata.description) + + var contextKeywords: [String] = [] + if !directoryTokens.isEmpty { + contextKeywords.append(contentsOf: ["directory", "dir", "cwd", "path"]) + } + if !branchTokens.isEmpty { + contextKeywords.append(contentsOf: ["branch", "git"]) + } + if !portTokens.isEmpty { + contextKeywords.append(contentsOf: ["port", "ports"]) + } + if !descriptionTokens.isEmpty { + contextKeywords.append(contentsOf: ["description", "descriptions", "notes", "note"]) + } + + return contextKeywords + directoryTokens + branchTokens + portTokens + descriptionTokens + } + + private static func directoryTokensForSearch( + _ rawDirectory: String, + detail: MetadataDetail + ) -> [String] { + let trimmed = rawDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + + let standardized = (trimmed as NSString).standardizingPath + let canonical = standardized.isEmpty ? trimmed : standardized + let abbreviated = (canonical as NSString).abbreviatingWithTildeInPath + switch detail { + case .workspace: + return uniqueNormalizedPreservingOrder([trimmed, canonical, abbreviated]) + case .surface: + let basename = URL(fileURLWithPath: canonical, isDirectory: true).lastPathComponent + let components = canonical.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } + return uniqueNormalizedPreservingOrder( + [trimmed, canonical, abbreviated, basename] + components + ) + } + } + + private static func branchTokensForSearch( + _ rawBranch: String, + detail: MetadataDetail + ) -> [String] { + let trimmed = rawBranch.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + switch detail { + case .workspace: + return [trimmed] + case .surface: + let components = trimmed.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } + return uniqueNormalizedPreservingOrder([trimmed] + components) + } + } + + private static func portTokensForSearch(_ port: Int) -> [String] { + guard (1...65535).contains(port) else { return [] } + let portText = String(port) + return [portText, ":\(portText)"] + } + + private static func descriptionTokensForSearch(_ rawDescription: String?) -> [String] { + let trimmed = rawDescription?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { return [] } + let normalizedWhitespace = trimmed.replacingOccurrences( + of: "\\s+", + with: " ", + options: .regularExpression + ) + let components = normalizedWhitespace.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } + return uniqueNormalizedPreservingOrder([trimmed, normalizedWhitespace] + components) + } + + private static func uniqueNormalizedPreservingOrder(_ values: [String]) -> [String] { + var result: [String] = [] + var seen: Set = [] + result.reserveCapacity(values.count) + + for value in values { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let normalizedKey = trimmed + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + guard seen.insert(normalizedKey).inserted else { continue } + result.append(trimmed) + } + return result + } +} diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSwitcherSearchMetadata.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSwitcherSearchMetadata.swift new file mode 100644 index 00000000000..f8295549b83 --- /dev/null +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSwitcherSearchMetadata.swift @@ -0,0 +1,45 @@ +import Foundation + +/// Searchable workspace/surface metadata (directories, git branches, ports, +/// and the user description) feeding the switcher search corpus. +public struct CommandPaletteSwitcherSearchMetadata: Equatable, Sendable { + /// Working directories associated with the workspace or surface. + public let directories: [String] + /// Git branches associated with the workspace or surface. + public let branches: [String] + /// Listening ports associated with the workspace or surface. + public let ports: [Int] + /// Optional user-provided description. + public let description: String? + + /// Creates metadata; all fields default to empty. + public init( + directories: [String] = [], + branches: [String] = [], + ports: [Int] = [], + description: String? = nil + ) { + self.directories = directories + self.branches = branches + self.ports = ports + self.description = description + } + + /// Feeds the metadata into `hasher` for switcher change detection + /// (order- and count-sensitive). + public func combine(into hasher: inout Hasher) { + hasher.combine(directories.count) + for directory in directories { + hasher.combine(directory) + } + hasher.combine(branches.count) + for branch in branches { + hasher.combine(branch) + } + hasher.combine(ports.count) + for port in ports { + hasher.combine(port) + } + hasher.combine(description ?? "") + } +} diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/Nucleo/CommandPaletteNucleoSearchIndex.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/Nucleo/CommandPaletteNucleoSearchIndex.swift new file mode 100644 index 00000000000..08483aaba54 --- /dev/null +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/Nucleo/CommandPaletteNucleoSearchIndex.swift @@ -0,0 +1,105 @@ +import Foundation + +// Sendable is safe here because the Swift payload entries are immutable, the +// raw index pointer is destroyed only in deinit, and Rust keeps per-thread +// matcher scratch state outside the immutable index. +/// Immutable nucleo FFI search index over a prepared corpus. +public final class CommandPaletteNucleoSearchIndex: @unchecked Sendable where Payload: Sendable { + private let library: CommandPaletteNucleoSearchLibrary + private let pointer: OpaquePointer + private let entries: [CommandPaletteSearchCorpusEntry] + + /// Builds an index over `entries`, or returns nil when the FFI dylib is + /// unavailable or index creation fails. + public init?(entries: [CommandPaletteSearchCorpusEntry]) { + guard let library = CommandPaletteNucleoSearchLibrary.shared, + let pointer = library.createIndex(entries: entries) else { + return nil + } + self.library = library + self.pointer = pointer + self.entries = entries + } + + deinit { + library.destroy(index: pointer) + } + + /// Searches the index; returns nil when the FFI call fails (callers fall + /// back to the Swift engine), or an empty array when cancelled. + public func search( + query: String, + resultLimit: Int, + historyBoost: ((Payload, Bool) -> Int)? = nil, + shouldCancel: () -> Bool = { false } + ) -> [CommandPaletteNucleoSearchResult]? { + guard resultLimit > 0 else { return [] } + if shouldCancel() { return [] } + + let preparedQuery = CommandPaletteFuzzyMatcher.preparedQuery(query) + let queryIsEmpty = preparedQuery.isEmpty + let boosts: [Int32]? + if let historyBoost { + var values: [Int32] = [] + values.reserveCapacity(entries.count) + var hasNonZeroBoost = false + for entry in entries { + let boost = Int32(clamping: historyBoost(entry.payload, queryIsEmpty)) + hasNonZeroBoost = hasNonZeroBoost || boost != 0 + values.append(boost) + } + boosts = hasNonZeroBoost ? values : nil + } else { + boosts = nil + } + guard let rawMatches = library.search( + index: pointer, + query: query, + resultLimit: min(resultLimit, entries.count), + boosts: boosts + ) else { + return nil + } + if shouldCancel() { return [] } + + var results: [CommandPaletteNucleoSearchResult] = [] + results.reserveCapacity(rawMatches.count) + for rawMatch in rawMatches { + guard entries.indices.contains(rawMatch.index) else { continue } + let entry = entries[rawMatch.index] + let titleMatchIndices: Set + if queryIsEmpty { + titleMatchIndices = [] + } else { + titleMatchIndices = entry.preparedTitle.map { + CommandPaletteFuzzyMatcher.matchCharacterIndices( + preparedQuery: preparedQuery, + preparedCandidate: $0 + ) + } ?? [] + } + results.append( + CommandPaletteNucleoSearchResult( + payload: entry.payload, + rank: entry.rank, + title: entry.title, + score: Self.clampedRoundedScore(rawMatch.score), + titleMatchIndices: titleMatchIndices + ) + ) + } + return results + } + + private static func clampedRoundedScore(_ score: Double) -> Int { + let rounded = score.rounded() + guard rounded.isFinite else { + if rounded == .infinity { return Int.max } + if rounded == -.infinity { return Int.min } + return 0 + } + if rounded >= Double(Int.max) { return Int.max } + if rounded <= Double(Int.min) { return Int.min } + return Int(rounded) + } +} diff --git a/Sources/CommandPalette/CommandPaletteNucleoSearch.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/Nucleo/CommandPaletteNucleoSearchLibrary.swift similarity index 72% rename from Sources/CommandPalette/CommandPaletteNucleoSearch.swift rename to Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/Nucleo/CommandPaletteNucleoSearchLibrary.swift index 7208161340b..6c03235faa3 100644 --- a/Sources/CommandPalette/CommandPaletteNucleoSearch.swift +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/Nucleo/CommandPaletteNucleoSearchLibrary.swift @@ -1,17 +1,15 @@ import Darwin import Foundation -struct CommandPaletteNucleoSearchResult: Sendable where Payload: Sendable { - let payload: Payload - let rank: Int - let title: String - let score: Int - let titleMatchIndices: Set -} - // Sendable is safe here because the dlopen handle and C function pointers are // immutable after initialization. The Rust side owns synchronization for index // search state. +/// Loaded `cmux-nucleo-ffi` dylib handle with resolved C entry points. +/// +/// `shared` caches the process-wide dlopen handle; the handle and function +/// pointers are immutable after load, and the Rust side owns search-state +/// synchronization, so reusing one instance per process is the existing, +/// deliberate design (a second instance would just re-dlopen the same dylib). final class CommandPaletteNucleoSearchLibrary: @unchecked Sendable { private typealias CreateIndex = @convention(c) ( UnsafePointer?, @@ -122,10 +120,16 @@ final class CommandPaletteNucleoSearchLibrary: @unchecked Sendable { ) } + // Repo source root: this file lives at + // Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/Nucleo/. let sourceRoot = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() .deletingLastPathComponent() .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() paths.append( sourceRoot .appendingPathComponent("Native/CommandPaletteNucleoFFI/target/cmux-nucleo-ffi") @@ -202,7 +206,7 @@ final class CommandPaletteNucleoSearchLibrary: @unchecked Sendable { destroyIndex(index) } - fileprivate func search( + func search( index: OpaquePointer, query: String, resultLimit: Int, @@ -253,106 +257,7 @@ final class CommandPaletteNucleoSearchLibrary: @unchecked Sendable { } } -// Sendable is safe here because the Swift payload entries are immutable, the -// raw index pointer is destroyed only in deinit, and Rust keeps per-thread -// matcher scratch state outside the immutable index. -final class CommandPaletteNucleoSearchIndex: @unchecked Sendable where Payload: Sendable { - private let library: CommandPaletteNucleoSearchLibrary - private let pointer: OpaquePointer - private let entries: [CommandPaletteSearchCorpusEntry] - - init?(entries: [CommandPaletteSearchCorpusEntry]) { - guard let library = CommandPaletteNucleoSearchLibrary.shared, - let pointer = library.createIndex(entries: entries) else { - return nil - } - self.library = library - self.pointer = pointer - self.entries = entries - } - - deinit { - library.destroy(index: pointer) - } - - func search( - query: String, - resultLimit: Int, - historyBoost: ((Payload, Bool) -> Int)? = nil, - shouldCancel: () -> Bool = { false } - ) -> [CommandPaletteNucleoSearchResult]? { - guard resultLimit > 0 else { return [] } - if shouldCancel() { return [] } - - let preparedQuery = CommandPaletteFuzzyMatcher.preparedQuery(query) - let queryIsEmpty = preparedQuery.isEmpty - let boosts: [Int32]? - if let historyBoost { - var values: [Int32] = [] - values.reserveCapacity(entries.count) - var hasNonZeroBoost = false - for entry in entries { - let boost = Int32(clamping: historyBoost(entry.payload, queryIsEmpty)) - hasNonZeroBoost = hasNonZeroBoost || boost != 0 - values.append(boost) - } - boosts = hasNonZeroBoost ? values : nil - } else { - boosts = nil - } - guard let rawMatches = library.search( - index: pointer, - query: query, - resultLimit: min(resultLimit, entries.count), - boosts: boosts - ) else { - return nil - } - if shouldCancel() { return [] } - - var results: [CommandPaletteNucleoSearchResult] = [] - results.reserveCapacity(rawMatches.count) - for rawMatch in rawMatches { - guard entries.indices.contains(rawMatch.index) else { continue } - let entry = entries[rawMatch.index] - let titleMatchIndices: Set - if queryIsEmpty { - titleMatchIndices = [] - } else { - titleMatchIndices = entry.preparedTitle.map { - CommandPaletteFuzzyMatcher.matchCharacterIndices( - preparedQuery: preparedQuery, - preparedCandidate: $0 - ) - } ?? [] - } - results.append( - CommandPaletteNucleoSearchResult( - payload: entry.payload, - rank: entry.rank, - title: entry.title, - score: Self.clampedRoundedScore(rawMatch.score), - titleMatchIndices: titleMatchIndices - ) - ) - } - return results - } - - private static func clampedRoundedScore(_ score: Double) -> Int { - let rounded = score.rounded() - guard rounded.isFinite else { - if rounded == .infinity { return Int.max } - if rounded == -.infinity { return Int.min } - return 0 - } - if rounded >= Double(Int.max) { return Int.max } - if rounded <= Double(Int.min) { return Int.min } - return Int(rounded) - } -} - -fileprivate struct CommandPaletteNucleoCandidateSpan { +struct CommandPaletteNucleoCandidateSpan { let titleOffset: Int let titleLength: Int let searchOffset: Int @@ -360,7 +265,7 @@ fileprivate struct CommandPaletteNucleoCandidateSpan { let rank: Int32 } -fileprivate struct CommandPaletteNucleoRawMatch { +struct CommandPaletteNucleoRawMatch { var index: Int var score: Double var rank: Int32 diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/Nucleo/CommandPaletteNucleoSearchResult.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/Nucleo/CommandPaletteNucleoSearchResult.swift new file mode 100644 index 00000000000..5f60174d554 --- /dev/null +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/Nucleo/CommandPaletteNucleoSearchResult.swift @@ -0,0 +1,15 @@ +import Foundation + +/// One scored hit returned by ``CommandPaletteNucleoSearchIndex``. +public struct CommandPaletteNucleoSearchResult: Sendable where Payload: Sendable { + /// The matched entry's payload. + public let payload: Payload + /// The matched entry's rank. + public let rank: Int + /// The matched entry's title. + public let title: String + /// Rounded, clamped nucleo score including any boost. + public let score: Int + /// Title character indices to highlight (computed by the Swift matcher). + public let titleMatchIndices: Set +} diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteCommand.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteCommand.swift new file mode 100644 index 00000000000..0a0c94a073f --- /dev/null +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteCommand.swift @@ -0,0 +1,52 @@ +import Foundation + +/// One runnable palette command: identity, display strings, search keywords, +/// and the action executed when the command is activated. +public struct CommandPaletteCommand: Identifiable { + /// Stable command identifier. + public let id: String + /// Tie-break rank; lower sorts first at equal score. + public let rank: Int + /// Display title. + public let title: String + /// Display subtitle. + public let subtitle: String + /// Optional keyboard-shortcut hint shown trailing the row. + public let shortcutHint: String? + /// Optional kind label (for example a switcher row's surface kind). + public let kindLabel: String? + /// Additional search keywords. + public let keywords: [String] + /// Whether activating the command dismisses the palette. + public let dismissOnRun: Bool + /// The action executed on activation. + public let action: () -> Void + + /// Creates a command. + public init( + id: String, + rank: Int, + title: String, + subtitle: String, + shortcutHint: String?, + kindLabel: String?, + keywords: [String], + dismissOnRun: Bool, + action: @escaping () -> Void + ) { + self.id = id + self.rank = rank + self.title = title + self.subtitle = subtitle + self.shortcutHint = shortcutHint + self.kindLabel = kindLabel + self.keywords = keywords + self.dismissOnRun = dismissOnRun + self.action = action + } + + /// Texts the search corpus indexes for this command. + public var searchableTexts: [String] { + [title, subtitle] + keywords + } +} diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteInputFocusPolicy.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteInputFocusPolicy.swift new file mode 100644 index 00000000000..d5b0ad53e38 --- /dev/null +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteInputFocusPolicy.swift @@ -0,0 +1,24 @@ +import Foundation + +/// Pairs the input to focus with the selection behavior to apply on focus. +public struct CommandPaletteInputFocusPolicy: Sendable { + /// The input to focus. + public let focusTarget: CommandPaletteInputFocusTarget + /// The selection applied once focused. + public let selectionBehavior: CommandPaletteTextSelectionBehavior + + /// Creates a focus policy. + public init( + focusTarget: CommandPaletteInputFocusTarget, + selectionBehavior: CommandPaletteTextSelectionBehavior + ) { + self.focusTarget = focusTarget + self.selectionBehavior = selectionBehavior + } + + /// Focus the search field with the caret at the end. + public static let search = CommandPaletteInputFocusPolicy( + focusTarget: .search, + selectionBehavior: .caretAtEnd + ) +} diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteInputFocusTarget.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteInputFocusTarget.swift new file mode 100644 index 00000000000..fe97b6f1a8b --- /dev/null +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteInputFocusTarget.swift @@ -0,0 +1,9 @@ +import Foundation + +/// Which palette text input should receive keyboard focus. +public enum CommandPaletteInputFocusTarget: Sendable { + /// The search field. + case search + /// The rename/description editor. + case rename +} diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteMode.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteMode.swift new file mode 100644 index 00000000000..e56769f7348 --- /dev/null +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteMode.swift @@ -0,0 +1,14 @@ +import Foundation + +/// The palette's input mode: the regular command/switcher list, one of the +/// two rename phases, or the workspace-description editor. +public enum CommandPaletteMode { + /// Regular command/switcher list. + case commands + /// Rename editor is open for `target`. + case renameInput(CommandPaletteRenameTarget) + /// Rename confirmation for `target` with the user's `proposedName`. + case renameConfirm(CommandPaletteRenameTarget, proposedName: String) + /// Workspace-description editor is open for the target workspace. + case workspaceDescriptionInput(CommandPaletteWorkspaceDescriptionTarget) +} diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPalettePendingActivation.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPalettePendingActivation.swift new file mode 100644 index 00000000000..5e7650c13aa --- /dev/null +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPalettePendingActivation.swift @@ -0,0 +1,11 @@ +import Foundation + +/// A queued palette activation (Return pressed) waiting for the in-flight +/// search whose `requestID` it captured to resolve. +public enum CommandPalettePendingActivation: Equatable { + /// Activate whatever ends up selected; fall back to `fallbackSelectedIndex` + /// or `preferredCommandID` when the results changed. + case selected(requestID: UInt64, fallbackSelectedIndex: Int, preferredCommandID: String?) + /// Activate the specific command `commandID`. + case command(requestID: UInt64, commandID: String) +} diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPalettePendingActivationResolutionResult.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPalettePendingActivationResolutionResult.swift new file mode 100644 index 00000000000..d0f99bdac1f --- /dev/null +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPalettePendingActivationResolutionResult.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Outcome of resolving a pending activation against the current results. +public struct CommandPalettePendingActivationResolutionResult: Equatable { + /// The activation to run, or nil when nothing should activate yet. + public let resolvedActivation: CommandPaletteResolvedActivation? + /// Whether the pending activation should be cleared. + public let shouldClearPendingActivation: Bool + + /// Creates a resolution result. + public init( + resolvedActivation: CommandPaletteResolvedActivation?, + shouldClearPendingActivation: Bool + ) { + self.resolvedActivation = resolvedActivation + self.shouldClearPendingActivation = shouldClearPendingActivation + } +} diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteRenameTarget.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteRenameTarget.swift new file mode 100644 index 00000000000..cd6344721a3 --- /dev/null +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteRenameTarget.swift @@ -0,0 +1,58 @@ +public import Foundation + +/// Identifies what a palette rename flow edits (a workspace or a tab) and +/// carries the name shown when the editor opens. +public struct CommandPaletteRenameTarget: Equatable { + /// The renameable entity. + public enum Kind: Equatable { + /// Rename the workspace with this id. + case workspace(workspaceId: UUID) + /// Rename the tab `panelId` inside workspace `workspaceId`. + case tab(workspaceId: UUID, panelId: UUID) + } + + /// The entity being renamed. + public let kind: Kind + /// The current (pre-edit) name. + public let currentName: String + + /// Creates a rename target. + public init(kind: Kind, currentName: String) { + self.kind = kind + self.currentName = currentName + } + + // Strings resolve against the app bundle (`bundle: .main`) so the keys in + // the app's Localizable.xcstrings (including Japanese) keep working from + // package code. + + /// Localized editor title. + public var title: String { + switch kind { + case .workspace: + return String(localized: "commandPalette.rename.workspaceTitle", defaultValue: "Rename Workspace", bundle: .main) + case .tab: + return String(localized: "commandPalette.rename.tabTitle", defaultValue: "Rename Tab", bundle: .main) + } + } + + /// Localized editor description. + public var description: String { + switch kind { + case .workspace: + return String(localized: "commandPalette.rename.workspaceDescription", defaultValue: "Choose a custom workspace name.", bundle: .main) + case .tab: + return String(localized: "commandPalette.rename.tabDescription", defaultValue: "Choose a custom tab name.", bundle: .main) + } + } + + /// Localized input placeholder. + public var placeholder: String { + switch kind { + case .workspace: + return String(localized: "commandPalette.rename.workspacePlaceholder", defaultValue: "Workspace name", bundle: .main) + case .tab: + return String(localized: "commandPalette.rename.tabPlaceholder", defaultValue: "Tab name", bundle: .main) + } + } +} diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteResolvedActivation.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteResolvedActivation.swift new file mode 100644 index 00000000000..e7beaafbe31 --- /dev/null +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteResolvedActivation.swift @@ -0,0 +1,10 @@ +import Foundation + +/// The activation resolved from a ``CommandPalettePendingActivation`` once +/// results are available. +public enum CommandPaletteResolvedActivation: Equatable { + /// Activate the result at `index`. + case selected(index: Int) + /// Activate the command `commandID`. + case command(commandID: String) +} diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteSearchResult.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteSearchResult.swift new file mode 100644 index 00000000000..1bf4c20fa2f --- /dev/null +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteSearchResult.swift @@ -0,0 +1,22 @@ +import Foundation + +/// One scored, displayable palette row: the command plus its score and the +/// title characters to highlight. +public struct CommandPaletteSearchResult: Identifiable { + /// The matched command. + public let command: CommandPaletteCommand + /// Final score including boosts. + public let score: Int + /// Title character indices to highlight. + public let titleMatchIndices: Set + + /// Creates a search result row. + public init(command: CommandPaletteCommand, score: Int, titleMatchIndices: Set) { + self.command = command + self.score = score + self.titleMatchIndices = titleMatchIndices + } + + /// The command's identifier. + public var id: String { command.id } +} diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteSwitcherFingerprintContext.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteSwitcherFingerprintContext.swift new file mode 100644 index 00000000000..ec783402a78 --- /dev/null +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteSwitcherFingerprintContext.swift @@ -0,0 +1,55 @@ +public import Foundation + +/// Change-detection fingerprint input for one window's switcher contents. +public struct CommandPaletteSwitcherFingerprintContext: Sendable { + /// Window id. + public let windowId: UUID + /// Optional window label. + public let windowLabel: String? + /// The window's selected workspace, when any. + public let selectedWorkspaceId: UUID? + /// The window's workspaces, in switcher order. + public let workspaces: [CommandPaletteSwitcherFingerprintWorkspace] + + /// Creates a window fingerprint input. + public init( + windowId: UUID, + windowLabel: String?, + selectedWorkspaceId: UUID?, + workspaces: [CommandPaletteSwitcherFingerprintWorkspace] + ) { + self.windowId = windowId + self.windowLabel = windowLabel + self.selectedWorkspaceId = selectedWorkspaceId + self.workspaces = workspaces + } + + /// Order-sensitive fingerprint over every window's switcher contents, + /// used to detect when the switcher corpus must be rebuilt. Hash values + /// are only compared within the current process. + public static func fingerprint( + windowContexts: [CommandPaletteSwitcherFingerprintContext] + ) -> Int { + var hasher = Hasher() + hasher.combine(windowContexts.count) + for context in windowContexts { + hasher.combine(context.windowId) + hasher.combine(context.windowLabel) + hasher.combine(context.selectedWorkspaceId) + hasher.combine(context.workspaces.count) + for workspace in context.workspaces { + hasher.combine(workspace.id) + hasher.combine(workspace.displayName) + workspace.metadata.combine(into: &hasher) + hasher.combine(workspace.surfaces.count) + for surface in workspace.surfaces { + hasher.combine(surface.id) + hasher.combine(surface.displayName) + hasher.combine(surface.kindLabel) + surface.metadata.combine(into: &hasher) + } + } + } + return hasher.finalize() + } +} diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteSwitcherFingerprintSurface.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteSwitcherFingerprintSurface.swift new file mode 100644 index 00000000000..01731a0fa2f --- /dev/null +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteSwitcherFingerprintSurface.swift @@ -0,0 +1,26 @@ +public import Foundation + +/// Change-detection fingerprint input for one surface in the switcher. +public struct CommandPaletteSwitcherFingerprintSurface: Sendable { + /// Surface id. + public let id: UUID + /// Surface display name. + public let displayName: String + /// Surface kind label. + public let kindLabel: String + /// Searchable surface metadata. + public let metadata: CommandPaletteSwitcherSearchMetadata + + /// Creates a surface fingerprint input. + public init( + id: UUID, + displayName: String, + kindLabel: String, + metadata: CommandPaletteSwitcherSearchMetadata + ) { + self.id = id + self.displayName = displayName + self.kindLabel = kindLabel + self.metadata = metadata + } +} diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteSwitcherFingerprintWorkspace.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteSwitcherFingerprintWorkspace.swift new file mode 100644 index 00000000000..a63de090126 --- /dev/null +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteSwitcherFingerprintWorkspace.swift @@ -0,0 +1,26 @@ +public import Foundation + +/// Change-detection fingerprint input for one workspace in the switcher. +public struct CommandPaletteSwitcherFingerprintWorkspace: Sendable { + /// Workspace id. + public let id: UUID + /// Workspace display name. + public let displayName: String + /// Searchable workspace metadata. + public let metadata: CommandPaletteSwitcherSearchMetadata + /// The workspace's surfaces, in switcher order. + public let surfaces: [CommandPaletteSwitcherFingerprintSurface] + + /// Creates a workspace fingerprint input. + public init( + id: UUID, + displayName: String, + metadata: CommandPaletteSwitcherSearchMetadata, + surfaces: [CommandPaletteSwitcherFingerprintSurface] + ) { + self.id = id + self.displayName = displayName + self.metadata = metadata + self.surfaces = surfaces + } +} diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteTextSelectionBehavior.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteTextSelectionBehavior.swift new file mode 100644 index 00000000000..2a01a77387c --- /dev/null +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteTextSelectionBehavior.swift @@ -0,0 +1,9 @@ +import Foundation + +/// How text is selected when a palette input gains programmatic focus. +public enum CommandPaletteTextSelectionBehavior: Sendable { + /// Place the caret at the end without selecting. + case caretAtEnd + /// Select the whole text. + case selectAll +} diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteWorkspaceDescriptionTarget.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteWorkspaceDescriptionTarget.swift new file mode 100644 index 00000000000..56504bee269 --- /dev/null +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Values/CommandPaletteWorkspaceDescriptionTarget.swift @@ -0,0 +1,38 @@ +public import Foundation + +/// Identifies the workspace whose description the palette edits and carries +/// the description shown when the editor opens. +public struct CommandPaletteWorkspaceDescriptionTarget: Equatable { + /// The workspace being edited. + public let workspaceId: UUID + /// The current (pre-edit) description. + public let currentDescription: String + + /// Creates a description-edit target. + public init(workspaceId: UUID, currentDescription: String) { + self.workspaceId = workspaceId + self.currentDescription = currentDescription + } + + // Strings resolve against the app bundle (`bundle: .main`) so the keys in + // the app's Localizable.xcstrings (including Japanese) keep working from + // package code. + + /// Localized input placeholder. + public var placeholder: String { + String( + localized: "commandPalette.description.workspacePlaceholder", + defaultValue: "Workspace description", + bundle: .main + ) + } + + /// Localized input hint shown under the editor. + public var inputHint: String { + String( + localized: "commandPalette.description.workspaceInputHint", + defaultValue: "Press Enter to save. Press Shift-Enter for a new line, or Escape to cancel.", + bundle: .main + ) + } +} diff --git a/Packages/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteNucleoFFILibrarySupport.swift b/Packages/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteNucleoFFILibrarySupport.swift new file mode 100644 index 00000000000..543c03cfb54 --- /dev/null +++ b/Packages/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteNucleoFFILibrarySupport.swift @@ -0,0 +1,291 @@ +import Darwin +import Foundation + +@testable import CmuxCommandPalette + +struct FFICandidateSpan { + let titleOffset: Int + let titleLength: Int + let searchOffset: Int + let searchLength: Int + let rank: Int32 +} + +struct FFIMatch { + var index: Int + var score: Double + var rank: Int32 +} + +final class NucleoLibrary { + private static let supportedVersion: UInt32 = 2 + private static let libraryFileName = "libcmux_command_palette_nucleo_ffi.dylib" + + typealias CreateIndex = @convention(c) ( + UnsafePointer?, + Int, + UnsafeRawPointer?, + Int + ) -> OpaquePointer? + typealias DestroyIndex = @convention(c) (OpaquePointer?) -> Void + typealias SearchIndex = @convention(c) ( + OpaquePointer?, + UnsafePointer?, + Int, + Int, + UnsafeMutableRawPointer?, + Int, + UnsafeMutablePointer? + ) -> Int32 + typealias Version = @convention(c) () -> UInt32 + + let handle: UnsafeMutableRawPointer + let createIndex: CreateIndex + let destroyIndex: DestroyIndex + let searchIndex: SearchIndex + let version: Version + + /// Returns nil when the nucleo FFI dylib has not been built or bundled in + /// this environment (the XCTest port threw XCTSkip in that case). Throws + /// only for real load failures (dlopen/dlsym/version errors). + static func loadIfAvailable() throws -> NucleoLibrary? { + let environment = ProcessInfo.processInfo.environment + let paths = defaultLibraryPaths(environment: environment) + guard let path = paths.first(where: { FileManager.default.fileExists(atPath: $0) }) else { + return nil + } + return try NucleoLibrary(path: path) + } + + private init(path: String) throws { + guard let handle = dlopen(path, RTLD_NOW | RTLD_LOCAL) else { + throw NSError( + domain: "CommandPaletteNucleoFFITests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "dlopen failed: \(Self.dlerrorText())"] + ) + } + self.handle = handle + self.createIndex = try Self.symbol( + "cmux_nucleo_index_create", + from: handle, + as: CreateIndex.self + ) + self.destroyIndex = try Self.symbol( + "cmux_nucleo_index_destroy", + from: handle, + as: DestroyIndex.self + ) + self.searchIndex = try Self.symbol( + "cmux_nucleo_index_search", + from: handle, + as: SearchIndex.self + ) + self.version = try Self.symbol( + "cmux_nucleo_ffi_version", + from: handle, + as: Version.self + ) + let resolvedVersion = self.version() + guard resolvedVersion == Self.supportedVersion else { + dlclose(handle) + throw NSError( + domain: "CommandPaletteNucleoFFITests", + code: 6, + userInfo: [ + NSLocalizedDescriptionKey: "unsupported cmux_nucleo_ffi_version \(resolvedVersion)" + ] + ) + } + } + + private static func defaultLibraryPaths(environment: [String: String]) -> [String] { + var paths: [String] = [] + if let environmentPath = environment["CMUX_NUCLEO_FFI_LIB"], !environmentPath.isEmpty { + paths.append(environmentPath) + } + if let privateFrameworksPath = Bundle.main.privateFrameworksPath { + paths.append( + URL(fileURLWithPath: privateFrameworksPath) + .appendingPathComponent(libraryFileName) + .path + ) + } + + // This file lives at Packages/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/, + // so five deletions reach the repo root (which contains Native/CommandPaletteNucleoFFI/). + let sourceRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let crateTarget = sourceRoot.appendingPathComponent("Native/CommandPaletteNucleoFFI/target") + paths.append( + crateTarget + .appendingPathComponent("cmux-nucleo-ffi") + .appendingPathComponent(libraryFileName) + .path + ) + paths.append( + crateTarget + .appendingPathComponent("release") + .appendingPathComponent(libraryFileName) + .path + ) +#if arch(arm64) + paths.append( + crateTarget + .appendingPathComponent("aarch64-apple-darwin/release") + .appendingPathComponent(libraryFileName) + .path + ) +#elseif arch(x86_64) + paths.append( + crateTarget + .appendingPathComponent("x86_64-apple-darwin/release") + .appendingPathComponent(libraryFileName) + .path + ) +#endif + return paths + } + + deinit { + dlclose(handle) + } + + private static func symbol(_ name: String, from handle: UnsafeMutableRawPointer, as _: T.Type) throws -> T { + guard let pointer = dlsym(handle, name) else { + throw NSError( + domain: "CommandPaletteNucleoFFITests", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "dlsym(\(name)) failed: \(dlerrorText())"] + ) + } + return unsafeBitCast(pointer, to: T.self) + } + + private static func dlerrorText() -> String { + guard let error = dlerror() else { return "unknown error" } + return String(cString: error) + } +} + +final class NucleoIndex { + let library: NucleoLibrary + let pointer: OpaquePointer + let entries: [FixtureEntry] + + init(library: NucleoLibrary, entries: [FixtureEntry]) throws { + self.library = library + self.entries = entries + + var blob: [UInt8] = [] + var spans: [FFICandidateSpan] = [] + blob.reserveCapacity(entries.reduce(0) { total, entry in + total + entry.title.utf8.count + entry.searchableTexts.reduce(0) { $0 + $1.utf8.count + 1 } + }) + spans.reserveCapacity(entries.count) + + for entry in entries { + let titleOffset = blob.count + blob.append(contentsOf: entry.title.utf8) + let titleLength = blob.count - titleOffset + + let searchOffset = blob.count + blob.append(contentsOf: entry.searchableTexts.joined(separator: "\n").utf8) + let searchLength = blob.count - searchOffset + + spans.append( + FFICandidateSpan( + titleOffset: titleOffset, + titleLength: titleLength, + searchOffset: searchOffset, + searchLength: searchLength, + rank: Int32(entry.rank) + ) + ) + } + + guard let pointer = blob.withUnsafeBufferPointer({ blobBuffer in + spans.withUnsafeBufferPointer { spanBuffer in + library.createIndex( + blobBuffer.baseAddress, + blobBuffer.count, + UnsafeRawPointer(spanBuffer.baseAddress), + spanBuffer.count + ) + } + }) else { + throw NSError( + domain: "CommandPaletteNucleoFFITests", + code: 3, + userInfo: [NSLocalizedDescriptionKey: "cmux_nucleo_index_create returned null"] + ) + } + self.pointer = pointer + } + + deinit { + library.destroyIndex(pointer) + } + + func search(query: String, limit: Int) throws -> [NucleoResult] { + var matches = Array( + repeating: FFIMatch(index: 0, score: 0, rank: 0), + count: max(1, limit) + ) + var count = 0 + let queryBytes = Array(query.utf8) + let status = queryBytes.withUnsafeBufferPointer { queryBuffer in + matches.withUnsafeMutableBufferPointer { matchBuffer in + library.searchIndex( + pointer, + queryBuffer.baseAddress, + queryBuffer.count, + limit, + UnsafeMutableRawPointer(matchBuffer.baseAddress), + matchBuffer.count, + &count + ) + } + } + guard status == 0 else { + throw NSError( + domain: "CommandPaletteNucleoFFITests", + code: Int(status), + userInfo: [NSLocalizedDescriptionKey: "cmux_nucleo_index_search failed with \(status)"] + ) + } + + guard count >= 0, count <= matches.count, count <= limit else { + throw NSError( + domain: "CommandPaletteNucleoFFITests", + code: 4, + userInfo: [ + NSLocalizedDescriptionKey: "cmux_nucleo_index_search returned invalid count \(count) for limit \(limit)" + ] + ) + } + + return try matches.prefix(count).map { match in + guard entries.indices.contains(match.index) else { + throw NSError( + domain: "CommandPaletteNucleoFFITests", + code: 5, + userInfo: [ + NSLocalizedDescriptionKey: "cmux_nucleo_index_search returned invalid index \(match.index)" + ] + ) + } + let entry = entries[match.index] + return NucleoResult( + id: entry.id, + rank: Int(match.rank), + title: entry.title, + score: match.score + ) + } + } +} diff --git a/Packages/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteNucleoFFITests.swift b/Packages/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteNucleoFFITests.swift new file mode 100644 index 00000000000..fa81eb3b520 --- /dev/null +++ b/Packages/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteNucleoFFITests.swift @@ -0,0 +1,604 @@ +import Foundation +import Testing + +@testable import CmuxCommandPalette + +// Serialized: the comparison/overhead tests assert on wall-clock benchmark +// numbers, which parallel execution would skew. +@Suite(.serialized) +struct CommandPaletteNucleoFFITests { + @Test func nucleoFFIPrefersOpenFolderForOpenFolderQuery() throws { + guard let library = try NucleoLibrary.loadIfAvailable() else { return } + // Skipped: nucleo FFI dylib not built in this environment. + #expect(library.version() == 2) + let entries = makeOpenFolderEntries() + let index = try NucleoIndex(library: library, entries: entries) + + let resultIDs = try index.search(query: "open folder", limit: 4).map(\.id) + + #expect( + Array(resultIDs.prefix(2)) == + ["palette.openFolder", "palette.openFolderInVSCodeInline"] + ) + } + + @Test func nucleoFFIMatchesMultiTokenQueriesAcrossFieldsWithoutOrderDependency() throws { + guard let library = try NucleoLibrary.loadIfAvailable() else { return } + // Skipped: nucleo FFI dylib not built in this environment. + let entries = makeOpenFolderEntries() + let index = try NucleoIndex(library: library, entries: entries) + + let resultIDs = try index.search(query: "directory open", limit: 4).map(\.id) + + #expect( + Array(resultIDs.prefix(2)) == + ["palette.openFolder", "palette.openFolderInVSCodeInline"] + ) + } + + @Test func nucleoFFIPrefersTitleInitialismOverCompactInWordMatch() throws { + guard let library = try NucleoLibrary.loadIfAvailable() else { return } + // Skipped: nucleo FFI dylib not built in this environment. + let entries = makeInitialismWorkspaceEntries() + let index = try NucleoIndex(library: library, entries: entries) + + let resultIDs = try index.search(query: "ims", limit: 5).map(\.id) + + #expect(resultIDs.first == "workspace.indigoMarkdownStudio") + } + + @Test func nucleoFFIPrefersExactAliasOverTitleInitialism() throws { + guard let library = try NucleoLibrary.loadIfAvailable() else { return } + // Skipped: nucleo FFI dylib not built in this environment. + let entries = [ + FixtureEntry( + id: "palette.openWorkspacePRLinks", + rank: 0, + title: "Open All Workspace PR Links", + searchableTexts: [ + "Open All Workspace PR Links", + "Workspace", + "pull", + "request", + "review", + "merge", + "pr", + "mr", + "open", + "links", + "workspace", + ] + ), + FixtureEntry( + id: "palette.markWorkspaceRead", + rank: 1, + title: "Mark Workspace as Read", + searchableTexts: ["Mark Workspace as Read", "Workspace", "workspace", "read", "notification"] + ), + ] + let index = try NucleoIndex(library: library, entries: entries) + + let resultIDs = try index.search(query: "mr", limit: 5).map(\.id) + + #expect(resultIDs.first == "palette.openWorkspacePRLinks") + } + + @Test func nucleoFFIPrefersTitleMatchOverLongExactKeyword() throws { + guard let library = try NucleoLibrary.loadIfAvailable() else { return } + // Skipped: nucleo FFI dylib not built in this environment. + let entries = [ + FixtureEntry( + id: "palette.checkForUpdates", + rank: 0, + title: "Check for Updates", + searchableTexts: ["Check for Updates", "Global", "update", "upgrade", "release"] + ), + FixtureEntry( + id: "palette.attemptUpdate", + rank: 1, + title: "Attempt Update", + searchableTexts: ["Attempt Update", "Global", "attempt", "check", "update", "upgrade", "release"] + ), + ] + let index = try NucleoIndex(library: library, entries: entries) + + let resultIDs = try index.search(query: "check", limit: 5).map(\.id) + + #expect(resultIDs.first == "palette.checkForUpdates") + } + + @Test func nucleoFFIPrefersVisibleTitlePrefixOverHiddenMetadataKeyword() throws { + // Regression: in the workspace switcher, a workspace whose visible title starts with the + // query must rank above one that only matched a hidden metadata token (a branch or + // description word produced by commandPaletteWorkspaceSearchMetadata). For short queries + // an exact match on such a hidden line scored 30_030 and beat the visible title prefix, + // which only reached nucleo(~88) + 2_000. The "ios" row shown to the user therefore had + // no highlighted title yet sat at the top. https://github.com/manaflow-ai/cmux/pull/5148 + guard let library = try NucleoLibrary.loadIfAvailable() else { return } + // Skipped: nucleo FFI dylib not built in this environment. + let entries = [ + FixtureEntry( + id: "workspace.iosMobileTerminal", + rank: 0, + title: "iOS Mobile Terminal", + searchableTexts: ["iOS Mobile Terminal", "Workspace", "workspace", "switch", "go"] + ), + FixtureEntry( + id: "workspace.forkSessionNotFound", + rank: 1, + title: "Fork Session Not Found", + // "ios" here stands in for a hidden branch/description token, the field that the + // switcher indexes but never highlights in the row. + searchableTexts: [ + "Fork Session Not Found", "Workspace", "workspace", "switch", "go", + "branch", "ios", + ] + ), + ] + let index = try NucleoIndex(library: library, entries: entries) + + let resultIDs = try index.search(query: "ios", limit: 5).map(\.id) + + #expect(resultIDs.first == "workspace.iosMobileTerminal") + #expect(resultIDs.contains("workspace.forkSessionNotFound")) + } + + @Test func nucleoFFIPrefersTitleOverSummedHiddenKeywordsForMultiTokenQuery() throws { + // Regression: the per-token keyword path is summed, so a multi-token query like "ios app" + // can give a hidden row an exact line per token (~30_030 each, ~60_060 summed) that beats a + // flat title-literal score. The title tier is scaled by query token count so a visible + // title match still wins for multi-token queries. + // https://github.com/manaflow-ai/cmux/pull/5148 + guard let library = try NucleoLibrary.loadIfAvailable() else { return } + // Skipped: nucleo FFI dylib not built in this environment. + let entries = [ + FixtureEntry( + id: "workspace.iosApp", + rank: 0, + title: "iOS App", + searchableTexts: ["iOS App", "Workspace", "workspace", "switch", "go"] + ), + FixtureEntry( + id: "workspace.hiddenMetadataRow", + rank: 1, + title: "Some Unrelated Workspace", + // Both query tokens present only as hidden metadata tokens (branch/description). + searchableTexts: [ + "Some Unrelated Workspace", "Workspace", "workspace", "switch", "go", + "branch", "ios", "app", + ] + ), + ] + let index = try NucleoIndex(library: library, entries: entries) + + let resultIDs = try index.search(query: "ios app", limit: 5).map(\.id) + + #expect(resultIDs.first == "workspace.iosApp") + } + + @Test func nucleoFFIPrefersDiacriticTitlePrefixOverHiddenKeyword() throws { + // Regression: the literal-title check must use the matcher's case + Smart diacritic + // normalization, so a localized title like "Éclair" is recognized as a prefix of "e" and + // still beats a row that only has a hidden exact "e" metadata token. + // https://github.com/manaflow-ai/cmux/pull/5148 + guard let library = try NucleoLibrary.loadIfAvailable() else { return } + // Skipped: nucleo FFI dylib not built in this environment. + let entries = [ + FixtureEntry( + id: "workspace.eclair", + rank: 0, + title: "Éclair Notes", + searchableTexts: ["Éclair Notes", "Workspace", "workspace", "switch", "go"] + ), + FixtureEntry( + id: "workspace.hiddenEKeyword", + rank: 1, + title: "Some Other Workspace", + searchableTexts: [ + "Some Other Workspace", "Workspace", "workspace", "switch", "go", "branch", "e", + ] + ), + ] + let index = try NucleoIndex(library: library, entries: entries) + + let resultIDs = try index.search(query: "e", limit: 5).map(\.id) + + #expect(resultIDs.first == "workspace.eclair") + } + + @Test func nucleoFFIDoesNotMatchSingleTokenAcrossSearchFields() throws { + guard let library = try NucleoLibrary.loadIfAvailable() else { return } + // Skipped: nucleo FFI dylib not built in this environment. + let entries = [ + FixtureEntry( + id: "palette.crossFieldOnly", + rank: 0, + title: "Other Command", + searchableTexts: ["Other Command", "foo", "bar"] + ), + FixtureEntry( + id: "palette.frameRate", + rank: 1, + title: "Frame Rate", + searchableTexts: ["Frame Rate", "Display", "frame", "rate"] + ), + ] + let index = try NucleoIndex(library: library, entries: entries) + + let resultIDs = try index.search(query: "fr", limit: 5).map(\.id) + + #expect(resultIDs.first == "palette.frameRate") + #expect(!resultIDs.contains("palette.crossFieldOnly")) + } + + @Test func nucleoFFIMatchesAsciiQueryAgainstDiacriticTitle() throws { + guard let library = try NucleoLibrary.loadIfAvailable() else { return } + // Skipped: nucleo FFI dylib not built in this environment. + let entries = [ + FixtureEntry( + id: "workspace.cafe", + rank: 0, + title: "Café Notes", + searchableTexts: ["Café Notes", "Workspace"] + ), + FixtureEntry( + id: "workspace.cargo", + rank: 1, + title: "Cargo Notes", + searchableTexts: ["Cargo Notes", "Workspace"] + ), + ] + let index = try NucleoIndex(library: library, entries: entries) + + let resultIDs = try index.search(query: "cafe", limit: 5).map(\.id) + + #expect(resultIDs.first == "workspace.cafe") + } + + @Test func nucleoFFIHandlesEmptyQuery() throws { + guard let library = try NucleoLibrary.loadIfAvailable() else { return } + // Skipped: nucleo FFI dylib not built in this environment. + let entries = makeOpenFolderEntries() + let index = try NucleoIndex(library: library, entries: entries) + + let resultIDs = try index.search(query: "", limit: 3).map(\.id) + + #expect(resultIDs == ["palette.newWorkspace", "palette.newWindow", "palette.openFolder"]) + } + + @Test func nucleoFFIFindsDeepWorkspaceMatch() throws { + guard let library = try NucleoLibrary.loadIfAvailable() else { return } + // Skipped: nucleo FFI dylib not built in this environment. + let entries = makeLargeWorkspaceSwitcherEntries(count: 5_000) + let index = try NucleoIndex(library: library, entries: entries) + + let results = try index.search(query: "workspace 4913", limit: 10) + + #expect(results.first?.id == "workspace.large.4913") + #expect(results.count <= 10) + } + + @Test func productionNucleoSearchIndexFindsCommandPaletteCommands() throws { + let entries = makeOpenFolderEntries() + let corpus = searchCorpus(entries: entries) + guard let index = CommandPaletteNucleoSearchIndex(entries: corpus) else { return } + // Skipped: nucleo FFI dylib not built in this environment. + + let resultIDs = index.search( + query: "open folder", + resultLimit: 4, + historyBoost: { _, _ in 0 } + )?.map(\.payload) + + #expect( + Array((resultIDs ?? []).prefix(2)) == + ["palette.openFolder", "palette.openFolderInVSCodeInline"] + ) + } + + @Test func productionNucleoSearchIndexAppliesHistoryBoostBeforeLimiting() throws { + let entries = makeOpenFolderEntries() + let corpus = searchCorpus(entries: entries) + guard let index = CommandPaletteNucleoSearchIndex(entries: corpus) else { return } + // Skipped: nucleo FFI dylib not built in this environment. + + let results = index.search( + query: "", + resultLimit: 1, + historyBoost: { commandID, _ in commandID == "palette.openFolder" ? 600 : 0 } + ) + + #expect(results?.map(\.payload) == ["palette.openFolder"]) + } + + @Test func nucleoFFILargeWorkspacePerformanceAndCorrectnessComparison() throws { + guard let library = try NucleoLibrary.loadIfAvailable() else { return } + // Skipped: nucleo FFI dylib not built in this environment. + let entries = makeLargeWorkspaceSwitcherEntries(count: 800) + let corpus = searchCorpus(entries: entries) + let queries = repeatedQueries( + [ + "workspace 799", + "palette latency", + "feature 401", + "cmd-p-search", + "project-642", + "4207", + "9204", + "Window 3", + ], + repetitions: 3 + ) + + var index: NucleoIndex? + let buildMs = benchmarkElapsedMs { + index = try? NucleoIndex(library: library, entries: entries) + } + let nucleoIndex = try #require(index) + + for query in queries.prefix(8) { + _ = optimizedResults(corpus: corpus, query: query, resultLimit: 100) + _ = try nucleoIndex.search(query: query, limit: 100) + } + + let optimizedMs = benchmarkElapsedMs { + for query in queries { + _ = optimizedResults(corpus: corpus, query: query, resultLimit: 100) + } + } + let nucleoMs = benchmarkElapsedMs { + for query in queries { + _ = try? nucleoIndex.search(query: query, limit: 100) + } + } + + let comparison = try correctnessComparison( + corpus: corpus, + queries: Array(Set(queries)).sorted(), + index: nucleoIndex, + resultLimit: 20 + ) + print(String( + format: "BENCH cmd+p nucleo-ffi large-workspaces build=%.2fms swiftOptimized=%.2fms nucleo=%.2fms top1Agreement=%d/%d meanTop10Overlap=%.2f", + buildMs, + optimizedMs, + nucleoMs, + comparison.top1Agreement, + comparison.queryCount, + comparison.meanTop10Overlap + )) + if !comparison.top1Mismatches.isEmpty { + print("CHECK cmd+p nucleo-ffi top1Mismatches \(comparison.top1Mismatches.joined(separator: "; "))") + } + + #expect(comparison.queryCount > 0) + #expect(nucleoMs > 0) + } + + @Test func nucleoFFIFastTypingFrameBudgetComparison() throws { + guard let library = try NucleoLibrary.loadIfAvailable() else { return } + // Skipped: nucleo FFI dylib not built in this environment. + let entries = makeLargeWorkspaceSwitcherEntries(count: 800) + let previewEntries = Array(entries.prefix(128)) + let corpus = searchCorpus(entries: entries) + let previewCorpus = searchCorpus(entries: previewEntries) + let fullIndex = try NucleoIndex(library: library, entries: entries) + let previewIndex = try NucleoIndex(library: library, entries: previewEntries) + let queries = repeatedQueries( + fastTypingPrefixes("cmd-p-search") + fastTypingPrefixes("palette latency"), + repetitions: 2 + ) + + for query in queries.prefix(8) { + _ = optimizedResults(corpus: corpus, query: query, resultLimit: 100) + _ = optimizedResults(corpus: previewCorpus, query: query, resultLimit: 48) + _ = try fullIndex.search(query: query, limit: 100) + _ = try previewIndex.search(query: query, limit: 48) + } + + var swiftFullDurationsMs: [Double] = [] + var swiftPreviewDurationsMs: [Double] = [] + var nucleoFullDurationsMs: [Double] = [] + var nucleoPreviewDurationsMs: [Double] = [] + swiftFullDurationsMs.reserveCapacity(queries.count) + swiftPreviewDurationsMs.reserveCapacity(queries.count) + nucleoFullDurationsMs.reserveCapacity(queries.count) + nucleoPreviewDurationsMs.reserveCapacity(queries.count) + + for query in queries { + swiftFullDurationsMs.append( + benchmarkElapsedMs { + _ = optimizedResults(corpus: corpus, query: query, resultLimit: 100) + } + ) + swiftPreviewDurationsMs.append( + benchmarkElapsedMs { + _ = optimizedResults(corpus: previewCorpus, query: query, resultLimit: 48) + } + ) + nucleoFullDurationsMs.append( + benchmarkElapsedMs { + _ = try? fullIndex.search(query: query, limit: 100) + } + ) + nucleoPreviewDurationsMs.append( + benchmarkElapsedMs { + _ = try? previewIndex.search(query: query, limit: 48) + } + ) + } + + print(String( + format: "BENCH cmd+p nucleo-ffi fast-typing swiftFull=%.2fms swiftPreview=%.2fms nucleoFull=%.2fms nucleoPreview=%.2fms maxSwiftFull=%.2fms maxSwiftPreview=%.2fms maxNucleoFull=%.2fms maxNucleoPreview=%.2fms swiftFullDroppedFrames=%d swiftPreviewDroppedFrames=%d nucleoFullDroppedFrames=%d nucleoPreviewDroppedFrames=%d", + swiftFullDurationsMs.reduce(0, +), + swiftPreviewDurationsMs.reduce(0, +), + nucleoFullDurationsMs.reduce(0, +), + nucleoPreviewDurationsMs.reduce(0, +), + swiftFullDurationsMs.max() ?? 0, + swiftPreviewDurationsMs.max() ?? 0, + nucleoFullDurationsMs.max() ?? 0, + nucleoPreviewDurationsMs.max() ?? 0, + estimatedDroppedFrames(for: swiftFullDurationsMs), + estimatedDroppedFrames(for: swiftPreviewDurationsMs), + estimatedDroppedFrames(for: nucleoFullDurationsMs), + estimatedDroppedFrames(for: nucleoPreviewDurationsMs) + )) + + #expect( + estimatedDroppedFrames(for: nucleoPreviewDurationsMs) <= + estimatedDroppedFrames(for: nucleoFullDurationsMs) + ) + } + + @Test func nucleoFFIEdgeCaseTypingFrameBudgetComparison() throws { + let entries = makeEdgeCasePaletteEntries(generatedWorkspaceCount: 2_000) + let corpus = searchCorpus(entries: entries) + var index: CommandPaletteNucleoSearchIndex? + let buildMs = benchmarkElapsedMs { + index = CommandPaletteNucleoSearchIndex(entries: corpus) + } + guard let productionIndex = index else { return } + // Skipped: nucleo FFI dylib not built in this environment. + let queries = repeatedQueries(edgeCaseTypingQueries(), repetitions: 2) + + for query in queries.prefix(16) { + _ = optimizedResults(corpus: corpus, query: query, resultLimit: 100) + _ = productionIndex.search(query: query, resultLimit: 100) + } + + var swiftDurationsMs: [Double] = [] + var nucleoDurationsMs: [Double] = [] + var boostedNucleoDurationsMs: [Double] = [] + swiftDurationsMs.reserveCapacity(queries.count) + nucleoDurationsMs.reserveCapacity(queries.count) + boostedNucleoDurationsMs.reserveCapacity(queries.count) + + for query in queries { + swiftDurationsMs.append( + benchmarkElapsedMs { + _ = optimizedResults(corpus: corpus, query: query, resultLimit: 100) + } + ) + nucleoDurationsMs.append( + benchmarkElapsedMs { + _ = productionIndex.search(query: query, resultLimit: 100) + } + ) + boostedNucleoDurationsMs.append( + benchmarkElapsedMs { + _ = productionIndex.search( + query: query, + resultLimit: 100, + historyBoost: { commandID, queryIsEmpty in + if commandID == "palette.markWorkspaceUnread" { + return queryIsEmpty ? 300 : 120 + } + return 0 + } + ) + } + ) + } + + let expectedTopResults = [ + ("ims", "workspace.indigoMarkdownStudio"), + ("wunr", "palette.markWorkspaceUnread"), + ("open folder", "palette.openFolder"), + ("workspace 1901", "workspace.large.1901"), + ("cafe", "workspace.cafeUnicodeNotes"), + ] + for (query, expectedID) in expectedTopResults { + #expect( + productionIndex.search(query: query, resultLimit: 10)?.first?.payload == expectedID, + "Unexpected top result for \(query)" + ) + } + + print(String( + format: "BENCH cmd+p nucleo-ffi edge-typing entries=%d queries=%d build=%.2fms swift=%.2fms nucleo=%.2fms boostedNucleo=%.2fms maxSwift=%.2fms p95Swift=%.2fms maxNucleo=%.2fms p95Nucleo=%.2fms maxBoostedNucleo=%.2fms p95BoostedNucleo=%.2fms swiftDroppedFrames=%d nucleoDroppedFrames=%d boostedNucleoDroppedFrames=%d", + entries.count, + queries.count, + buildMs, + swiftDurationsMs.reduce(0, +), + nucleoDurationsMs.reduce(0, +), + boostedNucleoDurationsMs.reduce(0, +), + swiftDurationsMs.max() ?? 0, + percentile(swiftDurationsMs, percentile: 0.95), + nucleoDurationsMs.max() ?? 0, + percentile(nucleoDurationsMs, percentile: 0.95), + boostedNucleoDurationsMs.max() ?? 0, + percentile(boostedNucleoDurationsMs, percentile: 0.95), + estimatedDroppedFrames(for: swiftDurationsMs), + estimatedDroppedFrames(for: nucleoDurationsMs), + estimatedDroppedFrames(for: boostedNucleoDurationsMs) + )) + + #expect( + estimatedDroppedFrames(for: nucleoDurationsMs) <= + estimatedDroppedFrames(for: swiftDurationsMs) + ) + } + + @Test func nucleoFFICallOverheadBenchmark() throws { + guard let library = try NucleoLibrary.loadIfAvailable() else { return } + // Skipped: nucleo FFI dylib not built in this environment. + let entries = makeLargeWorkspaceSwitcherEntries(count: 800) + let corpus = searchCorpus(entries: entries) + let rawIndex = try NucleoIndex(library: library, entries: entries) + let productionIndex = try #require(CommandPaletteNucleoSearchIndex(entries: corpus)) + let noopIterations = 50_000 + let searchIterations = 200 + let queryBytes = Array("cmd-p-search".utf8) + var ffiNoopFailures = 0 + + var outCount = 0 + let ffiNoopMs = benchmarkElapsedMs { + for _ in 0.. [FixtureEntry] { + [ + FixtureEntry( + id: "palette.newWorkspace", + rank: 0, + title: "New Workspace", + searchableTexts: ["New Workspace", "Workspace", "create", "new", "workspace"] + ), + FixtureEntry( + id: "palette.newWindow", + rank: 1, + title: "New Window", + searchableTexts: ["New Window", "Window", "create", "new", "window"] + ), + FixtureEntry( + id: "palette.openFolder", + rank: 2, + title: "Open Folder...", + searchableTexts: ["Open Folder...", "Workspace", "open", "folder", "repository", "project", "directory"] + ), + FixtureEntry( + id: "palette.openFolderInVSCodeInline", + rank: 3, + title: "Open Folder in VS Code (Inline)...", + searchableTexts: [ + "Open Folder in VS Code (Inline)...", + "VS Code Inline", + "open", + "folder", + "directory", + "project", + "vs", + "code", + "inline", + "editor", + "browser", + ] + ), + ] +} + +func makeInitialismWorkspaceEntries() -> [FixtureEntry] { + [ + FixtureEntry( + id: "workspace.yarrowImageSorter", + rank: 0, + title: "Yarrow Image Sorter", + searchableTexts: ["Yarrow Image Sorter", "Workspace", "workspace", "switch", "go"] + ), + FixtureEntry( + id: "workspace.indigoMarkdownStudio", + rank: 1, + title: "Indigo Markdown Studio", + searchableTexts: ["Indigo Markdown Studio", "Workspace", "workspace", "switch", "go"] + ), + FixtureEntry( + id: "workspace.ivoryMeetingNotes", + rank: 2, + title: "Ivory Meeting Notes", + searchableTexts: ["Ivory Meeting Notes", "Workspace", "workspace", "switch", "go"] + ), + FixtureEntry( + id: "workspace.graniteMusicVault", + rank: 3, + title: "Granite Music Vault", + searchableTexts: ["Granite Music Vault", "Workspace", "workspace", "switch", "go"] + ), + FixtureEntry( + id: "workspace.nimbusInvoiceDesk", + rank: 4, + title: "Nimbus Invoice Desk", + searchableTexts: ["Nimbus Invoice Desk", "Workspace", "workspace", "switch", "go"] + ), + ] +} + +func makeEdgeCasePaletteEntries(generatedWorkspaceCount: Int) -> [FixtureEntry] { + var entries = makeOpenFolderEntries() + entries.append( + FixtureEntry( + id: "palette.markWorkspaceUnread", + rank: entries.count, + title: "Mark Workspace as Unread", + searchableTexts: [ + "Mark Workspace as Unread", + "Workspace", + "mark", + "unread", + "notification", + ] + ) + ) + entries.append(contentsOf: makeInitialismWorkspaceEntries().map { entry in + FixtureEntry( + id: entry.id, + rank: entries.count + entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + }) + entries.append( + FixtureEntry( + id: "workspace.cafeUnicodeNotes", + rank: entries.count, + title: "Café Unicode Notes", + searchableTexts: [ + "Café Unicode Notes", + "Cafe Unicode Notes", + "cafe", + "unicode", + "workspace", + ] + ) + ) + + let generatedRankOffset = entries.count + entries.append(contentsOf: makeLargeWorkspaceSwitcherEntries(count: generatedWorkspaceCount).map { entry in + FixtureEntry( + id: entry.id, + rank: generatedRankOffset + entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + }) + return entries +} + +func makeLargeWorkspaceSwitcherEntries(count: Int) -> [FixtureEntry] { + (0.. [CommandPaletteSearchCorpusEntry] { + entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry.id, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } +} + +func optimizedResults( + corpus: [CommandPaletteSearchCorpusEntry], + query: String, + resultLimit: Int +) -> [FixtureResult] { + CommandPaletteSearchEngine.search( + entries: corpus, + query: query, + resultLimit: resultLimit + ) { _, _ in 0 } + .map { + FixtureResult( + id: $0.payload, + rank: $0.rank, + title: $0.title, + score: $0.score + ) + } +} + +func optimizedResults( + entries: [FixtureEntry], + query: String, + resultLimit: Int +) -> [FixtureResult] { + optimizedResults(corpus: searchCorpus(entries: entries), query: query, resultLimit: resultLimit) +} + +func correctnessComparison( + corpus: [CommandPaletteSearchCorpusEntry], + queries: [String], + index: NucleoIndex, + resultLimit: Int +) throws -> ( + queryCount: Int, + top1Agreement: Int, + meanTop10Overlap: Double, + top1Mismatches: [String] +) { + var top1Agreement = 0 + var totalTop10Overlap = 0.0 + var top1Mismatches: [String] = [] + + for query in queries { + let swiftIDs = optimizedResults(corpus: corpus, query: query, resultLimit: resultLimit) + .map(\.id) + let nucleoIDs = try index.search(query: query, limit: resultLimit).map(\.id) + if swiftIDs.first == nucleoIDs.first { + top1Agreement += 1 + } else { + let swiftTop = swiftIDs.first ?? "" + let nucleoTop = nucleoIDs.first ?? "" + top1Mismatches.append("\(query): swift=\(swiftTop) nucleo=\(nucleoTop)") + } + + let swiftTop10 = Set(swiftIDs.prefix(10)) + let nucleoTop10 = Set(nucleoIDs.prefix(10)) + if !swiftTop10.isEmpty || !nucleoTop10.isEmpty { + totalTop10Overlap += Double(swiftTop10.intersection(nucleoTop10).count) / 10.0 + } + } + + return ( + queryCount: queries.count, + top1Agreement: top1Agreement, + meanTop10Overlap: queries.isEmpty ? 0 : totalTop10Overlap / Double(queries.count), + top1Mismatches: top1Mismatches + ) +} + +func fastTypingPrefixes(_ text: String) -> [String] { + text.indices.map { index in + String(text[...index]) + } +} + +func edgeCaseTypingQueries() -> [String] { + var queries: [String] = [] + for text in [ + "ims", + "wunr", + "open folder", + "workspace 1901", + "feature/palette-latency-177", + "project-1999", + "cmd-p-search", + "cafe unicode", + "zzzzzzzz", + ] { + queries.append(contentsOf: fastTypingPrefixes(text)) + } + queries.append(contentsOf: [ + "", + " ", + " OPEN FOLDER ", + "Window 3", + "3007", + "4207", + "9207", + "task/cmd-p-search-7", + "feature palette latency", + "project 42 cmd p", + "workspace/branch:177", + "café", + "Cafe", + "no-match-query", + ]) + return queries +} + +func estimatedDroppedFrames( + for queryDurationsMs: [Double], + frameBudgetMs: Double = 1000.0 / 60.0 +) -> Int { + queryDurationsMs.reduce(0) { total, durationMs in + total + max(0, Int(ceil(durationMs / frameBudgetMs)) - 1) + } +} + +func benchmarkElapsedMs(operation: () -> Void) -> Double { + let start = DispatchTime.now().uptimeNanoseconds + operation() + let elapsed = DispatchTime.now().uptimeNanoseconds - start + return Double(elapsed) / 1_000_000 +} + +func percentile(_ values: [Double], percentile: Double) -> Double { + guard !values.isEmpty else { return 0 } + let sorted = values.sorted() + let clampedPercentile = min(1, max(0, percentile)) + let index = Int((Double(sorted.count - 1) * clampedPercentile).rounded()) + return sorted[index] +} + +func repeatedQueries(_ baseQueries: [String], repetitions: Int) -> [String] { + Array(repeating: baseQueries, count: repetitions).flatMap { $0 } +} diff --git a/Packages/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteOverlayPromotionPolicyTests.swift b/Packages/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteOverlayPromotionPolicyTests.swift new file mode 100644 index 00000000000..aae66fa291d --- /dev/null +++ b/Packages/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteOverlayPromotionPolicyTests.swift @@ -0,0 +1,12 @@ +import Testing +@testable import CmuxCommandPalette + +@Suite("CommandPaletteOverlayPromotionPolicy") +struct CommandPaletteOverlayPromotionPolicyTests { + @Test func promotesOnlyOnHiddenToVisibleTransition() { + #expect(CommandPaletteOverlayPromotionPolicy.shouldPromote(previouslyVisible: false, isVisible: true)) + #expect(!CommandPaletteOverlayPromotionPolicy.shouldPromote(previouslyVisible: true, isVisible: true)) + #expect(!CommandPaletteOverlayPromotionPolicy.shouldPromote(previouslyVisible: false, isVisible: false)) + #expect(!CommandPaletteOverlayPromotionPolicy.shouldPromote(previouslyVisible: true, isVisible: false)) + } +} diff --git a/Packages/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteSearchEngineTests.swift b/Packages/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteSearchEngineTests.swift new file mode 100644 index 00000000000..f0d1941f5be --- /dev/null +++ b/Packages/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteSearchEngineTests.swift @@ -0,0 +1,1216 @@ +import Foundation +import Testing + +@testable import CmuxCommandPalette + +// Serialized: several tests assert on wall-clock benchmark comparisons, which +// parallel execution would skew. +@Suite(.serialized) +struct CommandPaletteSearchEngineTests { + private struct FixtureEntry { + let id: String + let rank: Int + let title: String + let searchableTexts: [String] + } + + private struct FixtureResult: Equatable { + let id: String + let rank: Int + let title: String + let score: Int + let titleMatchIndices: Set + } + + private func makeCommandEntries(count: Int) -> [FixtureEntry] { + (0.. [FixtureEntry] { + (0.. [FixtureEntry] { + (0.. [FixtureEntry] { + [ + FixtureEntry( + id: "command.find", + rank: 0, + title: "Find...", + searchableTexts: ["Find...", "Search", "find", "search"] + ), + FixtureEntry( + id: "command.finder", + rank: 1, + title: "Open Current Directory in Finder", + searchableTexts: ["Open Current Directory in Finder", "Terminal", "finder", "directory", "open"] + ), + FixtureEntry( + id: "command.filter", + rank: 2, + title: "Filter Sidebar Items", + searchableTexts: ["Filter Sidebar Items", "Sidebar", "filter", "sidebar", "items"] + ), + ] + } + + private func makeUpdateCommandEntries() -> [FixtureEntry] { + [ + FixtureEntry( + id: "command.checkForUpdates", + rank: 0, + title: "Check for Updates", + searchableTexts: ["Check for Updates", "Global", "update", "upgrade", "release"] + ), + FixtureEntry( + id: "command.attemptUpdate", + rank: 1, + title: "Attempt Update", + searchableTexts: ["Attempt Update", "Global", "attempt", "check", "update", "upgrade", "release"] + ), + FixtureEntry( + id: "command.applyUpdateIfAvailable", + rank: 2, + title: "Apply Update (If Available)", + searchableTexts: ["Apply Update (If Available)", "Global", "apply", "install", "update", "available"] + ), + ] + } + + private func optimizedResults( + entries: [FixtureEntry], + query: String, + resultLimit: Int? = nil + ) -> [FixtureResult] { + let corpus = entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry.id, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } + + return CommandPaletteSearchEngine.search(entries: corpus, query: query, resultLimit: resultLimit) { _, _ in 0 } + .map { + FixtureResult( + id: $0.payload, + rank: $0.rank, + title: $0.title, + score: $0.score, + titleMatchIndices: $0.titleMatchIndices + ) + } + } + + private func referenceResults( + entries: [FixtureEntry], + query: String + ) -> [FixtureResult] { + let queryIsEmpty = query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let results: [FixtureResult] = queryIsEmpty + ? entries.map { entry in + FixtureResult(id: entry.id, rank: entry.rank, title: entry.title, score: 0, titleMatchIndices: []) + } + : entries.compactMap { entry in + guard let fuzzyScore = weightedReferenceScore( + query: query, + entry: entry + ) else { + return nil + } + return FixtureResult( + id: entry.id, + rank: entry.rank, + title: entry.title, + score: fuzzyScore, + titleMatchIndices: CommandPaletteFuzzyMatcher.matchCharacterIndices( + query: query, + candidate: entry.title + ) + ) + } + + return results.sorted { lhs, rhs in + if lhs.score != rhs.score { return lhs.score > rhs.score } + if lhs.rank != rhs.rank { return lhs.rank < rhs.rank } + return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending + } + } + + private func fastTypingPrefixes(_ text: String) -> [String] { + text.indices.map { index in + String(text[...index]) + } + } + + private func estimatedDroppedFrames( + for queryDurationsMs: [Double], + frameBudgetMs: Double = 1000.0 / 60.0 + ) -> Int { + queryDurationsMs.reduce(0) { total, durationMs in + total + max(0, Int(ceil(durationMs / frameBudgetMs)) - 1) + } + } + + private func weightedReferenceScore( + query: String, + entry: FixtureEntry + ) -> Int? { + guard let fuzzyScore = CommandPaletteFuzzyMatcher.score( + query: query, + candidates: entry.searchableTexts + ) else { + return nil + } + guard let titleScore = CommandPaletteFuzzyMatcher.score( + query: query, + candidate: entry.title + ) else { + return fuzzyScore + } + return max(fuzzyScore, titleScore + 2000) + } + + private func benchmarkElapsedMs(operation: () -> Void) -> Double { + let start = DispatchTime.now().uptimeNanoseconds + operation() + let elapsed = DispatchTime.now().uptimeNanoseconds - start + return Double(elapsed) / 1_000_000 + } + + private func repeatedQueries(_ baseQueries: [String], repetitions: Int) -> [String] { + Array(repeating: baseQueries, count: repetitions).flatMap { $0 } + } + + /// Reproduces the deleted production test-only wrapper + /// `CommandPaletteSearchOrchestrator.commandPreviewMatchCommandIDsForTests` + /// by calling the public `previewSearchMatches` with the same fixed + /// arguments and mapping to command IDs. + private func commandPreviewMatchCommandIDs( + searchCorpus: [CommandPaletteSearchCorpusEntry], + searchIndex: CommandPaletteNucleoSearchIndex?, + candidateCommandIDs: [String], + searchCorpusByID: [String: CommandPaletteSearchCorpusEntry], + query: String, + resultLimit: Int + ) -> [String] { + CommandPaletteSearchOrchestrator.previewSearchMatches( + scope: .commands, + searchIndex: searchIndex, + searchCorpus: searchCorpus, + candidateCommandIDs: candidateCommandIDs, + searchCorpusByID: searchCorpusByID, + query: query, + usageHistory: [:], + queryIsEmpty: CommandPaletteFuzzyMatcher.preparedQuery(query).isEmpty, + historyTimestamp: 0, + resultLimit: resultLimit + ).map(\.commandID) + } + + @Test func optimizedSearchMatchesReferencePipeline() { + let commandEntries = makeCommandEntries(count: 96) + let switcherEntries = makeSwitcherEntries(count: 64) + let queries = [ + "rename", + "rename tab", + "workspace", + "feature-12", + "3004", + "toggle side", + "open dir", + "phoenix", + "apply update", + ] + + for query in queries { + #expect( + optimizedResults(entries: commandEntries, query: query) == + referenceResults(entries: commandEntries, query: query), + "Command corpus mismatch for query \(query)" + ) + #expect( + optimizedResults(entries: switcherEntries, query: query) == + referenceResults(entries: switcherEntries, query: query), + "Switcher corpus mismatch for query \(query)" + ) + } + } + + @Test func multiTokenSearchCanMatchAcrossTitleAndKeywordFields() { + let entries = [ + FixtureEntry( + id: "workspace.projectA", + rank: 0, + title: "Project A", + searchableTexts: ["Project A", "Workspace"] + ), + FixtureEntry( + id: "workspace.notes", + rank: 1, + title: "Notes", + searchableTexts: ["Notes", "Workspace"] + ), + ] + + #expect( + optimizedResults(entries: entries, query: "project workspace").first?.id == + "workspace.projectA" + ) + } + + @Test func limitedSearchReturnsSameTopResultsAsFullSearch() { + let entries = makeLargeWorkspaceSwitcherEntries(count: 800) + let queries = [ + "workspace 799", + "palette latency", + "feature 401", + "cmd-p-search", + "project-642", + "Window 3", + ] + + for query in queries { + let fullResults = optimizedResults(entries: entries, query: query) + let limitedResults = optimizedResults(entries: entries, query: query, resultLimit: 48) + + #expect( + limitedResults == + Array(fullResults.prefix(48)), + "Limited search should preserve full-search ordering and highlight output for query \(query)" + ) + } + } + + @Test func limitedSearchStillFindsDeepWorkspaceMatch() { + let entries = makeLargeWorkspaceSwitcherEntries(count: 5_000) + + let results = optimizedResults( + entries: entries, + query: "workspace 4913", + resultLimit: 10 + ) + + #expect(results.first?.id == "workspace.large.4913") + #expect(results.count <= 10) + } + + @Test func limitedSearchReturnsOnlyRequestedResultCountForBroadWorkspaceQuery() { + let entries = makeLargeWorkspaceSwitcherEntries(count: 1_200) + + let results = optimizedResults( + entries: entries, + query: "workspace", + resultLimit: 100 + ) + + #expect(results.count == 100) + #expect( + results == + Array(optimizedResults(entries: entries, query: "workspace").prefix(100)) + ) + } + + @Test func resolvedSearchMatchesReturnFullFinalResultSetWhenUnbounded() { + let entries = makeLargeWorkspaceSwitcherEntries(count: 150) + let corpus = entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry.id, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } + + let matches = CommandPaletteSearchOrchestrator.resolvedSearchMatches( + searchIndex: nil, + searchCorpus: corpus, + query: "workspace", + usageHistory: [:], + queryIsEmpty: false, + historyTimestamp: 0 + ) + + #expect(matches.count == entries.count) + } + + @Test func nucleoResolvedSearchMatchesReturnFullFinalResultSetWhenUnbounded() throws { + let entries = makeLargeWorkspaceSwitcherEntries(count: 150) + let corpus = entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry.id, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } + guard let searchIndex = CommandPaletteNucleoSearchIndex(entries: corpus) else { return } + // Skipped: nucleo FFI dylib not built in this environment. + + let matches = CommandPaletteSearchOrchestrator.resolvedSearchMatches( + searchIndex: searchIndex, + searchCorpus: corpus, + query: "workspace", + usageHistory: [:], + queryIsEmpty: false, + historyTimestamp: 0 + ) + + #expect(matches.count == entries.count) + } + + @Test func searchCancellationReturnsNoResults() { + let entries = makeCommandEntries(count: 512) + let corpus = entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry.id, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } + var cancellationChecks = 0 + + let results = CommandPaletteSearchEngine.search( + entries: corpus, + query: "rename" + ) { _, _ in + 0 + } shouldCancel: { + cancellationChecks += 1 + return cancellationChecks >= 4 + } + + #expect(results.isEmpty) + #expect(cancellationChecks >= 4) + } + + @Test func commandPreviewSearchUsesFullCommandCorpus() { + let entries = [ + FixtureEntry( + id: "command.find", + rank: 0, + title: "Find...", + searchableTexts: ["Find...", "Search", "find", "search"] + ), + FixtureEntry( + id: "command.finder", + rank: 1, + title: "Open Current Directory in Finder", + searchableTexts: ["Open Current Directory in Finder", "Terminal", "finder", "directory", "open"] + ), + ] + let corpus = entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry.id, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } + let corpusByID = Dictionary(uniqueKeysWithValues: corpus.map { ($0.payload, $0) }) + let searchIndex = CommandPaletteNucleoSearchIndex(entries: corpus) + + let previewCommandIDs = commandPreviewMatchCommandIDs( + searchCorpus: corpus, + searchIndex: searchIndex, + candidateCommandIDs: ["command.find"], + searchCorpusByID: corpusByID, + query: "finde", + resultLimit: 48 + ) + + #expect(previewCommandIDs.first == "command.finder") + } + + @Test func nucleoEmptyResultsFallBackToSwiftSingleEditMatching() throws { + let entries = [ + FixtureEntry( + id: "palette.renameTab", + rank: 0, + title: "Rename Tab...", + searchableTexts: ["Rename Tab...", "rename", "tab", "title"] + ), + FixtureEntry( + id: "palette.openFolder", + rank: 1, + title: "Open Folder...", + searchableTexts: ["Open Folder...", "open", "folder", "directory"] + ), + ] + let corpus = entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry.id, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } + guard let searchIndex = CommandPaletteNucleoSearchIndex(entries: corpus) else { return } + // Skipped: nucleo FFI dylib not built in this environment. + + let matches = CommandPaletteSearchOrchestrator.resolvedSearchMatches( + searchIndex: searchIndex, + searchCorpus: corpus, + query: "renamd", + usageHistory: [:], + queryIsEmpty: CommandPaletteFuzzyMatcher.preparedQuery("renamd").isEmpty, + historyTimestamp: 0, + resultLimit: 10 + ) + + #expect(matches.first?.commandID == "palette.renameTab") + } + + @Test func nucleoPartialResultsIncludeSwiftSingleEditFallback() throws { + let entries = [ + FixtureEntry( + id: "palette.reactNativeMarkdown", + rank: 0, + title: "React Native Markdown", + searchableTexts: ["React Native Markdown", "react", "native", "markdown"] + ), + FixtureEntry( + id: "palette.renameTab", + rank: 1, + title: "Rename Tab...", + searchableTexts: ["Rename Tab...", "rename", "tab", "title"] + ), + FixtureEntry( + id: "palette.openFolder", + rank: 2, + title: "Open Folder...", + searchableTexts: ["Open Folder...", "open", "folder", "directory"] + ), + ] + let corpus = entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry.id, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } + guard let searchIndex = CommandPaletteNucleoSearchIndex(entries: corpus) else { return } + // Skipped: nucleo FFI dylib not built in this environment. + let nucleoOnlyMatches = try #require( + searchIndex.search(query: "renamd", resultLimit: 10) + ) + #expect(!nucleoOnlyMatches.isEmpty) + + let matches = CommandPaletteSearchOrchestrator.resolvedSearchMatches( + searchIndex: searchIndex, + searchCorpus: corpus, + query: "renamd", + usageHistory: [:], + queryIsEmpty: CommandPaletteFuzzyMatcher.preparedQuery("renamd").isEmpty, + historyTimestamp: 0, + resultLimit: 10 + ) + + #expect(matches.first?.commandID == "palette.renameTab") + } + + @Test func nucleoFullPageResultsIncludeSwiftSingleEditFallback() throws { + var entries = (0..<150).map { index in + FixtureEntry( + id: "palette.reactNativeMarkdown.\(index)", + rank: index, + title: "React Native Markdown \(index)", + searchableTexts: ["React Native Markdown \(index)", "react", "native", "markdown"] + ) + } + entries.append( + FixtureEntry( + id: "palette.renameTab", + rank: 200, + title: "Rename Tab...", + searchableTexts: ["Rename Tab...", "rename", "tab", "title"] + ) + ) + let corpus = entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry.id, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } + guard let searchIndex = CommandPaletteNucleoSearchIndex(entries: corpus) else { return } + // Skipped: nucleo FFI dylib not built in this environment. + let nucleoOnlyMatches = try #require( + searchIndex.search(query: "renamd", resultLimit: 10) + ) + #expect(nucleoOnlyMatches.count == 10) + #expect(nucleoOnlyMatches.first?.payload != "palette.renameTab") + + let matches = CommandPaletteSearchOrchestrator.resolvedSearchMatches( + searchIndex: searchIndex, + searchCorpus: corpus, + query: "renamd", + usageHistory: [:], + queryIsEmpty: CommandPaletteFuzzyMatcher.preparedQuery("renamd").isEmpty, + historyTimestamp: 0, + resultLimit: 10 + ) + + #expect(matches.first?.commandID == "palette.renameTab") + } + + @Test func swiftFallbackMergeKeepsCombinedResultsSortedByScore() { + let entries = [ + FixtureEntry( + id: "palette.high", + rank: 0, + title: "High Score", + searchableTexts: ["High Score"] + ), + FixtureEntry( + id: "palette.medium", + rank: 1, + title: "Medium Score", + searchableTexts: ["Medium Score"] + ), + FixtureEntry( + id: "palette.fallback", + rank: 2, + title: "Fallback Score", + searchableTexts: ["Fallback Score"] + ), + ] + let corpus = entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry.id, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } + let corpusByID = Dictionary(uniqueKeysWithValues: corpus.map { ($0.payload, $0) }) + + let matches = CommandPaletteSearchOrchestrator.mergedSwiftFallbackMatches( + [ + CommandPaletteResolvedSearchMatch( + commandID: "palette.fallback", + score: 25, + titleMatchIndices: [] + ) + ], + nucleoMatches: [ + CommandPaletteResolvedSearchMatch( + commandID: "palette.medium", + score: 80, + titleMatchIndices: [] + ), + CommandPaletteResolvedSearchMatch( + commandID: "palette.high", + score: 100, + titleMatchIndices: [] + ), + ], + searchCorpusByID: corpusByID, + limit: 3 + ) + + #expect(matches.map(\.commandID) == ["palette.high", "palette.medium", "palette.fallback"]) + } + + @Test func firstValueDictionaryPreservesFirstDuplicateKey() { + let values = [ + (id: "palette.duplicate", title: "First"), + (id: "palette.unique", title: "Unique"), + (id: "palette.duplicate", title: "Second"), + ] + + let valuesByID = CommandPaletteSearchOrchestrator.firstValueDictionary(values) { $0.id } + + #expect(valuesByID["palette.duplicate"]?.title == "First") + #expect(valuesByID["palette.unique"]?.title == "Unique") + #expect(valuesByID.count == 2) + } + + @Test func nucleoExactPartialResultsDoNotRunSwiftSingleEditFallback() throws { + let entries = [ + FixtureEntry( + id: "workspace.project642", + rank: 0, + title: "Project 642 Command Palette", + searchableTexts: ["Project 642 Command Palette", "Workspace", "project-642", "cmd-p-search"] + ), + FixtureEntry( + id: "workspace.project641", + rank: 1, + title: "Project 641 Markdown Preview", + searchableTexts: ["Project 641 Markdown Preview", "Workspace", "project-641", "markdown-preview"] + ), + ] + let corpus = entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry.id, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } + guard let searchIndex = CommandPaletteNucleoSearchIndex(entries: corpus) else { return } + // Skipped: nucleo FFI dylib not built in this environment. + let nucleoOnlyMatches = try #require( + searchIndex.search(query: "project-642", resultLimit: 10) + ) + #expect(nucleoOnlyMatches.count < 10) + + var cancellationChecks = 0 + let matches = CommandPaletteSearchOrchestrator.resolvedSearchMatches( + searchIndex: searchIndex, + searchCorpus: corpus, + query: "project-642", + usageHistory: [:], + queryIsEmpty: CommandPaletteFuzzyMatcher.preparedQuery("project-642").isEmpty, + historyTimestamp: 0, + resultLimit: 10 + ) { + cancellationChecks += 1 + return false + } + + #expect(matches.first?.commandID == "workspace.project642") + #expect(cancellationChecks == 2) + } + + @Test func commandSearchPrefersOpenFolderForOpenFolderQuery() { + let entries = [ + FixtureEntry( + id: "palette.newWorkspace", + rank: 0, + title: "New Workspace", + searchableTexts: ["New Workspace", "Workspace", "create", "new", "workspace"] + ), + FixtureEntry( + id: "palette.newWindow", + rank: 1, + title: "New Window", + searchableTexts: ["New Window", "Window", "create", "new", "window"] + ), + FixtureEntry( + id: "palette.openFolder", + rank: 2, + title: "Open Folder...", + searchableTexts: ["Open Folder...", "Workspace", "open", "folder", "repository", "project", "directory"] + ), + FixtureEntry( + id: "palette.openFolderInVSCodeInline", + rank: 3, + title: "Open Folder in VS Code (Inline)...", + searchableTexts: [ + "Open Folder in VS Code (Inline)...", + "VS Code Inline", + "open", + "folder", + "directory", + "project", + "vs", + "code", + "inline", + "editor", + "browser", + ] + ), + ] + + #expect( + optimizedResults(entries: entries, query: "open folder").prefix(2).map(\.id) == + ["palette.openFolder", "palette.openFolderInVSCodeInline"] + ) + } + + @Test func searchMatchesSingleOmittedCharacterInCommandWordPrefix() { + let entries = makeFinderCommandEntries() + + #expect( + optimizedResults(entries: entries, query: "findr").first?.id == + "command.finder" + ) + } + + @Test func searchMatchesSingleInsertedCharacterInCommandWordPrefix() { + let entries = makeFinderCommandEntries() + + #expect( + optimizedResults(entries: entries, query: "findder").first?.id == + "command.finder" + ) + } + + @Test func searchMatchesSingleSubstitutedCharacterInCommandWordPrefix() { + let entries = makeFinderCommandEntries() + + #expect( + optimizedResults(entries: entries, query: "fander").first?.id == + "command.finder" + ) + } + + @Test func searchMatchesSingleTransposedCharacterInCommandWordPrefix() { + let entries = makeFinderCommandEntries() + + #expect( + optimizedResults(entries: entries, query: "fidner").first?.id == + "command.finder" + ) + } + + @Test func searchRejectsMultipleEditsInCommandWordPrefix() { + let entries = makeFinderCommandEntries() + + #expect( + optimizedResults(entries: entries, query: "fadnr").first?.id != + "command.finder" + ) + } + + @Test func searchPrefersTitleMatchOverKeywordOnlyMatchForCheckQuery() { + let results = optimizedResults(entries: makeUpdateCommandEntries(), query: "check") + + #expect( + results.prefix(2).map(\.id) == + ["command.checkForUpdates", "command.attemptUpdate"] + ) + } + + @Test func previewCandidateCommandIDsAreBounded() { + let resultIDs = (0..<500).map { "command.\($0)" } + + let previewCandidateIDs = CommandPaletteSearchOrchestrator.previewCandidateCommandIDs( + resultIDs: resultIDs, + limit: 192 + ) + + #expect(previewCandidateIDs.count == 192) + #expect(previewCandidateIDs.first == "command.0") + #expect(previewCandidateIDs.last == "command.191") + } + + @Test func synchronousSeedRunsOnlyWhenScopeHasNoVisibleResultsAndSearchIndexIsReady() { + #expect( + CommandPaletteSearchOrchestrator.shouldSynchronouslySeedResults( + hasVisibleResultsForScope: false, + hasSearchIndex: true, + corpusCount: 5_000 + ) + ) + #expect( + CommandPaletteSearchOrchestrator.shouldSynchronouslySeedResults( + hasVisibleResultsForScope: false, + hasSearchIndex: false, + corpusCount: 256 + ) + ) + #expect( + !CommandPaletteSearchOrchestrator.shouldSynchronouslySeedResults( + hasVisibleResultsForScope: false, + hasSearchIndex: false, + corpusCount: 257 + ) + ) + #expect( + !CommandPaletteSearchOrchestrator.shouldSynchronouslySeedResults( + hasVisibleResultsForScope: true, + hasSearchIndex: true, + corpusCount: 5_000 + ) + ) + } + + @Test func pendingEmptyStateIsNotPreservedWhenSearchIsNotPending() { + #expect( + !CommandPaletteSearchOrchestrator.shouldPreserveEmptyStateWhileSearchPending( + isSearchPending: false, + visibleResultsScopeMatches: true, + resolvedSearchScopeMatches: true, + resolvedSearchFingerprintMatches: true, + resolvedResultsAreEmpty: true + ) + ) + } + + @Test func pendingEmptyStateIsPreservedForSameResolvedNoMatchQuery() { + #expect( + CommandPaletteSearchOrchestrator.shouldPreserveEmptyStateWhileSearchPending( + isSearchPending: true, + visibleResultsScopeMatches: true, + resolvedSearchScopeMatches: true, + resolvedSearchFingerprintMatches: true, + resolvedResultsAreEmpty: true + ) + ) + } + + @Test func pendingEmptyStateIsPreservedForSameScopeNoMatchInPlaceEdit() { + #expect( + CommandPaletteSearchOrchestrator.shouldPreserveEmptyStateWhileSearchPending( + isSearchPending: true, + visibleResultsScopeMatches: true, + resolvedSearchScopeMatches: true, + resolvedSearchFingerprintMatches: true, + resolvedResultsAreEmpty: true + ) + ) + } + + @Test func pendingEmptyStateIsNotPreservedWhenResolvedResultsMayBeStale() { + #expect( + !CommandPaletteSearchOrchestrator.shouldPreserveEmptyStateWhileSearchPending( + isSearchPending: true, + visibleResultsScopeMatches: false, + resolvedSearchScopeMatches: true, + resolvedSearchFingerprintMatches: true, + resolvedResultsAreEmpty: true + ) + ) + #expect( + !CommandPaletteSearchOrchestrator.shouldPreserveEmptyStateWhileSearchPending( + isSearchPending: true, + visibleResultsScopeMatches: true, + resolvedSearchScopeMatches: false, + resolvedSearchFingerprintMatches: true, + resolvedResultsAreEmpty: true + ) + ) + #expect( + !CommandPaletteSearchOrchestrator.shouldPreserveEmptyStateWhileSearchPending( + isSearchPending: true, + visibleResultsScopeMatches: true, + resolvedSearchScopeMatches: true, + resolvedSearchFingerprintMatches: false, + resolvedResultsAreEmpty: true + ) + ) + #expect( + !CommandPaletteSearchOrchestrator.shouldPreserveEmptyStateWhileSearchPending( + isSearchPending: true, + visibleResultsScopeMatches: true, + resolvedSearchScopeMatches: true, + resolvedSearchFingerprintMatches: true, + resolvedResultsAreEmpty: false + ) + ) + } + + @Test func commandSearchBenchmarkBeatsLegacyPipeline() { + let entries = makeCommandEntries(count: 900) + let corpus = entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry.id, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } + let queries = repeatedQueries( + ["rename", "rename tab", "open dir", "toggle side", "apply update", "notif", "split right", "cmux"], + repetitions: 12 + ) + + for query in queries.prefix(8) { + _ = referenceResults(entries: entries, query: query) + _ = CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 } + } + + let referenceMs = benchmarkElapsedMs { + for query in queries { + _ = referenceResults(entries: entries, query: query) + } + } + let optimizedMs = benchmarkElapsedMs { + for query in queries { + _ = CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 } + } + } + + print(String(format: "BENCH cmd+shift+p reference=%.2fms optimized=%.2fms", referenceMs, optimizedMs)) + #expect( + optimizedMs < referenceMs * 1.25, + "Optimized command search regressed significantly: reference=\(referenceMs) optimized=\(optimizedMs)" + ) + } + + @Test func switcherSearchBenchmarkBeatsLegacyPipeline() { + let entries = makeSwitcherEntries(count: 400) + let corpus = entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry.id, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } + let queries = repeatedQueries( + ["workspace 12", "phoenix", "feature-18", "rename-tab", "3007", "9202", "switch", "worktrees"], + repetitions: 12 + ) + + for query in queries.prefix(8) { + _ = referenceResults(entries: entries, query: query) + _ = CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 } + } + + let referenceMs = benchmarkElapsedMs { + for query in queries { + _ = referenceResults(entries: entries, query: query) + } + } + let optimizedMs = benchmarkElapsedMs { + for query in queries { + _ = CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 } + } + } + + print(String(format: "BENCH cmd+p reference=%.2fms optimized=%.2fms", referenceMs, optimizedMs)) + #expect( + optimizedMs < referenceMs * 1.25, + "Optimized switcher search regressed significantly: reference=\(referenceMs) optimized=\(optimizedMs)" + ) + } + + @Test func largeWorkspaceSwitcherSearchBenchmarkAvoidsPerQueryPreparationCost() { + let entries = makeLargeWorkspaceSwitcherEntries(count: 800) + let corpus = entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry.id, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } + let queries = repeatedQueries( + [ + "workspace 799", + "palette latency", + "feature 401", + "cmd-p-search", + "project-642", + "4207", + "9204", + "Window 3", + ], + repetitions: 3 + ) + + for query in queries.prefix(8) { + _ = referenceResults(entries: entries, query: query) + _ = CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 } + } + + let referenceMs = benchmarkElapsedMs { + for query in queries { + _ = referenceResults(entries: entries, query: query) + } + } + let optimizedMs = benchmarkElapsedMs { + for query in queries { + _ = CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 } + } + } + + print(String(format: "BENCH cmd+p large-workspaces reference=%.2fms optimized=%.2fms", referenceMs, optimizedMs)) + #expect( + optimizedMs < referenceMs * 0.80, + "Large switcher search should reuse prepared corpus data: reference=\(referenceMs) optimized=\(optimizedMs)" + ) + } + + @Test func fastTypingPreviewSearchBenchmarkReportsEstimatedDroppedFrames() { + let entries = makeLargeWorkspaceSwitcherEntries(count: 800) + let corpus = entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry.id, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } + let visibleCandidateCorpus = Array(corpus.prefix(128)) + let queries = repeatedQueries( + fastTypingPrefixes("cmd-p-search") + fastTypingPrefixes("palette latency"), + repetitions: 2 + ) + + for query in queries.prefix(8) { + _ = CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 } + _ = CommandPaletteSearchEngine.search(entries: corpus, query: query, resultLimit: 100) { _, _ in 0 } + _ = CommandPaletteSearchEngine.search(entries: visibleCandidateCorpus, query: query, resultLimit: 48) { _, _ in 0 } + } + + var fullDurationsMs: [Double] = [] + var cappedFullDurationsMs: [Double] = [] + var previewDurationsMs: [Double] = [] + fullDurationsMs.reserveCapacity(queries.count) + cappedFullDurationsMs.reserveCapacity(queries.count) + previewDurationsMs.reserveCapacity(queries.count) + + for query in queries { + fullDurationsMs.append( + benchmarkElapsedMs { + _ = CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 } + } + ) + cappedFullDurationsMs.append( + benchmarkElapsedMs { + _ = CommandPaletteSearchEngine.search(entries: corpus, query: query, resultLimit: 100) { _, _ in 0 } + } + ) + previewDurationsMs.append( + benchmarkElapsedMs { + _ = CommandPaletteSearchEngine.search(entries: visibleCandidateCorpus, query: query, resultLimit: 48) { _, _ in 0 } + } + ) + } + + let fullMs = fullDurationsMs.reduce(0, +) + let cappedFullMs = cappedFullDurationsMs.reduce(0, +) + let previewMs = previewDurationsMs.reduce(0, +) + let fullDroppedFrames = estimatedDroppedFrames(for: fullDurationsMs) + let cappedFullDroppedFrames = estimatedDroppedFrames(for: cappedFullDurationsMs) + let previewDroppedFrames = estimatedDroppedFrames(for: previewDurationsMs) + let maxFullMs = fullDurationsMs.max() ?? 0 + let maxCappedFullMs = cappedFullDurationsMs.max() ?? 0 + let maxPreviewMs = previewDurationsMs.max() ?? 0 + let maxPreviewQuery = previewDurationsMs.enumerated().max(by: { $0.element < $1.element }).map { + queries[$0.offset] + } ?? "" + + print(String( + format: "BENCH cmd+p fast-typing full=%.2fms cappedFull=%.2fms visiblePreview=%.2fms maxFull=%.2fms maxCappedFull=%.2fms maxVisiblePreview=%.2fms maxVisiblePreviewQuery=%@ fullDroppedFrames=%d cappedFullDroppedFrames=%d visiblePreviewDroppedFrames=%d", + fullMs, + cappedFullMs, + previewMs, + maxFullMs, + maxCappedFullMs, + maxPreviewMs, + maxPreviewQuery, + fullDroppedFrames, + cappedFullDroppedFrames, + previewDroppedFrames + )) + #expect( + cappedFullMs < fullMs, + "Capped full-corpus search should avoid preparing results the UI cannot render: full=\(fullMs) capped=\(cappedFullMs)" + ) + #expect( + cappedFullDroppedFrames <= fullDroppedFrames, + "Capped full-corpus search should not increase estimated frame-budget misses: full=\(fullDroppedFrames) capped=\(cappedFullDroppedFrames)" + ) + #expect( + previewMs < cappedFullMs, + "Visible-candidate preview search should avoid full-corpus work during fast typing: capped=\(cappedFullMs) preview=\(previewMs)" + ) + #expect( + previewDroppedFrames <= cappedFullDroppedFrames, + "Preview search should not increase estimated frame-budget misses: capped=\(cappedFullDroppedFrames) preview=\(previewDroppedFrames)" + ) + } +} diff --git a/Sources/App/TerminalDirectoryOpenSupport.swift b/Sources/App/TerminalDirectoryOpenSupport.swift index 047243bde28..ccb05c0250b 100644 --- a/Sources/App/TerminalDirectoryOpenSupport.swift +++ b/Sources/App/TerminalDirectoryOpenSupport.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxCommandPalette import Darwin import Foundation @@ -937,3 +938,12 @@ enum WorkspaceShortcutMapper { return nil } } + +extension CommandPaletteContextKeys { + /// Typed app-side overload over the package's raw-value key builder, so + /// palette context keys keep the exact `terminal.openTarget..available` + /// format without the package importing the terminal domain. + static func terminalOpenTargetAvailable(_ target: TerminalDirectoryOpenTarget) -> String { + terminalOpenTargetAvailable(rawValue: target.rawValue) + } +} diff --git a/Sources/CommandPalette/CommandPaletteSettingsToggle.swift b/Sources/CommandPalette/CommandPaletteSettingsToggle.swift index d858f6fd542..397c77f920d 100644 --- a/Sources/CommandPalette/CommandPaletteSettingsToggle.swift +++ b/Sources/CommandPalette/CommandPaletteSettingsToggle.swift @@ -1,3 +1,4 @@ +import CmuxCommandPalette import Foundation struct CommandPaletteSettingToggleDescriptor: Sendable { diff --git a/Sources/ContentView+AuthCommandPalette.swift b/Sources/ContentView+AuthCommandPalette.swift index 8b952f45058..1bd8198cc58 100644 --- a/Sources/ContentView+AuthCommandPalette.swift +++ b/Sources/ContentView+AuthCommandPalette.swift @@ -1,3 +1,4 @@ +import CmuxCommandPalette import AppKit import Foundation diff --git a/Sources/ContentView+MoveTabToNewWorkspace.swift b/Sources/ContentView+MoveTabToNewWorkspace.swift index cfcee7b8222..4a7cf21c12f 100644 --- a/Sources/ContentView+MoveTabToNewWorkspace.swift +++ b/Sources/ContentView+MoveTabToNewWorkspace.swift @@ -1,3 +1,4 @@ +import CmuxCommandPalette import AppKit import SwiftUI diff --git a/Sources/ContentView+RightSidebarCommandPalette.swift b/Sources/ContentView+RightSidebarCommandPalette.swift index 34bafe3aff9..d8539c87e3d 100644 --- a/Sources/ContentView+RightSidebarCommandPalette.swift +++ b/Sources/ContentView+RightSidebarCommandPalette.swift @@ -1,3 +1,4 @@ +import CmuxCommandPalette import AppKit extension ContentView { diff --git a/Sources/ContentView+ViewCommandPalette.swift b/Sources/ContentView+ViewCommandPalette.swift index 6a434867acb..2a70aabebf3 100644 --- a/Sources/ContentView+ViewCommandPalette.swift +++ b/Sources/ContentView+ViewCommandPalette.swift @@ -1,3 +1,4 @@ +import CmuxCommandPalette import Foundation extension ContentView { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 1ebf08a926a..a1441d97ccf 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1,5 +1,6 @@ import AppKit import CmuxSocketControl +import CmuxCommandPalette import Bonsplit import Combine import CmuxSidebarInterpreterClient @@ -1170,126 +1171,12 @@ struct ContentView: View { @FocusState private var isCommandPaletteSearchFocused: Bool @FocusState private var isCommandPaletteRenameFocused: Bool - private enum CommandPaletteMode { - case commands - case renameInput(CommandPaletteRenameTarget) - case renameConfirm(CommandPaletteRenameTarget, proposedName: String) - case workspaceDescriptionInput(CommandPaletteWorkspaceDescriptionTarget) - } - - enum CommandPalettePendingActivation: Equatable { - case selected(requestID: UInt64, fallbackSelectedIndex: Int, preferredCommandID: String?) - case command(requestID: UInt64, commandID: String) - } - - enum CommandPaletteResolvedActivation: Equatable { - case selected(index: Int) - case command(commandID: String) - } - - struct CommandPalettePendingActivationResolutionResult: Equatable { - let resolvedActivation: CommandPaletteResolvedActivation? - let shouldClearPendingActivation: Bool - } - - private struct CommandPaletteRenameTarget: Equatable { - enum Kind: Equatable { - case workspace(workspaceId: UUID) - case tab(workspaceId: UUID, panelId: UUID) - } - - let kind: Kind - let currentName: String - - var title: String { - switch kind { - case .workspace: - return String(localized: "commandPalette.rename.workspaceTitle", defaultValue: "Rename Workspace") - case .tab: - return String(localized: "commandPalette.rename.tabTitle", defaultValue: "Rename Tab") - } - } - - var description: String { - switch kind { - case .workspace: - return String(localized: "commandPalette.rename.workspaceDescription", defaultValue: "Choose a custom workspace name.") - case .tab: - return String(localized: "commandPalette.rename.tabDescription", defaultValue: "Choose a custom tab name.") - } - } - - var placeholder: String { - switch kind { - case .workspace: - return String(localized: "commandPalette.rename.workspacePlaceholder", defaultValue: "Workspace name") - case .tab: - return String(localized: "commandPalette.rename.tabPlaceholder", defaultValue: "Tab name") - } - } - } - - private struct CommandPaletteWorkspaceDescriptionTarget: Equatable { - let workspaceId: UUID - let currentDescription: String - - var placeholder: String { - String( - localized: "commandPalette.description.workspacePlaceholder", - defaultValue: "Workspace description" - ) - } - - var inputHint: String { - String( - localized: "commandPalette.description.workspaceInputHint", - defaultValue: "Press Enter to save. Press Shift-Enter for a new line, or Escape to cancel." - ) - } - } - private struct CommandPaletteRestoreFocusTarget { let workspaceId: UUID let panelId: UUID let intent: PanelFocusIntent } - private enum CommandPaletteInputFocusTarget { - case search - case rename - } - - private enum CommandPaletteTextSelectionBehavior { - case caretAtEnd - case selectAll - } - - private struct CommandPaletteInputFocusPolicy { - let focusTarget: CommandPaletteInputFocusTarget - let selectionBehavior: CommandPaletteTextSelectionBehavior - - static let search = CommandPaletteInputFocusPolicy( - focusTarget: .search, - selectionBehavior: .caretAtEnd - ) - } - - private struct CommandPaletteCommand: Identifiable { - let id: String - let rank: Int - let title: String - let subtitle: String - let shortcutHint: String? - let kindLabel: String? - let keywords: [String] - let dismissOnRun: Bool - let action: () -> Void - - var searchableTexts: [String] { - [title, subtitle] + keywords - } - } - static func tmuxWorkspacePaneExactRect( for panel: Panel, in contentView: NSView @@ -1434,134 +1321,6 @@ struct ContentView: View { ) } - struct CommandPaletteContextSnapshot { - private var boolValues: [String: Bool] = [:] - private var stringValues: [String: String] = [:] - - init() {} - - mutating func setBool(_ key: String, _ value: Bool) { - boolValues[key] = value - } - - mutating func setString(_ key: String, _ value: String?) { - guard let value, !value.isEmpty else { - stringValues.removeValue(forKey: key) - return - } - stringValues[key] = value - } - - func bool(_ key: String) -> Bool { - boolValues[key] ?? false - } - - func string(_ key: String) -> String? { - stringValues[key] - } - - func fingerprint() -> Int { - ContentView.commandPaletteContextFingerprint( - boolValues: boolValues, - stringValues: stringValues - ) - } - } - - private struct CommandPaletteCommandsContext { - let snapshot: CommandPaletteContextSnapshot - } - - enum CommandPaletteContextKeys { - static let hasWorkspace = "workspace.hasSelection" - static let workspaceName = "workspace.name" - static let workspaceHasCustomName = "workspace.hasCustomName" - static let workspaceHasCustomDescription = "workspace.hasCustomDescription" - static let workspaceMinimalModeEnabled = "workspace.minimalModeEnabled" - static let workspaceShouldPin = "workspace.shouldPin" - static let workspaceHasPullRequests = "workspace.hasPullRequests" - static let workspaceHasSplits = "workspace.hasSplits" - static let workspaceHasPeers = "workspace.hasPeers" - static let workspaceHasAbove = "workspace.hasAbove" - static let workspaceHasBelow = "workspace.hasBelow" - static let workspaceCanMarkRead = "workspace.canMarkRead" - static let workspaceCanMarkUnread = "workspace.canMarkUnread" - static let sidebarMatchTerminalBackground = "sidebar.matchTerminalBackground" - static let hasFocusedPanel = "panel.hasFocus" - static let panelName = "panel.name" - static let panelIsBrowser = "panel.isBrowser" - static let panelBrowserFocusModeActive = "panel.browserFocusModeActive" - static let panelBrowserOmnibarVisible = "panel.browser.omnibarVisible" - static let panelIsMarkdown = "panel.isMarkdown" - static let panelIsTerminal = "panel.isTerminal" - static let panelHasPane = "panel.hasPane" - static let panelHasForkableAgent = "panel.hasForkableAgent" - static let panelHasCustomName = "panel.hasCustomName" - static let panelShouldPin = "panel.shouldPin" - static let panelHasUnread = "panel.hasUnread" - static let panelCanMoveToNewWorkspace = "panel.canMoveToNewWorkspace" - static let updateHasAvailable = "update.hasAvailable" - static let cliInstalledInPATH = "cli.installedInPATH" - static let defaultTerminalIsDefault = "defaultTerminal.isDefault" - static let browserDisabled = "browser.disabled" - static let authSignedIn = "auth.signedIn" - static let authWorking = "auth.working" - static func terminalOpenTargetAvailable(_ target: TerminalDirectoryOpenTarget) -> String { - "terminal.openTarget.\(target.rawValue).available" - } - } - - struct CommandPaletteCommandContribution { - let commandId: String - let title: (CommandPaletteContextSnapshot) -> String - let subtitle: (CommandPaletteContextSnapshot) -> String - let shortcutHint: String? - let keywords: [String] - let dismissOnRun: Bool - let when: (CommandPaletteContextSnapshot) -> Bool - let enablement: (CommandPaletteContextSnapshot) -> Bool - - init( - commandId: String, - title: @escaping (CommandPaletteContextSnapshot) -> String, - subtitle: @escaping (CommandPaletteContextSnapshot) -> String, - shortcutHint: String? = nil, - keywords: [String] = [], - dismissOnRun: Bool = true, - when: @escaping (CommandPaletteContextSnapshot) -> Bool = { _ in true }, - enablement: @escaping (CommandPaletteContextSnapshot) -> Bool = { _ in true } - ) { - self.commandId = commandId - self.title = title - self.subtitle = subtitle - self.shortcutHint = shortcutHint - self.keywords = keywords - self.dismissOnRun = dismissOnRun - self.when = when - self.enablement = enablement - } - } - - struct CommandPaletteHandlerRegistry { - private var handlers: [String: () -> Void] = [:] - - mutating func register(commandId: String, handler: @escaping () -> Void) { - handlers[commandId] = handler - } - - func handler(for commandId: String) -> (() -> Void)? { - handlers[commandId] - } - } - - private struct CommandPaletteSearchResult: Identifiable { - let command: CommandPaletteCommand - let score: Int - let titleMatchIndices: Set - - var id: String { command.id } - } - private struct CommandPaletteSwitcherWindowContext { let windowId: UUID let tabManager: TabManager @@ -1569,27 +1328,6 @@ struct ContentView: View { let windowLabel: String? } - struct CommandPaletteSwitcherFingerprintWorkspace: Sendable { - let id: UUID - let displayName: String - let metadata: CommandPaletteSwitcherSearchMetadata - let surfaces: [CommandPaletteSwitcherFingerprintSurface] - } - - struct CommandPaletteSwitcherFingerprintSurface: Sendable { - let id: UUID - let displayName: String - let kindLabel: String - let metadata: CommandPaletteSwitcherSearchMetadata - } - - struct CommandPaletteSwitcherFingerprintContext: Sendable { - let windowId: UUID - let windowLabel: String? - let selectedWorkspaceId: UUID? - let workspaces: [CommandPaletteSwitcherFingerprintWorkspace] - } - private static let fixedSidebarResizeCursor = NSCursor( image: NSCursor.resizeLeftRight.image, hotSpot: NSCursor.resizeLeftRight.hotSpot @@ -5661,7 +5399,7 @@ struct ContentView: View { } ) } - return Self.commandPaletteSwitcherFingerprint(windowContexts: fingerprintContexts) + return CommandPaletteSwitcherFingerprintContext.fingerprint(windowContexts: fingerprintContexts) } private static func commandPaletteHighlightedTitleText(_ title: String, matchedIndices: Set) -> Text { @@ -8734,67 +8472,6 @@ struct ContentView: View { ) } - static func commandPaletteContextFingerprint( - boolValues: [String: Bool], - stringValues: [String: String] - ) -> Int { - var hasher = Hasher() - for key in boolValues.keys.sorted() { - hasher.combine(key) - hasher.combine(boolValues[key] ?? false) - } - for key in stringValues.keys.sorted() { - hasher.combine(key) - hasher.combine(stringValues[key] ?? "") - } - return hasher.finalize() - } - - static func commandPaletteSwitcherFingerprint( - windowContexts: [CommandPaletteSwitcherFingerprintContext] - ) -> Int { - var hasher = Hasher() - hasher.combine(windowContexts.count) - for context in windowContexts { - hasher.combine(context.windowId) - hasher.combine(context.windowLabel) - hasher.combine(context.selectedWorkspaceId) - hasher.combine(context.workspaces.count) - for workspace in context.workspaces { - hasher.combine(workspace.id) - hasher.combine(workspace.displayName) - combineCommandPaletteSwitcherSearchMetadata(workspace.metadata, into: &hasher) - hasher.combine(workspace.surfaces.count) - for surface in workspace.surfaces { - hasher.combine(surface.id) - hasher.combine(surface.displayName) - hasher.combine(surface.kindLabel) - combineCommandPaletteSwitcherSearchMetadata(surface.metadata, into: &hasher) - } - } - } - return hasher.finalize() - } - - static func combineCommandPaletteSwitcherSearchMetadata( - _ metadata: CommandPaletteSwitcherSearchMetadata, - into hasher: inout Hasher - ) { - hasher.combine(metadata.directories.count) - for directory in metadata.directories { - hasher.combine(directory) - } - hasher.combine(metadata.branches.count) - for branch in metadata.branches { - hasher.combine(branch) - } - hasher.combine(metadata.ports.count) - for port in metadata.ports { - hasher.combine(port) - } - hasher.combine(metadata.description ?? "") - } - static func commandPaletteScrollPositionAnchor( selectedIndex: Int, resultCount: Int diff --git a/Sources/ContentViewIdentifierCopyCommands.swift b/Sources/ContentViewIdentifierCopyCommands.swift index c0e8b0ac290..f9820e14ad2 100644 --- a/Sources/ContentViewIdentifierCopyCommands.swift +++ b/Sources/ContentViewIdentifierCopyCommands.swift @@ -1,3 +1,4 @@ +import CmuxCommandPalette import AppKit import Foundation diff --git a/Sources/TextBoxMentionCandidateIndex.swift b/Sources/TextBoxMentionCandidateIndex.swift index 457e5fcc57d..ab493884d33 100644 --- a/Sources/TextBoxMentionCandidateIndex.swift +++ b/Sources/TextBoxMentionCandidateIndex.swift @@ -1,3 +1,4 @@ +import CmuxCommandPalette import Foundation struct TextBoxMentionCandidateIndex: Sendable { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 026dc12e411..6942c987b52 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -175,6 +175,7 @@ B9000033A1B2C3D4E5F60719 /* CMUXCLI+TopRendering.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000032A1B2C3D4E5F60719 /* CMUXCLI+TopRendering.swift */; }; C0DE31390000000000000105 /* CMUXCLIErrorOutputRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE31390000000000000106 /* CMUXCLIErrorOutputRegressionTests.swift */; }; A72C9F4179B54DF38E99A021 /* CmuxCLIPathInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4FE96C3F394FC6A6D4B018 /* CmuxCLIPathInstaller.swift */; }; + C9A1C00000000000000000A3 /* CmuxCommandPalette in Frameworks */ = {isa = PBXBuildFile; productRef = C9A1C00000000000000000A2 /* CmuxCommandPalette */; }; A5001650 /* CmuxConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001651 /* CmuxConfig.swift */; }; C0DEF0A40000000000000001 /* CmuxConfigContextMenuTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0A40000000000000002 /* CmuxConfigContextMenuTests.swift */; }; A5001652 /* CmuxConfigExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001653 /* CmuxConfigExecutor.swift */; }; @@ -268,11 +269,8 @@ C0DEC0DE000000000000F202 /* CommandPaletteNucleoFFILibrarySupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEC0DE000000000000F201 /* CommandPaletteNucleoFFILibrarySupport.swift */; }; C0DEC0DE000000000000F102 /* CommandPaletteNucleoFFITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEC0DE000000000000F101 /* CommandPaletteNucleoFFITests.swift */; }; C0DEC0DE000000000000F204 /* CommandPaletteNucleoFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEC0DE000000000000F203 /* CommandPaletteNucleoFixtures.swift */; }; - C0DEFF100000000000000001 /* CommandPaletteNucleoSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFF100000000000000002 /* CommandPaletteNucleoSearch.swift */; }; C0DEFF200000000000000001 /* CommandPaletteOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFF200000000000000002 /* CommandPaletteOverlay.swift */; }; - A8CBA43C2DA5AB9E3A1E65A4 /* CommandPaletteSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38A09EB2E92203B2E95923A7 /* CommandPaletteSearch.swift */; }; A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008382 /* CommandPaletteSearchEngineTests.swift */; }; - C0DEFF300000000000000001 /* CommandPaletteSearchOrchestrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFF300000000000000002 /* CommandPaletteSearchOrchestrator.swift */; }; C0DEF4110000000000000001 /* CommandPaletteSettingsToggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF4110000000000000002 /* CommandPaletteSettingsToggle.swift */; }; C0DEF4120000000000000001 /* CommandPaletteSettingsToggleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF4120000000000000002 /* CommandPaletteSettingsToggleTests.swift */; }; C1713006C1713006C1713006 /* CommandPaletteShortcutCustomizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1713005C1713005C1713005 /* CommandPaletteShortcutCustomizationTests.swift */; }; @@ -1082,11 +1080,8 @@ C0DEC0DE000000000000F201 /* CommandPaletteNucleoFFILibrarySupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteNucleoFFILibrarySupport.swift; sourceTree = ""; }; C0DEC0DE000000000000F101 /* CommandPaletteNucleoFFITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteNucleoFFITests.swift; sourceTree = ""; }; C0DEC0DE000000000000F203 /* CommandPaletteNucleoFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteNucleoFixtures.swift; sourceTree = ""; }; - C0DEFF100000000000000002 /* CommandPaletteNucleoSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPalette/CommandPaletteNucleoSearch.swift; sourceTree = ""; }; C0DEFF200000000000000002 /* CommandPaletteOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPalette/CommandPaletteOverlay.swift; sourceTree = ""; }; - 38A09EB2E92203B2E95923A7 /* CommandPaletteSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPalette/CommandPaletteSearch.swift; sourceTree = ""; }; A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = ""; }; - C0DEFF300000000000000002 /* CommandPaletteSearchOrchestrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPalette/CommandPaletteSearchOrchestrator.swift; sourceTree = ""; }; C0DEF4110000000000000002 /* CommandPaletteSettingsToggle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPalette/CommandPaletteSettingsToggle.swift; sourceTree = ""; }; C0DEF4120000000000000002 /* CommandPaletteSettingsToggleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSettingsToggleTests.swift; sourceTree = ""; }; C1713005C1713005C1713005 /* CommandPaletteShortcutCustomizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteShortcutCustomizationTests.swift; sourceTree = ""; }; @@ -1601,6 +1596,7 @@ F53000A0A1B2C3D4E5F60718 /* CMUXAgentVault in Frameworks */, 5EDB6027B346C46521A93C74 /* CMUXAuthCore in Frameworks */, 53750003A0B1C2D3E4F50003 /* CmuxAuthRuntime in Frameworks */, + C9A1C00000000000000000A3 /* CmuxCommandPalette in Frameworks */, C750100000000000000000A3 /* CmuxControlSocket in Frameworks */, C0DE46000000000000000001 /* CMUXExtensionHostSupport in Frameworks */, C0DE45000000000000000001 /* CmuxExtensionKit in Frameworks */, @@ -1890,10 +1886,7 @@ A5001701A1B2C3D4E5F60718 /* WindowAppearanceSnapshot.swift */, A5001801A1B2C3D4E5F60718 /* WindowBackdropController.swift */, D1614EAD3CCF70A177A51BD1 /* SidebarState.swift */, - 38A09EB2E92203B2E95923A7 /* CommandPaletteSearch.swift */, - C0DEFF100000000000000002 /* CommandPaletteNucleoSearch.swift */, C0DEFF200000000000000002 /* CommandPaletteOverlay.swift */, - C0DEFF300000000000000002 /* CommandPaletteSearchOrchestrator.swift */, C0DEF4110000000000000002 /* CommandPaletteSettingsToggle.swift */, 9DAB808A8EC74C40B95F7672 /* GhosttySurfaceConfigurationRefresh.swift */, 6B8E2E03F4A64C61B729CF19 /* TerminalDirectoryOpenSupport.swift */, @@ -2717,6 +2710,7 @@ C8000311C8000311C8000311 /* XCLocalSwiftPackageReference "CmuxFileWatch" */, C617000000000000000000A1 /* XCLocalSwiftPackageReference "CmuxGit" */, C750100000000000000000A1 /* XCLocalSwiftPackageReference "CmuxControlSocket" */, + C9A1C00000000000000000A1 /* XCLocalSwiftPackageReference "CmuxCommandPalette" */, CD0CFE5100000000CD0CFE51 /* XCLocalSwiftPackageReference "CmuxSettings" */, CD0CFE5400000000CD0CFE54 /* XCLocalSwiftPackageReference "CmuxSettingsUI" */, CDFEED0100000000CDFEED01 /* XCLocalSwiftPackageReference "CmuxUpdater" */, @@ -3010,10 +3004,7 @@ A9F200000000000000000016 /* CodexAppServerQueuedInput.swift in Sources */, A9E02000000000000000000E /* CodexAppServerSession.swift in Sources */, C4041001000000000000001B /* CommandClickFileOpenRouter.swift in Sources */, - C0DEFF100000000000000001 /* CommandPaletteNucleoSearch.swift in Sources */, C0DEFF200000000000000001 /* CommandPaletteOverlay.swift in Sources */, - A8CBA43C2DA5AB9E3A1E65A4 /* CommandPaletteSearch.swift in Sources */, - C0DEFF300000000000000001 /* CommandPaletteSearchOrchestrator.swift in Sources */, C0DEF4110000000000000001 /* CommandPaletteSettingsToggle.swift in Sources */, C1713002C1713002C1713002 /* CommandPaletteShortcutRouting.swift in Sources */, 3023A1013023A1013023A101 /* ConfigSettingsView.swift in Sources */, @@ -4125,6 +4116,10 @@ isa = XCLocalSwiftPackageReference; relativePath = Packages/CmuxControlSocket; }; + C9A1C00000000000000000A1 /* XCLocalSwiftPackageReference "CmuxCommandPalette" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/CmuxCommandPalette; + }; CD0CFE5100000000CD0CFE51 /* XCLocalSwiftPackageReference "CmuxSettings" */ = { isa = XCLocalSwiftPackageReference; relativePath = Packages/CmuxSettings; @@ -4292,6 +4287,11 @@ package = C750100000000000000000A1 /* XCLocalSwiftPackageReference "CmuxControlSocket" */; productName = CmuxControlSocket; }; + C9A1C00000000000000000A2 /* CmuxCommandPalette */ = { + isa = XCSwiftPackageProductDependency; + package = C9A1C00000000000000000A1 /* XCLocalSwiftPackageReference "CmuxCommandPalette" */; + productName = CmuxCommandPalette; + }; C8000302C8000302C8000302 /* CmuxProcess */ = { isa = XCSwiftPackageProductDependency; package = C8000301C8000301C8000301 /* XCLocalSwiftPackageReference "CmuxProcess" */; diff --git a/cmuxTests/WorkspaceManualUnreadTests.swift b/cmuxTests/WorkspaceManualUnreadTests.swift index 29e956e54c4..b12afc47b66 100644 --- a/cmuxTests/WorkspaceManualUnreadTests.swift +++ b/cmuxTests/WorkspaceManualUnreadTests.swift @@ -1,3 +1,4 @@ +import CmuxCommandPalette import XCTest import AppKit From 7bb426d218b82de9818aa105ab73d12f361b89c7 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 12 Jun 2026 19:37:38 -0700 Subject: [PATCH 02/31] ContentView drain: complete command-palette test cutover (stack E) Closes the only gap in the lifted Wave 2 command-palette tranche: the app-target test files still referenced symbols and shims that moved into CmuxCommandPalette. - Import CmuxCommandPalette (hoisted above the canImport(cmux_DEV) block) in the two app-target palette test files so the moved public search/orchestrator symbols resolve. - Remove the two app-target tests that called the deleted production test-only shims (testSwiftFallbackMergeKeepsCombinedResultsSortedByScore, testCommandPreviewSearchUsesFullCommandCorpus); both are covered byte-identically by the package suite via @testable access to the now-internal real methods. - Remaining app-target palette tests (ContentView/ForkableAgent app-glue and the app-bundle dylib presence test) keep running unchanged. - Refresh the Swift file length budget for the moved package files and the shrunk app test file. Gates green: budget exit 0, lint-ios-package-conventions exit 0, CmuxCommandPalette swift build + 51 swift tests pass, full app compiles (xcodebuild -derivedDataPath /tmp/cmux-cview build -> BUILD SUCCEEDED), pbxproj normalize/check/ test-wiring clean. Co-Authored-By: Claude Fable 5 --- .github/swift-file-length-budget.tsv | 16 +-- cmuxTests/CommandPaletteNucleoFFITests.swift | 1 + .../CommandPaletteSearchEngineTests.swift | 97 +------------------ 3 files changed, 11 insertions(+), 103 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index a77847e9eb0..87f16b88ff1 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -3,7 +3,7 @@ # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 33285 CLI/cmux.swift 19990 Sources/Workspace.swift -19265 Sources/ContentView.swift +18942 Sources/ContentView.swift 18118 Sources/AppDelegate.swift 16674 Sources/GhosttyTerminalView.swift 14622 Sources/TerminalController.swift @@ -36,9 +36,9 @@ 2877 Sources/SessionIndexView.swift 2871 cmuxTests/CMUXOpenCommandTests.swift 2565 Sources/Panels/CmuxWebView.swift -2545 cmuxTests/WorkspaceManualUnreadTests.swift -2544 cmuxTests/CommandPaletteSearchEngineTests.swift +2546 cmuxTests/WorkspaceManualUnreadTests.swift 2516 Sources/KeyboardShortcutSettings.swift +2449 cmuxTests/CommandPaletteSearchEngineTests.swift 2327 cmuxTests/CJKIMEInputTests.swift 2322 Sources/Mobile/MobileHostService.swift 2314 Sources/FileExplorerView.swift @@ -62,7 +62,6 @@ 1498 cmuxTests/OmnibarAndToolsTests.swift 1496 cmuxUITests/MultiWindowNotificationsUITests.swift 1448 Sources/FileExplorerStore.swift -1410 Sources/CommandPalette/CommandPaletteSearch.swift 1380 cmuxUITests/MenuKeyEquivalentRoutingUITests.swift 1376 cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift 1372 cmuxTests/AppDelegateIssue2907RoutingTests.swift @@ -71,6 +70,7 @@ 1313 cmuxTests/MobileHostAuthorizationTests.swift 1285 cmuxUITests/SidebarHelpMenuUITests.swift 1255 Sources/Feed/FeedCoordinator.swift +1216 Packages/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteSearchEngineTests.swift 1205 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift 1197 cmuxTests/CodexAppServerSessionTests.swift 1156 cmuxTests/SidebarOrderingTests.swift @@ -82,18 +82,19 @@ 1093 cmuxUITests/BonsplitTabDragUITests.swift 1084 cmuxTests/AgentHibernationTests.swift 1084 cmuxTests/RestorableAgentSessionIndexTests.swift +1047 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteFuzzyMatcher.swift 1021 cmuxUITests/TerminalCmdClickUITests.swift 1006 cmuxTests/CmuxSSHURLRequestTests.swift 1000 cmuxTests/CmuxTopSnapshotScopeTests.swift +949 Sources/App/TerminalDirectoryOpenSupport.swift 947 Sources/TerminalNotificationPolicy.swift 945 Sources/SessionIndexRegisteredAgents.swift 944 Sources/App/ShortcutRoutingSupport.swift -939 Sources/App/TerminalDirectoryOpenSupport.swift 937 Sources/TextBoxMentionIndexStore.swift 924 Sources/DockPanelView.swift 913 cmuxTests/WorkspaceGroupTests.swift 905 Sources/CmuxSSHURLRequest.swift -896 Sources/CommandPalette/CommandPaletteSettingsToggle.swift +897 Sources/CommandPalette/CommandPaletteSettingsToggle.swift 878 Sources/WorkspaceContentView.swift 868 Sources/Panels/BrowserScreenshotSnapshotter.swift 864 Sources/Panels/TerminalPanel.swift @@ -137,7 +138,7 @@ 654 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift 650 Sources/Panels/MarkdownRemoteImageLoader.swift 649 Sources/CmuxTopSnapshot.swift -640 cmuxTests/CommandPaletteNucleoFFITests.swift +641 cmuxTests/CommandPaletteNucleoFFITests.swift 630 Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutWhenClause.swift 627 Sources/WorkspaceRemoteConfiguration.swift 621 cmuxUITests/RightSidebarChromeHeightUITests.swift @@ -146,6 +147,7 @@ 614 cmuxTests/SessionIndexViewTests.swift 613 Sources/PortScanner.swift 611 Sources/TerminalController+ControlPaneContext.swift +604 Packages/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteNucleoFFITests.swift 603 Sources/SettingsNavigation.swift 599 Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift 596 cmuxTests/CmuxEventBusTests.swift diff --git a/cmuxTests/CommandPaletteNucleoFFITests.swift b/cmuxTests/CommandPaletteNucleoFFITests.swift index 16560e0e14a..f911c12b993 100644 --- a/cmuxTests/CommandPaletteNucleoFFITests.swift +++ b/cmuxTests/CommandPaletteNucleoFFITests.swift @@ -1,3 +1,4 @@ +import CmuxCommandPalette import XCTest #if canImport(cmux_DEV) diff --git a/cmuxTests/CommandPaletteSearchEngineTests.swift b/cmuxTests/CommandPaletteSearchEngineTests.swift index 7ff0a2cdce9..b5525765e1f 100644 --- a/cmuxTests/CommandPaletteSearchEngineTests.swift +++ b/cmuxTests/CommandPaletteSearchEngineTests.swift @@ -1,3 +1,4 @@ +import CmuxCommandPalette import XCTest #if canImport(cmux_DEV) @@ -1368,44 +1369,6 @@ final class CommandPaletteSearchEngineTests: XCTestCase { ) } - func testCommandPreviewSearchUsesFullCommandCorpus() { - let entries = [ - FixtureEntry( - id: "command.find", - rank: 0, - title: "Find...", - searchableTexts: ["Find...", "Search", "find", "search"] - ), - FixtureEntry( - id: "command.finder", - rank: 1, - title: "Open Current Directory in Finder", - searchableTexts: ["Open Current Directory in Finder", "Terminal", "finder", "directory", "open"] - ), - ] - let corpus = entries.map { entry in - CommandPaletteSearchCorpusEntry( - payload: entry.id, - rank: entry.rank, - title: entry.title, - searchableTexts: entry.searchableTexts - ) - } - let corpusByID = Dictionary(uniqueKeysWithValues: corpus.map { ($0.payload, $0) }) - let searchIndex = CommandPaletteNucleoSearchIndex(entries: corpus) - - let previewCommandIDs = CommandPaletteSearchOrchestrator.commandPreviewMatchCommandIDsForTests( - searchCorpus: corpus, - searchIndex: searchIndex, - candidateCommandIDs: ["command.find"], - searchCorpusByID: corpusByID, - query: "finde", - resultLimit: 48 - ) - - XCTAssertEqual(previewCommandIDs.first, "command.finder") - } - func testNucleoEmptyResultsFallBackToSwiftSingleEditMatching() throws { let entries = [ FixtureEntry( @@ -1543,64 +1506,6 @@ final class CommandPaletteSearchEngineTests: XCTestCase { XCTAssertEqual(matches.first?.commandID, "palette.renameTab") } - func testSwiftFallbackMergeKeepsCombinedResultsSortedByScore() { - let entries = [ - FixtureEntry( - id: "palette.high", - rank: 0, - title: "High Score", - searchableTexts: ["High Score"] - ), - FixtureEntry( - id: "palette.medium", - rank: 1, - title: "Medium Score", - searchableTexts: ["Medium Score"] - ), - FixtureEntry( - id: "palette.fallback", - rank: 2, - title: "Fallback Score", - searchableTexts: ["Fallback Score"] - ), - ] - let corpus = entries.map { entry in - CommandPaletteSearchCorpusEntry( - payload: entry.id, - rank: entry.rank, - title: entry.title, - searchableTexts: entry.searchableTexts - ) - } - let corpusByID = Dictionary(uniqueKeysWithValues: corpus.map { ($0.payload, $0) }) - - let matches = CommandPaletteSearchOrchestrator.mergedSwiftFallbackMatchesForTests( - [ - CommandPaletteResolvedSearchMatch( - commandID: "palette.fallback", - score: 25, - titleMatchIndices: [] - ) - ], - nucleoMatches: [ - CommandPaletteResolvedSearchMatch( - commandID: "palette.medium", - score: 80, - titleMatchIndices: [] - ), - CommandPaletteResolvedSearchMatch( - commandID: "palette.high", - score: 100, - titleMatchIndices: [] - ), - ], - searchCorpusByID: corpusByID, - limit: 3 - ) - - XCTAssertEqual(matches.map(\.commandID), ["palette.high", "palette.medium", "palette.fallback"]) - } - func testFirstValueDictionaryPreservesFirstDuplicateKey() { let values = [ (id: "palette.duplicate", title: "First"), From d0172792325457287ddca1b9df9fbd01fab30683 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 12 Jun 2026 19:42:48 -0700 Subject: [PATCH 03/31] ContentView drain: remove leftover duplicate overlay-promotion policy (stack E) The Wave 2 lift left CommandPaletteOverlayPromotionPolicy declared in BOTH ContentView.swift (app-internal) and CmuxCommandPalette (public). The app compiled only because the app-internal copy shadowed the package type at the one call site, leaving the lifted package version dead and a name collision latent (the app-side-duplicate-plus-unconditional-import ambiguity trap). - Delete the app-side enum from ContentView.swift; the single call site (overlay re-promotion on hidden->visible) now binds to the package's public CommandPaletteOverlayPromotionPolicy. - Remove the now-redundant app-target CommandPaletteOverlayPromotionPolicyTests; the package suite covers all four transition cases identically. - Refresh the Swift file length budget for the shrunk ContentView/test files. Swept all 40 CmuxCommandPalette public types against app Sources/: this was the only remaining lifted-but-not-removed duplicate. ContentView.swift 18,942 -> 18,936. App compiles (BUILD SUCCEEDED), 51 package tests pass, budget + lint exit 0. Co-Authored-By: Claude Fable 5 --- .github/swift-file-length-budget.tsv | 4 +-- Sources/ContentView.swift | 6 ---- .../ShortcutAndCommandPaletteTests.swift | 36 ------------------- 3 files changed, 2 insertions(+), 44 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 87f16b88ff1..555df0e2d7e 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -3,7 +3,7 @@ # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 33285 CLI/cmux.swift 19990 Sources/Workspace.swift -18942 Sources/ContentView.swift +18936 Sources/ContentView.swift 18118 Sources/AppDelegate.swift 16674 Sources/GhosttyTerminalView.swift 14622 Sources/TerminalController.swift @@ -44,8 +44,8 @@ 2314 Sources/FileExplorerView.swift 2260 Sources/TerminalWindowPortal.swift 2198 Sources/SessionPersistence.swift -2123 cmuxTests/ShortcutAndCommandPaletteTests.swift 2117 cmuxTests/CmuxConfigTests.swift +2087 cmuxTests/ShortcutAndCommandPaletteTests.swift 2030 Sources/KeyboardShortcutSettingsFileStore.swift 1949 Sources/Panels/BrowserWebAuthnSupport.swift 1941 Sources/TerminalNotificationStore.swift diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index a1441d97ccf..cd8cfc14889 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -39,12 +39,6 @@ private func windowContentOverlayInstallationTarget(for window: NSWindow) -> (co return (themeFrame, contentView) } -enum CommandPaletteOverlayPromotionPolicy { - static func shouldPromote(previouslyVisible: Bool, isVisible: Bool) -> Bool { - isVisible && !previouslyVisible - } -} - @MainActor private final class CommandPaletteOverlayContainerView: NSView { var capturesMouseEvents = false diff --git a/cmuxTests/ShortcutAndCommandPaletteTests.swift b/cmuxTests/ShortcutAndCommandPaletteTests.swift index d861e9a1a56..07f9c15bbf3 100644 --- a/cmuxTests/ShortcutAndCommandPaletteTests.swift +++ b/cmuxTests/ShortcutAndCommandPaletteTests.swift @@ -2085,39 +2085,3 @@ private final class UpdateChoiceRecorder: @unchecked Sendable { return choices } } - -@MainActor -final class CommandPaletteOverlayPromotionPolicyTests: XCTestCase { - func testShouldPromoteWhenBecomingVisible() { - XCTAssertTrue( - CommandPaletteOverlayPromotionPolicy.shouldPromote( - previouslyVisible: false, - isVisible: true - ) - ) - } - - func testShouldNotPromoteWhenAlreadyVisible() { - XCTAssertFalse( - CommandPaletteOverlayPromotionPolicy.shouldPromote( - previouslyVisible: true, - isVisible: true - ) - ) - } - - func testShouldNotPromoteWhenHidden() { - XCTAssertFalse( - CommandPaletteOverlayPromotionPolicy.shouldPromote( - previouslyVisible: true, - isVisible: false - ) - ) - XCTAssertFalse( - CommandPaletteOverlayPromotionPolicy.shouldPromote( - previouslyVisible: false, - isVisible: false - ) - ) - } -} From 521da3466704c15da9ed32eafcd7ffa4e91c8f36 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 12 Jun 2026 20:01:37 -0700 Subject: [PATCH 04/31] ContentView drain: lift AppKit-support primitives into CmuxAppKitSupportUI (stack E) Faithful lift of the shared AppKit-bridge UI primitives out of ContentView's preamble/tail into a new CmuxAppKitSupportUI package (Wave 3 of the ContentView plan): ArrowlessPopoverAnchor, MiddleClickCapture (+MiddleClickCaptureView), and ClearScrollBackground (+ScrollBackgroundClearer). Bodies are byte-identical to pre-tranche HEAD; the only deltas are package-seam mechanics: public visibility, synthesized public inits (cross-module memberwise init is inaccessible), public import SwiftUI on public-API files, and @MainActor on the popover Coordinator (ContentView was implicitly main-actor). App-side call sites in ContentView and SessionIndexView gain 'import CmuxAppKitSupportUI'; no shadow copies remain. ContentView.swift: 18936 -> 18699 lines. Package builds + 1 test pass; app compile BUILD SUCCEEDED; pbxproj 6-entry wiring mirrors CmuxCommandPalette, normalized + checked; file-length budget ratcheted. Co-Authored-By: Claude Fable 5 --- .github/swift-file-length-budget.tsv | 4 +- Packages/CmuxAppKitSupportUI/Package.swift | 35 +++ .../Mouse/MiddleClickCapture.swift | 24 ++ .../Mouse/MiddleClickCaptureView.swift | 26 ++ .../Popover/ArrowlessPopoverAnchor.swift | 174 +++++++++++++ .../Scroll/ClearScrollBackground.swift | 20 ++ .../Scroll/ScrollBackgroundClearer.swift | 46 ++++ .../MiddleClickCaptureViewTests.swift | 32 +++ Sources/ContentView.swift | 239 +----------------- Sources/SessionIndexView.swift | 1 + cmux.xcodeproj/project.pbxproj | 12 + 11 files changed, 373 insertions(+), 240 deletions(-) create mode 100644 Packages/CmuxAppKitSupportUI/Package.swift create mode 100644 Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Mouse/MiddleClickCapture.swift create mode 100644 Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Mouse/MiddleClickCaptureView.swift create mode 100644 Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift create mode 100644 Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/ClearScrollBackground.swift create mode 100644 Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/ScrollBackgroundClearer.swift create mode 100644 Packages/CmuxAppKitSupportUI/Tests/CmuxAppKitSupportUITests/MiddleClickCaptureViewTests.swift diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 555df0e2d7e..757a943f2b5 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -3,7 +3,7 @@ # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 33285 CLI/cmux.swift 19990 Sources/Workspace.swift -18936 Sources/ContentView.swift +18699 Sources/ContentView.swift 18118 Sources/AppDelegate.swift 16674 Sources/GhosttyTerminalView.swift 14622 Sources/TerminalController.swift @@ -33,7 +33,7 @@ 3396 Sources/CmuxConfig.swift 3316 cmuxTests/TabManagerSessionSnapshotTests.swift 3202 Sources/Update/UpdateTitlebarAccessory.swift -2877 Sources/SessionIndexView.swift +2878 Sources/SessionIndexView.swift 2871 cmuxTests/CMUXOpenCommandTests.swift 2565 Sources/Panels/CmuxWebView.swift 2546 cmuxTests/WorkspaceManualUnreadTests.swift diff --git a/Packages/CmuxAppKitSupportUI/Package.swift b/Packages/CmuxAppKitSupportUI/Package.swift new file mode 100644 index 00000000000..9a80dd45d84 --- /dev/null +++ b/Packages/CmuxAppKitSupportUI/Package.swift @@ -0,0 +1,35 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "CmuxAppKitSupportUI", + platforms: [ + .macOS(.v14), + ], + products: [ + .library( + name: "CmuxAppKitSupportUI", + targets: ["CmuxAppKitSupportUI"] + ), + ], + targets: [ + .target( + name: "CmuxAppKitSupportUI", + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + .testTarget( + name: "CmuxAppKitSupportUITests", + dependencies: ["CmuxAppKitSupportUI"], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + ] +) diff --git a/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Mouse/MiddleClickCapture.swift b/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Mouse/MiddleClickCapture.swift new file mode 100644 index 00000000000..b0715e74c80 --- /dev/null +++ b/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Mouse/MiddleClickCapture.swift @@ -0,0 +1,24 @@ +import AppKit +public import SwiftUI + +/// A transparent overlay that intercepts only middle-mouse clicks, letting left-click +/// selection and right-click context menus hit-test through to the underlying view tree. +public struct MiddleClickCapture: NSViewRepresentable { + public let onMiddleClick: () -> Void + + /// Creates a middle-click capture overlay. + /// - Parameter onMiddleClick: Invoked when a middle (button 2) click lands on the overlay. + public init(onMiddleClick: @escaping () -> Void) { + self.onMiddleClick = onMiddleClick + } + + public func makeNSView(context: Context) -> MiddleClickCaptureView { + let view = MiddleClickCaptureView() + view.onMiddleClick = onMiddleClick + return view + } + + public func updateNSView(_ nsView: MiddleClickCaptureView, context: Context) { + nsView.onMiddleClick = onMiddleClick + } +} diff --git a/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Mouse/MiddleClickCaptureView.swift b/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Mouse/MiddleClickCaptureView.swift new file mode 100644 index 00000000000..72bcbc652fb --- /dev/null +++ b/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Mouse/MiddleClickCaptureView.swift @@ -0,0 +1,26 @@ +public import AppKit + +/// Backing `NSView` for ``MiddleClickCapture`` that hit-tests only middle-clicks. +public final class MiddleClickCaptureView: NSView { + /// Invoked when a middle (button 2) mouse-down lands on this view. + public var onMiddleClick: (() -> Void)? + + public override func hitTest(_ point: NSPoint) -> NSView? { + // Only intercept middle-click so left-click selection and right-click context menus + // continue to hit-test through to SwiftUI/AppKit normally. + guard let event = NSApp.currentEvent, + event.type == .otherMouseDown, + event.buttonNumber == 2 else { + return nil + } + return self + } + + public override func otherMouseDown(with event: NSEvent) { + guard event.buttonNumber == 2 else { + super.otherMouseDown(with: event) + return + } + onMiddleClick?() + } +} diff --git a/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift b/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift new file mode 100644 index 00000000000..118e85b41f7 --- /dev/null +++ b/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift @@ -0,0 +1,174 @@ +public import AppKit +public import SwiftUI + +/// An `NSViewRepresentable` that presents SwiftUI content in an `NSPopover` with the +/// popover arrow hidden, anchored to an invisible SwiftUI-backed view. +/// +/// The popover is positioned relative to a synthetic rect inset toward the anchor so the +/// detached content sits a fixed gap from the anchoring edge while the arrow stays hidden. +public struct ArrowlessPopoverAnchor: NSViewRepresentable { + @Binding public var isPresented: Bool + public let preferredEdge: NSRectEdge + public let detachedGap: CGFloat + @ViewBuilder public let content: () -> PopoverContent + + /// Creates an arrowless popover anchor. + /// - Parameters: + /// - isPresented: Binding driving popover presentation. + /// - preferredEdge: The edge of the anchor the popover prefers to appear from. + /// - detachedGap: The gap, in points, between the anchor edge and the popover. + /// - content: The SwiftUI content rendered inside the popover. + public init( + isPresented: Binding, + preferredEdge: NSRectEdge, + detachedGap: CGFloat, + @ViewBuilder content: @escaping () -> PopoverContent + ) { + self._isPresented = isPresented + self.preferredEdge = preferredEdge + self.detachedGap = detachedGap + self.content = content + } + + public func makeNSView(context: Context) -> NSView { + let view = NSView() + context.coordinator.anchorView = view + return view + } + + public func updateNSView(_ nsView: NSView, context: Context) { + context.coordinator.anchorView = nsView + context.coordinator.updateRootView(AnyView(content())) + + if isPresented { + context.coordinator.present( + preferredEdge: preferredEdge, + detachedGap: detachedGap + ) + } else { + context.coordinator.dismiss() + } + } + + public func makeCoordinator() -> Coordinator { + Coordinator(isPresented: $isPresented) + } + + /// Bridges popover lifecycle between AppKit's `NSPopover` and the SwiftUI binding. + @MainActor + public final class Coordinator: NSObject, NSPopoverDelegate { + @Binding var isPresented: Bool + + weak var anchorView: NSView? + private let hostingController = NSHostingController(rootView: AnyView(EmptyView())) + private var popover: NSPopover? + + init(isPresented: Binding) { + _isPresented = isPresented + } + + func updateRootView(_ rootView: AnyView) { + hostingController.rootView = AnyView(rootView.fixedSize()) + hostingController.view.invalidateIntrinsicContentSize() + hostingController.view.layoutSubtreeIfNeeded() + } + + func present(preferredEdge: NSRectEdge, detachedGap: CGFloat) { + guard let anchorView else { + isPresented = false + dismiss() + return + } + + let popover = popover ?? makePopover() + if popover.isShown { + return + } + + hostingController.view.invalidateIntrinsicContentSize() + hostingController.view.layoutSubtreeIfNeeded() + let fittingSize = hostingController.view.fittingSize + if fittingSize.width > 0, fittingSize.height > 0 { + popover.contentSize = NSSize( + width: ceil(fittingSize.width), + height: ceil(fittingSize.height) + ) + } + + popover.show( + relativeTo: positioningRect( + for: anchorView.bounds, + preferredEdge: preferredEdge, + detachedGap: detachedGap + ), + of: anchorView, + preferredEdge: preferredEdge + ) + } + + func dismiss() { + popover?.performClose(nil) + popover = nil + } + + public func popoverDidClose(_ notification: Notification) { + popover = nil + if isPresented { + isPresented = false + } + } + + private func makePopover() -> NSPopover { + let popover = NSPopover() + popover.behavior = .semitransient + popover.animates = true + popover.setValue(true, forKeyPath: "shouldHideAnchor") + popover.contentViewController = hostingController + popover.delegate = self + self.popover = popover + return popover + } + + private func positioningRect( + for bounds: CGRect, + preferredEdge: NSRectEdge, + detachedGap: CGFloat + ) -> CGRect { + let hiddenArrowInset: CGFloat = 13 + let compensation = max(hiddenArrowInset - detachedGap, 0) + + switch preferredEdge { + case .maxY: + return NSRect( + x: bounds.minX, + y: bounds.maxY - compensation, + width: bounds.width, + height: compensation + ) + case .minY: + return NSRect( + x: bounds.minX, + y: bounds.minY, + width: bounds.width, + height: compensation + ) + case .maxX: + return NSRect( + x: bounds.maxX - compensation, + y: bounds.minY, + width: compensation, + height: bounds.height + ) + case .minX: + return NSRect( + x: bounds.minX, + y: bounds.minY, + width: compensation, + height: bounds.height + ) + @unknown default: + return bounds + } + } + } +} diff --git a/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/ClearScrollBackground.swift b/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/ClearScrollBackground.swift new file mode 100644 index 00000000000..48b9cc79ab9 --- /dev/null +++ b/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/ClearScrollBackground.swift @@ -0,0 +1,20 @@ +import AppKit +public import SwiftUI + +/// A `ViewModifier` that makes the enclosing `NSScrollView` fully transparent, hiding the +/// SwiftUI scroll content background and clearing the AppKit scroll-view layer chain. +public struct ClearScrollBackground: ViewModifier { + /// Creates the clear-scroll-background modifier. + public init() {} + + public func body(content: Content) -> some View { + if #available(macOS 13.0, *) { + content + .scrollContentBackground(.hidden) + .background(ScrollBackgroundClearer()) + } else { + content + .background(ScrollBackgroundClearer()) + } + } +} diff --git a/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/ScrollBackgroundClearer.swift b/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/ScrollBackgroundClearer.swift new file mode 100644 index 00000000000..541b8867b03 --- /dev/null +++ b/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/ScrollBackgroundClearer.swift @@ -0,0 +1,46 @@ +import AppKit +import SwiftUI + +/// An invisible `NSViewRepresentable` that walks up to its enclosing `NSScrollView` and +/// clears every background and layer in the scroll-view chain so SwiftUI content renders +/// over a transparent scroll surface. +struct ScrollBackgroundClearer: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { + NSView() + } + + func updateNSView(_ nsView: NSView, context: Context) { + DispatchQueue.main.async { + guard let scrollView = findScrollView(startingAt: nsView) else { return } + // Clear all backgrounds and mark as non-opaque for transparency + scrollView.drawsBackground = false + scrollView.backgroundColor = .clear + scrollView.wantsLayer = true + scrollView.layer?.backgroundColor = NSColor.clear.cgColor + scrollView.layer?.isOpaque = false + + scrollView.contentView.drawsBackground = false + scrollView.contentView.backgroundColor = .clear + scrollView.contentView.wantsLayer = true + scrollView.contentView.layer?.backgroundColor = NSColor.clear.cgColor + scrollView.contentView.layer?.isOpaque = false + + if let docView = scrollView.documentView { + docView.wantsLayer = true + docView.layer?.backgroundColor = NSColor.clear.cgColor + docView.layer?.isOpaque = false + } + } + } + + private func findScrollView(startingAt view: NSView) -> NSScrollView? { + var current: NSView? = view + while let candidate = current { + if let scrollView = candidate as? NSScrollView { + return scrollView + } + current = candidate.superview + } + return nil + } +} diff --git a/Packages/CmuxAppKitSupportUI/Tests/CmuxAppKitSupportUITests/MiddleClickCaptureViewTests.swift b/Packages/CmuxAppKitSupportUI/Tests/CmuxAppKitSupportUITests/MiddleClickCaptureViewTests.swift new file mode 100644 index 00000000000..99573a3e6ab --- /dev/null +++ b/Packages/CmuxAppKitSupportUI/Tests/CmuxAppKitSupportUITests/MiddleClickCaptureViewTests.swift @@ -0,0 +1,32 @@ +import AppKit +import Testing + +@testable import CmuxAppKitSupportUI + +@MainActor +@Suite struct MiddleClickCaptureViewTests { + /// A non-middle `otherMouseDown` must fall through to `super` and never fire the handler. + @Test func forwardButtonDownDoesNotInvokeMiddleClickHandler() { + let view = MiddleClickCaptureView() + var invoked = 0 + view.onMiddleClick = { invoked += 1 } + + // A synthesized .otherMouseDown defaults to buttonNumber 0 (not 2), so the + // middle-click branch must be skipped. + let event = NSEvent.mouseEvent( + with: .otherMouseDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1 + ) + if let event { + view.otherMouseDown(with: event) + } + #expect(invoked == 0) + } +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index cd8cfc14889..71daa20d513 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxAppKitSupportUI import CmuxSocketControl import CmuxCommandPalette import Bonsplit @@ -14472,153 +14473,6 @@ private struct SidebarHelpMenuButton: View { } -private struct ArrowlessPopoverAnchor: NSViewRepresentable { - @Binding var isPresented: Bool - let preferredEdge: NSRectEdge - let detachedGap: CGFloat - @ViewBuilder let content: () -> PopoverContent - - func makeNSView(context: Context) -> NSView { - let view = NSView() - context.coordinator.anchorView = view - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { - context.coordinator.anchorView = nsView - context.coordinator.updateRootView(AnyView(content())) - - if isPresented { - context.coordinator.present( - preferredEdge: preferredEdge, - detachedGap: detachedGap - ) - } else { - context.coordinator.dismiss() - } - } - - func makeCoordinator() -> Coordinator { - Coordinator(isPresented: $isPresented) - } - - final class Coordinator: NSObject, NSPopoverDelegate { - @Binding var isPresented: Bool - - weak var anchorView: NSView? - private let hostingController = NSHostingController(rootView: AnyView(EmptyView())) - private var popover: NSPopover? - - init(isPresented: Binding) { - _isPresented = isPresented - } - - func updateRootView(_ rootView: AnyView) { - hostingController.rootView = AnyView(rootView.fixedSize()) - hostingController.view.invalidateIntrinsicContentSize() - hostingController.view.layoutSubtreeIfNeeded() - } - - func present(preferredEdge: NSRectEdge, detachedGap: CGFloat) { - guard let anchorView else { - isPresented = false - dismiss() - return - } - - let popover = popover ?? makePopover() - if popover.isShown { - return - } - - hostingController.view.invalidateIntrinsicContentSize() - hostingController.view.layoutSubtreeIfNeeded() - let fittingSize = hostingController.view.fittingSize - if fittingSize.width > 0, fittingSize.height > 0 { - popover.contentSize = NSSize( - width: ceil(fittingSize.width), - height: ceil(fittingSize.height) - ) - } - - popover.show( - relativeTo: positioningRect( - for: anchorView.bounds, - preferredEdge: preferredEdge, - detachedGap: detachedGap - ), - of: anchorView, - preferredEdge: preferredEdge - ) - } - - func dismiss() { - popover?.performClose(nil) - popover = nil - } - - func popoverDidClose(_ notification: Notification) { - popover = nil - if isPresented { - isPresented = false - } - } - - private func makePopover() -> NSPopover { - let popover = NSPopover() - popover.behavior = .semitransient - popover.animates = true - popover.setValue(true, forKeyPath: "shouldHideAnchor") - popover.contentViewController = hostingController - popover.delegate = self - self.popover = popover - return popover - } - - private func positioningRect( - for bounds: CGRect, - preferredEdge: NSRectEdge, - detachedGap: CGFloat - ) -> CGRect { - let hiddenArrowInset: CGFloat = 13 - let compensation = max(hiddenArrowInset - detachedGap, 0) - - switch preferredEdge { - case .maxY: - return NSRect( - x: bounds.minX, - y: bounds.maxY - compensation, - width: bounds.width, - height: compensation - ) - case .minY: - return NSRect( - x: bounds.minX, - y: bounds.minY, - width: bounds.width, - height: compensation - ) - case .maxX: - return NSRect( - x: bounds.maxX - compensation, - y: bounds.minY, - width: compensation, - height: bounds.height - ) - case .minX: - return NSRect( - x: bounds.minX, - y: bounds.minY, - width: compensation, - height: bounds.height - ) - @unknown default: - return bounds - } - } - } -} - private struct SidebarFooterIconButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { SidebarFooterIconButtonStyleBody(configuration: configuration) @@ -18343,102 +18197,11 @@ private struct ExtensionSidebarBrowserStackEndDropDelegate: DropDelegate { } } -private struct MiddleClickCapture: NSViewRepresentable { - let onMiddleClick: () -> Void - - func makeNSView(context: Context) -> MiddleClickCaptureView { - let view = MiddleClickCaptureView() - view.onMiddleClick = onMiddleClick - return view - } - - func updateNSView(_ nsView: MiddleClickCaptureView, context: Context) { - nsView.onMiddleClick = onMiddleClick - } -} - -private final class MiddleClickCaptureView: NSView { - var onMiddleClick: (() -> Void)? - - override func hitTest(_ point: NSPoint) -> NSView? { - // Only intercept middle-click so left-click selection and right-click context menus - // continue to hit-test through to SwiftUI/AppKit normally. - guard let event = NSApp.currentEvent, - event.type == .otherMouseDown, - event.buttonNumber == 2 else { - return nil - } - return self - } - - override func otherMouseDown(with event: NSEvent) { - guard event.buttonNumber == 2 else { - super.otherMouseDown(with: event) - return - } - onMiddleClick?() - } -} - enum SidebarSelection { case tabs case notifications } -struct ClearScrollBackground: ViewModifier { - func body(content: Content) -> some View { - if #available(macOS 13.0, *) { - content - .scrollContentBackground(.hidden) - .background(ScrollBackgroundClearer()) - } else { - content - .background(ScrollBackgroundClearer()) - } - } -} - -private struct ScrollBackgroundClearer: NSViewRepresentable { - func makeNSView(context: Context) -> NSView { - NSView() - } - - func updateNSView(_ nsView: NSView, context: Context) { - DispatchQueue.main.async { - guard let scrollView = findScrollView(startingAt: nsView) else { return } - // Clear all backgrounds and mark as non-opaque for transparency - scrollView.drawsBackground = false - scrollView.backgroundColor = .clear - scrollView.wantsLayer = true - scrollView.layer?.backgroundColor = NSColor.clear.cgColor - scrollView.layer?.isOpaque = false - - scrollView.contentView.drawsBackground = false - scrollView.contentView.backgroundColor = .clear - scrollView.contentView.wantsLayer = true - scrollView.contentView.layer?.backgroundColor = NSColor.clear.cgColor - scrollView.contentView.layer?.isOpaque = false - - if let docView = scrollView.documentView { - docView.wantsLayer = true - docView.layer?.backgroundColor = NSColor.clear.cgColor - docView.layer?.isOpaque = false - } - } - } - - private func findScrollView(startingAt view: NSView) -> NSScrollView? { - var current: NSView? = view - while let candidate = current { - if let scrollView = candidate as? NSScrollView { - return scrollView - } - current = candidate.superview - } - return nil - } -} - /// Wrapper view that tries NSGlassEffectView (macOS 26+) when available or requested private struct SidebarVisualEffectBackground: NSViewRepresentable { let material: NSVisualEffectView.Material diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index 440acc1862a..a0f2f19f4bf 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -1,5 +1,6 @@ import AppKit import Bonsplit +import CmuxAppKitSupportUI import CMUXAgentVault import SQLite3 import SwiftUI diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 6942c987b52..ddb7981b74e 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -151,6 +151,7 @@ E3309A07 /* cmuxApp+EqualizeSplitsMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3309A08 /* cmuxApp+EqualizeSplitsMenu.swift */; }; C4160A010000000000000001 /* cmuxApp+HistoryMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4160A010000000000000002 /* cmuxApp+HistoryMenu.swift */; }; A5001001 /* cmuxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001011 /* cmuxApp.swift */; }; + C9A2B00000000000000000B3 /* CmuxAppKitSupportUI in Frameworks */ = {isa = PBXBuildFile; productRef = C9A2B00000000000000000B2 /* CmuxAppKitSupportUI */; }; D35110010000000000000001 /* CmuxApplicationSupportDirectories.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35110010000000000000002 /* CmuxApplicationSupportDirectories.swift */; }; D35110010000000000000003 /* CmuxApplicationSupportDirectories.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35110010000000000000002 /* CmuxApplicationSupportDirectories.swift */; }; 5EDB6027B346C46521A93C74 /* CMUXAuthCore in Frameworks */ = {isa = PBXBuildFile; productRef = 29813FE5A6CBC1019289A251 /* CMUXAuthCore */; }; @@ -1594,6 +1595,7 @@ files = ( A5B00003A1B2C3D4E5F60718 /* CMUXAgentLaunch in Frameworks */, F53000A0A1B2C3D4E5F60718 /* CMUXAgentVault in Frameworks */, + C9A2B00000000000000000B3 /* CmuxAppKitSupportUI in Frameworks */, 5EDB6027B346C46521A93C74 /* CMUXAuthCore in Frameworks */, 53750003A0B1C2D3E4F50003 /* CmuxAuthRuntime in Frameworks */, C9A1C00000000000000000A3 /* CmuxCommandPalette in Frameworks */, @@ -2711,6 +2713,7 @@ C617000000000000000000A1 /* XCLocalSwiftPackageReference "CmuxGit" */, C750100000000000000000A1 /* XCLocalSwiftPackageReference "CmuxControlSocket" */, C9A1C00000000000000000A1 /* XCLocalSwiftPackageReference "CmuxCommandPalette" */, + C9A2B00000000000000000B1 /* XCLocalSwiftPackageReference "CmuxAppKitSupportUI" */, CD0CFE5100000000CD0CFE51 /* XCLocalSwiftPackageReference "CmuxSettings" */, CD0CFE5400000000CD0CFE54 /* XCLocalSwiftPackageReference "CmuxSettingsUI" */, CDFEED0100000000CDFEED01 /* XCLocalSwiftPackageReference "CmuxUpdater" */, @@ -4120,6 +4123,10 @@ isa = XCLocalSwiftPackageReference; relativePath = Packages/CmuxCommandPalette; }; + C9A2B00000000000000000B1 /* XCLocalSwiftPackageReference "CmuxAppKitSupportUI" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/CmuxAppKitSupportUI; + }; CD0CFE5100000000CD0CFE51 /* XCLocalSwiftPackageReference "CmuxSettings" */ = { isa = XCLocalSwiftPackageReference; relativePath = Packages/CmuxSettings; @@ -4292,6 +4299,11 @@ package = C9A1C00000000000000000A1 /* XCLocalSwiftPackageReference "CmuxCommandPalette" */; productName = CmuxCommandPalette; }; + C9A2B00000000000000000B2 /* CmuxAppKitSupportUI */ = { + isa = XCSwiftPackageProductDependency; + package = C9A2B00000000000000000B1 /* XCLocalSwiftPackageReference "CmuxAppKitSupportUI" */; + productName = CmuxAppKitSupportUI; + }; C8000302C8000302C8000302 /* CmuxProcess */ = { isa = XCSwiftPackageProductDependency; package = C8000301C8000301C8000301 /* XCLocalSwiftPackageReference "CmuxProcess" */; From b9d9911efcb18a841fdc69be915ad76aaa4fb148 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 12 Jun 2026 20:06:43 -0700 Subject: [PATCH 05/31] ContentView drain: lift NSColor.hexString into CmuxFoundation (stack E, Wave 1) Faithful Wave-1 foundation lift: the NSColor.hexString(includeAlpha:) helper moves out of ContentView's tail into Packages/CmuxFoundation as NSColor+HexString.swift, alongside the existing String+JavaScriptStringLiteral foundation extension precedent. Body is byte-identical to pre-tranche HEAD (machine-diffed); the only delta is public visibility + DocC + a file-scoped public import AppKit. Adopted at every call site in the same change (TASTE: extraction isn't done until adopted everywhere): all 11 app files that call .hexString (GhosttyTerminalView, Workspace, cmuxApp, GhosttyConfig, GhosttyTerminalAppearance, WorkspaceAppearanceResolution, WorkspaceContentView, WindowAppearanceSnapshot, WindowBackdropController, FeedButtonStyleDebugWindowController, ContentView) gain 'import CmuxFoundation'. Sole hexString declaration now lives in the package; no shadow copies remain. ContentView.swift: 18,699 -> 18,682 lines. CmuxFoundation builds + 4 new behavior tests pass (RGB/alpha/byte-rounding round-trips); app compile BUILD SUCCEEDED (0 errors); lint exit 0; budget ratcheted. Co-Authored-By: Claude Fable 5 --- .github/swift-file-length-budget.tsv | 14 ++++----- .../CmuxFoundation/NSColor+HexString.swift | 23 +++++++++++++++ .../NSColor+HexStringTests.swift | 29 +++++++++++++++++++ Sources/ContentView.swift | 19 +----------- ...FeedButtonStyleDebugWindowController.swift | 1 + Sources/GhosttyConfig.swift | 1 + Sources/GhosttyTerminalAppearance.swift | 1 + Sources/GhosttyTerminalView.swift | 1 + .../Windowing/WindowAppearanceSnapshot.swift | 1 + .../Windowing/WindowBackdropController.swift | 1 + Sources/Workspace.swift | 1 + Sources/WorkspaceAppearanceResolution.swift | 1 + Sources/WorkspaceContentView.swift | 1 + Sources/cmuxApp.swift | 1 + 14 files changed, 70 insertions(+), 25 deletions(-) create mode 100644 Packages/CmuxFoundation/Sources/CmuxFoundation/NSColor+HexString.swift create mode 100644 Packages/CmuxFoundation/Tests/CmuxFoundationTests/NSColor+HexStringTests.swift diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 757a943f2b5..38015952369 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -2,10 +2,10 @@ # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 33285 CLI/cmux.swift -19990 Sources/Workspace.swift -18699 Sources/ContentView.swift +19991 Sources/Workspace.swift +18682 Sources/ContentView.swift 18118 Sources/AppDelegate.swift -16674 Sources/GhosttyTerminalView.swift +16675 Sources/GhosttyTerminalView.swift 14622 Sources/TerminalController.swift 13606 Sources/Panels/BrowserPanel.swift 12044 cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -20,7 +20,7 @@ 6153 CLI/cmux_open.swift 6071 Sources/TextBoxInput.swift 5482 cmuxTests/BrowserConfigTests.swift -5462 Sources/cmuxApp.swift +5463 Sources/cmuxApp.swift 4801 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 4460 Sources/Panels/FilePreviewPanel.swift 4400 cmuxTests/BrowserPanelTests.swift @@ -65,7 +65,7 @@ 1380 cmuxUITests/MenuKeyEquivalentRoutingUITests.swift 1376 cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift 1372 cmuxTests/AppDelegateIssue2907RoutingTests.swift -1365 Sources/Feed/FeedButtonStyleDebugWindowController.swift +1366 Sources/Feed/FeedButtonStyleDebugWindowController.swift 1362 Sources/CMUXInstalledExtensionSidebarHostView.swift 1313 cmuxTests/MobileHostAuthorizationTests.swift 1285 cmuxUITests/SidebarHelpMenuUITests.swift @@ -78,7 +78,7 @@ 1139 cmuxTests/PiVaultAgentPersistenceTests.swift 1126 cmuxTests/FileExplorerStoreTests.swift 1107 Sources/AppDelegate+CmuxSSHURL.swift -1096 Sources/GhosttyConfig.swift +1097 Sources/GhosttyConfig.swift 1093 cmuxUITests/BonsplitTabDragUITests.swift 1084 cmuxTests/AgentHibernationTests.swift 1084 cmuxTests/RestorableAgentSessionIndexTests.swift @@ -95,7 +95,7 @@ 913 cmuxTests/WorkspaceGroupTests.swift 905 Sources/CmuxSSHURLRequest.swift 897 Sources/CommandPalette/CommandPaletteSettingsToggle.swift -878 Sources/WorkspaceContentView.swift +879 Sources/WorkspaceContentView.swift 868 Sources/Panels/BrowserScreenshotSnapshotter.swift 864 Sources/Panels/TerminalPanel.swift 856 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift diff --git a/Packages/CmuxFoundation/Sources/CmuxFoundation/NSColor+HexString.swift b/Packages/CmuxFoundation/Sources/CmuxFoundation/NSColor+HexString.swift new file mode 100644 index 00000000000..69e114dec39 --- /dev/null +++ b/Packages/CmuxFoundation/Sources/CmuxFoundation/NSColor+HexString.swift @@ -0,0 +1,23 @@ +public import AppKit + +extension NSColor { + /// Returns the receiver as an uppercase `#RRGGBB` (or `#RRGGBBAA`) hex string, + /// converting to the sRGB color space first. + /// - Parameter includeAlpha: When `true`, appends the alpha byte as `#RRGGBBAA`. + public func hexString(includeAlpha: Bool = false) -> String { + let color = usingColorSpace(.sRGB) ?? self + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + let redByte = min(255, max(0, Int(red * 255))) + let greenByte = min(255, max(0, Int(green * 255))) + let blueByte = min(255, max(0, Int(blue * 255))) + if includeAlpha { + let alphaByte = min(255, max(0, Int(alpha * 255))) + return String(format: "#%02X%02X%02X%02X", redByte, greenByte, blueByte, alphaByte) + } + return String(format: "#%02X%02X%02X", redByte, greenByte, blueByte) + } +} diff --git a/Packages/CmuxFoundation/Tests/CmuxFoundationTests/NSColor+HexStringTests.swift b/Packages/CmuxFoundation/Tests/CmuxFoundationTests/NSColor+HexStringTests.swift new file mode 100644 index 00000000000..5ca68de7d10 --- /dev/null +++ b/Packages/CmuxFoundation/Tests/CmuxFoundationTests/NSColor+HexStringTests.swift @@ -0,0 +1,29 @@ +import AppKit +import Testing + +@testable import CmuxFoundation + +/// Behavior tests for ``AppKit/NSColor/hexString(includeAlpha:)``: round-trips known +/// sRGB component values to their `#RRGGBB` / `#RRGGBBAA` encoding. +@Suite struct NSColorHexStringTests { + @Test func opaquePrimaryEncodesAsUppercaseRGB() { + let red = NSColor(srgbRed: 1, green: 0, blue: 0, alpha: 1) + #expect(red.hexString() == "#FF0000") + } + + @Test func midGrayRoundsComponentsToBytes() { + let gray = NSColor(srgbRed: 0.5, green: 0.5, blue: 0.5, alpha: 1) + // 0.5 * 255 = 127.5 -> Int truncates to 127 -> 0x7F + #expect(gray.hexString() == "#7F7F7F") + } + + @Test func includeAlphaAppendsAlphaByte() { + let translucent = NSColor(srgbRed: 0, green: 0, blue: 1, alpha: 0.5) + #expect(translucent.hexString(includeAlpha: true) == "#0000FF7F") + } + + @Test func alphaIsOmittedByDefault() { + let translucent = NSColor(srgbRed: 0, green: 1, blue: 0, alpha: 0.25) + #expect(translucent.hexString() == "#00FF00") + } +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 71daa20d513..dee70ad79d0 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1,5 +1,6 @@ import AppKit import CmuxAppKitSupportUI +import CmuxFoundation import CmuxSocketControl import CmuxCommandPalette import Bonsplit @@ -18679,21 +18680,3 @@ enum SidebarPresetOption: String, CaseIterable, Identifiable { } } -extension NSColor { - func hexString(includeAlpha: Bool = false) -> String { - let color = usingColorSpace(.sRGB) ?? self - var red: CGFloat = 0 - var green: CGFloat = 0 - var blue: CGFloat = 0 - var alpha: CGFloat = 0 - color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) - let redByte = min(255, max(0, Int(red * 255))) - let greenByte = min(255, max(0, Int(green * 255))) - let blueByte = min(255, max(0, Int(blue * 255))) - if includeAlpha { - let alphaByte = min(255, max(0, Int(alpha * 255))) - return String(format: "#%02X%02X%02X%02X", redByte, greenByte, blueByte, alphaByte) - } - return String(format: "#%02X%02X%02X", redByte, greenByte, blueByte) - } -} diff --git a/Sources/Feed/FeedButtonStyleDebugWindowController.swift b/Sources/Feed/FeedButtonStyleDebugWindowController.swift index 260b664ec20..7981706160e 100644 --- a/Sources/Feed/FeedButtonStyleDebugWindowController.swift +++ b/Sources/Feed/FeedButtonStyleDebugWindowController.swift @@ -1,5 +1,6 @@ #if DEBUG import AppKit +import CmuxFoundation import SwiftUI enum FeedButtonDebugVisualStyle: String, CaseIterable, Identifiable { diff --git a/Sources/GhosttyConfig.swift b/Sources/GhosttyConfig.swift index 302f09a4bc5..0c265600cdc 100644 --- a/Sources/GhosttyConfig.swift +++ b/Sources/GhosttyConfig.swift @@ -1,5 +1,6 @@ import Foundation import AppKit +import CmuxFoundation struct GhosttyConfig { enum ColorSchemePreference: Hashable { diff --git a/Sources/GhosttyTerminalAppearance.swift b/Sources/GhosttyTerminalAppearance.swift index c6c8c78e4c7..bfec7841963 100644 --- a/Sources/GhosttyTerminalAppearance.swift +++ b/Sources/GhosttyTerminalAppearance.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxFoundation import Foundation enum GhosttyDefaultBackgroundUpdateScope: Int { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 6cda4542314..3ca59454c7e 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3,6 +3,7 @@ import CmuxTerminalCopyMode import CmuxSocketControl import SwiftUI import AppKit +import CmuxFoundation import Metal import QuartzCore import Combine diff --git a/Sources/Windowing/WindowAppearanceSnapshot.swift b/Sources/Windowing/WindowAppearanceSnapshot.swift index 920c9fbd2a1..cee8efafc5c 100644 --- a/Sources/Windowing/WindowAppearanceSnapshot.swift +++ b/Sources/Windowing/WindowAppearanceSnapshot.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxFoundation import SwiftUI enum GhosttyTerminalBackdropRenderingMode { diff --git a/Sources/Windowing/WindowBackdropController.swift b/Sources/Windowing/WindowBackdropController.swift index d4e3f9f4620..7ecf2b62b06 100644 --- a/Sources/Windowing/WindowBackdropController.swift +++ b/Sources/Windowing/WindowBackdropController.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxFoundation import SwiftUI enum WindowBackdropHostingPhase: String, Equatable { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 262666d75d9..8d833fee02b 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1,6 +1,7 @@ import Foundation import SwiftUI import AppKit +import CmuxFoundation import Bonsplit import CMUXAgentLaunch import CmuxSocketControl diff --git a/Sources/WorkspaceAppearanceResolution.swift b/Sources/WorkspaceAppearanceResolution.swift index bfabb7b1e72..6e0594058e6 100644 --- a/Sources/WorkspaceAppearanceResolution.swift +++ b/Sources/WorkspaceAppearanceResolution.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxFoundation import Foundation extension WorkspaceContentView { diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 128777f7a43..8f7b4acbcb3 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -1,6 +1,7 @@ import SwiftUI import Foundation import AppKit +import CmuxFoundation import Bonsplit enum TmuxOverlayExperimentTarget: String, CaseIterable, Codable, Sendable { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 8ec50f638da..278bd00bd0f 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxFoundation import CmuxSidebarInterpreterClient import CmuxSidebarRemoteRender import CmuxSocketControl From cd036a75a772797ee11cd2bfe2986bcb2a664c3c Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 12 Jun 2026 20:09:20 -0700 Subject: [PATCH 06/31] ContentView drain: lift String.nilIfEmpty into CmuxFoundation (stack E, Wave 1) Faithful Wave-1 foundation lift: the String.nilIfEmpty helper moves out of ContentView's private extension into Packages/CmuxFoundation as String+NilIfEmpty.swift, next to the existing String+JavaScriptStringLiteral extension. Body is byte-identical to pre-tranche HEAD (machine-diffed); only delta is public visibility + DocC. ContentView already imports CmuxFoundation (prior tranche), so its 4 call sites resolve unchanged; the helper was ContentView-private with no other callers, so no further adoption is needed. Sole nilIfEmpty declaration now lives in the package. ContentView.swift: 18,682 -> 18,677 lines. CmuxFoundation builds + 3 new behavior tests pass; app compile BUILD SUCCEEDED (0 errors); lint exit 0; budget ratcheted down. Co-Authored-By: Claude Fable 5 --- .github/swift-file-length-budget.tsv | 2 +- .../CmuxFoundation/String+NilIfEmpty.swift | 8 ++++++++ .../String+NilIfEmptyTests.swift | 18 ++++++++++++++++++ Sources/ContentView.swift | 5 ----- 4 files changed, 27 insertions(+), 6 deletions(-) create mode 100644 Packages/CmuxFoundation/Sources/CmuxFoundation/String+NilIfEmpty.swift create mode 100644 Packages/CmuxFoundation/Tests/CmuxFoundationTests/String+NilIfEmptyTests.swift diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 38015952369..48a4ac5f288 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -3,7 +3,7 @@ # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 33285 CLI/cmux.swift 19991 Sources/Workspace.swift -18682 Sources/ContentView.swift +18677 Sources/ContentView.swift 18118 Sources/AppDelegate.swift 16675 Sources/GhosttyTerminalView.swift 14622 Sources/TerminalController.swift diff --git a/Packages/CmuxFoundation/Sources/CmuxFoundation/String+NilIfEmpty.swift b/Packages/CmuxFoundation/Sources/CmuxFoundation/String+NilIfEmpty.swift new file mode 100644 index 00000000000..efe24381c99 --- /dev/null +++ b/Packages/CmuxFoundation/Sources/CmuxFoundation/String+NilIfEmpty.swift @@ -0,0 +1,8 @@ +/// Returns `nil` for an empty string and the string itself otherwise, so callers can +/// collapse empty-or-missing text to a single optional at the use site. +extension String { + /// `nil` when the string is empty, otherwise `self`. + public var nilIfEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/Packages/CmuxFoundation/Tests/CmuxFoundationTests/String+NilIfEmptyTests.swift b/Packages/CmuxFoundation/Tests/CmuxFoundationTests/String+NilIfEmptyTests.swift new file mode 100644 index 00000000000..01a9536a549 --- /dev/null +++ b/Packages/CmuxFoundation/Tests/CmuxFoundationTests/String+NilIfEmptyTests.swift @@ -0,0 +1,18 @@ +import Testing + +@testable import CmuxFoundation + +@Suite struct StringNilIfEmptyTests { + @Test func emptyStringBecomesNil() { + #expect("".nilIfEmpty == nil) + } + + @Test func nonEmptyStringPassesThrough() { + #expect("cmux".nilIfEmpty == "cmux") + } + + @Test func whitespaceIsNotEmpty() { + // nilIfEmpty only checks isEmpty; a space is non-empty and passes through. + #expect(" ".nilIfEmpty == " ") + } +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index dee70ad79d0..ef7b57cf6ed 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9811,11 +9811,6 @@ struct SidebarTabItemSettingsSnapshot: Equatable { } -private extension String { - var nilIfEmpty: String? { - isEmpty ? nil : self - } -} enum CmuxExtensionSidebarSelection { static let defaultsKey = "cmuxExtensionSidebar.providerId" From cffc1f3272f8b97999add8fb8457a3848f3ad7c8 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 12 Jun 2026 20:19:26 -0700 Subject: [PATCH 07/31] ContentView drain: import CmuxFoundation in cmuxTests callers of NSColor.hexString (stack E) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test-depot (run 27454829534) failed: moving NSColor.hexString into CmuxFoundation left 7 cmuxTests files referencing it without the package import, so cmuxTests failed to compile (the app-host unit-test gate that package swift build and the no-test app build do not exercise — the documented Stack E tranche-1 trap). Adds 'import CmuxFoundation' (hoisted above the #if canImport(cmux_DEV) block, right after import AppKit) to: GhosttyConfigTests, GhosttyNotificationDispatcherTests, TerminalAndGhosttyTests, WindowAndDragTests, WindowAppearanceSnapshotTests, WorkspaceAppearanceConfigResolutionTests, WorkspaceUnitTests. The test target already links CmuxFoundation (SentryEventScrubberTests imports it on main), so no pbxproj change is needed. nilIfEmpty and the CmuxAppKitSupportUI types have zero cmuxTests references, so no further test imports are required. Budget ratcheted for the +1 import line on the three tracked large test files. Co-Authored-By: Claude Fable 5 --- .github/swift-file-length-budget.tsv | 8 ++++---- cmuxTests/GhosttyConfigTests.swift | 1 + cmuxTests/GhosttyNotificationDispatcherTests.swift | 1 + cmuxTests/TerminalAndGhosttyTests.swift | 1 + cmuxTests/WindowAndDragTests.swift | 1 + cmuxTests/WindowAppearanceSnapshotTests.swift | 1 + cmuxTests/WorkspaceAppearanceConfigResolutionTests.swift | 1 + cmuxTests/WorkspaceUnitTests.swift | 1 + 8 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 48a4ac5f288..0bc821c9596 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -12,11 +12,11 @@ 10020 Sources/TabManager.swift 9345 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 7737 Sources/Panels/BrowserPanelView.swift -7291 cmuxTests/WorkspaceUnitTests.swift +7292 cmuxTests/WorkspaceUnitTests.swift 6948 cmuxTests/WorkspaceRemoteConnectionTests.swift -6542 cmuxTests/GhosttyConfigTests.swift +6543 cmuxTests/GhosttyConfigTests.swift 6329 cmuxTests/SessionPersistenceTests.swift -6299 cmuxTests/TerminalAndGhosttyTests.swift +6300 cmuxTests/TerminalAndGhosttyTests.swift 6153 CLI/cmux_open.swift 6071 Sources/TextBoxInput.swift 5482 cmuxTests/BrowserConfigTests.swift @@ -25,7 +25,7 @@ 4460 Sources/Panels/FilePreviewPanel.swift 4400 cmuxTests/BrowserPanelTests.swift 4227 Sources/BrowserWindowPortal.swift -4009 cmuxTests/WindowAndDragTests.swift +4010 cmuxTests/WindowAndDragTests.swift 3937 Sources/Feed/FeedPanelView.swift 3760 cmuxTests/TabManagerUnitTests.swift 3699 cmuxTests/CLIGenericHookPersistenceTests.swift diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 2bcf6e7cf3e..f57743538ba 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -2,6 +2,7 @@ import CmuxSettings import CmuxSocketControl import AppKit +import CmuxFoundation import Combine import CoreText import WebKit diff --git a/cmuxTests/GhosttyNotificationDispatcherTests.swift b/cmuxTests/GhosttyNotificationDispatcherTests.swift index 1619d0166ae..fef79f1ed74 100644 --- a/cmuxTests/GhosttyNotificationDispatcherTests.swift +++ b/cmuxTests/GhosttyNotificationDispatcherTests.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxFoundation import XCTest #if canImport(cmux_DEV) diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index 1183524d7ad..de08ea6246f 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -4,6 +4,7 @@ import CmuxControlSocket import CmuxTerminalCopyMode import CmuxSocketControl import AppKit +import CmuxFoundation import SwiftUI import UniformTypeIdentifiers import WebKit diff --git a/cmuxTests/WindowAndDragTests.swift b/cmuxTests/WindowAndDragTests.swift index e7f5735707e..33947fb2112 100644 --- a/cmuxTests/WindowAndDragTests.swift +++ b/cmuxTests/WindowAndDragTests.swift @@ -1,5 +1,6 @@ import XCTest import AppKit +import CmuxFoundation import Carbon.HIToolbox import Darwin import PDFKit diff --git a/cmuxTests/WindowAppearanceSnapshotTests.swift b/cmuxTests/WindowAppearanceSnapshotTests.swift index 11ba6c28711..af1a22dc9bf 100644 --- a/cmuxTests/WindowAppearanceSnapshotTests.swift +++ b/cmuxTests/WindowAppearanceSnapshotTests.swift @@ -1,5 +1,6 @@ import XCTest import AppKit +import CmuxFoundation import SwiftUI #if canImport(cmux_DEV) diff --git a/cmuxTests/WorkspaceAppearanceConfigResolutionTests.swift b/cmuxTests/WorkspaceAppearanceConfigResolutionTests.swift index 06291792f5d..0d39ed35df9 100644 --- a/cmuxTests/WorkspaceAppearanceConfigResolutionTests.swift +++ b/cmuxTests/WorkspaceAppearanceConfigResolutionTests.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxFoundation import XCTest #if canImport(cmux_DEV) diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 5fde169a3e6..2c87ff9d3d7 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -1,5 +1,6 @@ import XCTest import AppKit +import CmuxFoundation import SwiftUI import UniformTypeIdentifiers import WebKit From 6f0aadd2f673762e650eab96d85dead108c9d96d Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 12 Jun 2026 22:25:26 -0700 Subject: [PATCH 08/31] ContentView drain: lift sidebar scroll-view resolver cluster into CmuxAppKitSupportUI (stack E) Faithfully lift SidebarScrollViewResolver (NSViewRepresentable, was in ContentView.swift), SidebarScrollViewResolverView, and SidebarScrollViewConfigurator into CmuxAppKitSupportUI/Scroll/. These are AppKit-only sidebar scroll-resolution primitives with no domain reach-through, completing the package's scroll cluster per ContentView.plan.md. Bodies are byte-identical; the only deltas are public access modifiers and public import (cross-module), an explicit public init on the resolver (memberwise inits aren't public across modules), and nonisolated(unsafe) on the observer token to satisfy Swift 6 strict-concurrency in the package (accessed only on the main thread; removed in the nonisolated deinit after main-thread access has ceased; justified inline). Removed the two app-side files from pbxproj (8 entries), normalized, ratcheted the file-length budget for ContentView (18677 -> 18662). Co-Authored-By: Claude Fable 5 --- .github/swift-file-length-budget.tsv | 2 +- .../SidebarScrollViewConfigurator.swift | 13 +++++--- .../Scroll/SidebarScrollViewResolver.swift | 28 ++++++++++++++++ .../SidebarScrollViewResolverView.swift | 33 ++++++++++++------- Sources/ContentView.swift | 15 --------- cmux.xcodeproj/project.pbxproj | 8 ----- 6 files changed, 59 insertions(+), 40 deletions(-) rename {Sources => Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll}/SidebarScrollViewConfigurator.swift (63%) create mode 100644 Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarScrollViewResolver.swift rename {Sources => Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll}/SidebarScrollViewResolverView.swift (58%) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 0bc821c9596..cab502cb40f 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -3,7 +3,7 @@ # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 33285 CLI/cmux.swift 19991 Sources/Workspace.swift -18677 Sources/ContentView.swift +18662 Sources/ContentView.swift 18118 Sources/AppDelegate.swift 16675 Sources/GhosttyTerminalView.swift 14622 Sources/TerminalController.swift diff --git a/Sources/SidebarScrollViewConfigurator.swift b/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarScrollViewConfigurator.swift similarity index 63% rename from Sources/SidebarScrollViewConfigurator.swift rename to Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarScrollViewConfigurator.swift index 8ec4334e187..c314807205a 100644 --- a/Sources/SidebarScrollViewConfigurator.swift +++ b/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarScrollViewConfigurator.swift @@ -1,15 +1,18 @@ -import AppKit +public import AppKit /// Applies the sidebar workspace list's stable overlay-scroller configuration. /// -/// `SidebarScrollViewResolver` re-resolves on every SwiftUI update of the -/// sidebar, so `apply(to:)` is called repeatedly for the same scroll view — +/// ``SidebarScrollViewResolver`` re-resolves on every SwiftUI update of the +/// sidebar, so ``apply(to:)`` is called repeatedly for the same scroll view — /// including while AppKit is mid-way through an overlay-scroller fade. Any /// write to these properties (even with an unchanged value) re-tiles the /// scrollers and can cancel the in-flight fade without rescheduling it, /// stranding the knob permanently visible (#3241 follow-up). -enum SidebarScrollViewConfigurator { - static func apply(to scrollView: NSScrollView) { +public enum SidebarScrollViewConfigurator { + /// Forces the scroll view into the stable overlay-scroller configuration, + /// writing each property only when it differs to avoid cancelling an + /// in-flight scroller fade. + public static func apply(to scrollView: NSScrollView) { if scrollView.hasHorizontalScroller { scrollView.hasHorizontalScroller = false } diff --git a/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarScrollViewResolver.swift b/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarScrollViewResolver.swift new file mode 100644 index 00000000000..c1b2de9dcdd --- /dev/null +++ b/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarScrollViewResolver.swift @@ -0,0 +1,28 @@ +public import AppKit +public import SwiftUI + +/// An invisible `NSViewRepresentable` that resolves the sidebar list's +/// enclosing `NSScrollView` for the SwiftUI layer and reports it back through +/// `onResolve`, so callers can apply overlay-scroller configuration +/// (`SidebarScrollViewConfigurator`). +public struct SidebarScrollViewResolver: NSViewRepresentable { + /// Invoked with the resolved enclosing scroll view (or `nil` when none is + /// reachable yet) on every resolution pass. + public let onResolve: (NSScrollView?) -> Void + + /// Creates a resolver that reports the enclosing scroll view via `onResolve`. + public init(onResolve: @escaping (NSScrollView?) -> Void) { + self.onResolve = onResolve + } + + public func makeNSView(context: Context) -> SidebarScrollViewResolverView { + let view = SidebarScrollViewResolverView() + view.onResolve = onResolve + return view + } + + public func updateNSView(_ nsView: SidebarScrollViewResolverView, context: Context) { + nsView.onResolve = onResolve + nsView.resolveScrollView() + } +} diff --git a/Sources/SidebarScrollViewResolverView.swift b/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarScrollViewResolverView.swift similarity index 58% rename from Sources/SidebarScrollViewResolverView.swift rename to Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarScrollViewResolverView.swift index aa81c78e9ea..a2f76ec55d4 100644 --- a/Sources/SidebarScrollViewResolverView.swift +++ b/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarScrollViewResolverView.swift @@ -1,14 +1,22 @@ -import AppKit +public import AppKit /// Resolves the sidebar list's enclosing `NSScrollView` for the SwiftUI layer -/// (`SidebarScrollViewResolver` in `ContentView.swift`), which applies -/// `SidebarScrollViewConfigurator`'s overlay configuration through +/// (``SidebarScrollViewResolver``), which applies +/// ``SidebarScrollViewConfigurator``'s overlay configuration through /// `onResolve`. -final class SidebarScrollViewResolverView: NSView { - var onResolve: ((NSScrollView?) -> Void)? - private var scrollerStyleObserver: NSObjectProtocol? +public final class SidebarScrollViewResolverView: NSView { + /// Invoked with the resolved enclosing scroll view (or `nil`) after each + /// deferred resolution hop. + public var onResolve: ((NSScrollView?) -> Void)? + // The observer token is only ever assigned/read on the main thread (this is + // a main-thread-only NSView); the lone exception is its removal in the + // nonisolated deinit, which is safe because deinit runs after all main-thread + // access has ceased. `nonisolated(unsafe)` keeps that one cross-isolation + // read legal under Swift 6 without weakening the type. + private nonisolated(unsafe) var scrollerStyleObserver: NSObjectProtocol? - override init(frame frameRect: NSRect) { + /// Creates the resolver view and begins observing scroller-style changes. + public override init(frame frameRect: NSRect) { super.init(frame: frameRect) // AppKit resets every NSScrollView's scrollerStyle to the new system // preference when the preferred scroller style changes (mouse @@ -29,7 +37,7 @@ final class SidebarScrollViewResolverView: NSView { } } - required init?(coder: NSCoder) { + public required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") } @@ -39,17 +47,20 @@ final class SidebarScrollViewResolverView: NSView { } } - override func viewDidMoveToSuperview() { + public override func viewDidMoveToSuperview() { super.viewDidMoveToSuperview() resolveScrollView() } - override func viewDidMoveToWindow() { + public override func viewDidMoveToWindow() { super.viewDidMoveToWindow() resolveScrollView() } - func resolveScrollView() { + /// Resolves the enclosing scroll view after one deferred main-actor hop so + /// the view hierarchy settles and any AppKit scroller-style reset lands + /// before the configuration is re-applied. + public func resolveScrollView() { // Deferred one main-actor hop so the view hierarchy settles before // enclosingScrollView is resolved and, on scroller-style changes, // AppKit's own synchronous per-scroll-view reset lands before the diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index ef7b57cf6ed..86b41f68316 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -14526,21 +14526,6 @@ private struct SidebarDevFooter: View { } #endif -private struct SidebarScrollViewResolver: NSViewRepresentable { - let onResolve: (NSScrollView?) -> Void - - func makeNSView(context: Context) -> SidebarScrollViewResolverView { - let view = SidebarScrollViewResolverView() - view.onResolve = onResolve - return view - } - - func updateNSView(_ nsView: SidebarScrollViewResolverView, context: Context) { - nsView.onResolve = onResolve - nsView.resolveScrollView() - } -} - private struct SidebarEmptyArea: View { @EnvironmentObject var tabManager: TabManager let rowSpacing: CGFloat diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index ddb7981b74e..c8689c0a868 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -596,9 +596,7 @@ B9000130A1B2C3D4E5F60719 /* SidebarPullRequestInteractivityUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000131A1B2C3D4E5F60719 /* SidebarPullRequestInteractivityUITests.swift */; }; B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */; }; C0DE35010000000000000001 /* SidebarScrim.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE35010000000000000002 /* SidebarScrim.swift */; }; - C9A57511C9A57511C9A57511 /* SidebarScrollViewConfigurator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57512C9A57512C9A57512 /* SidebarScrollViewConfigurator.swift */; }; C9A57513C9A57513C9A57513 /* SidebarScrollViewConfiguratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57514C9A57514C9A57514 /* SidebarScrollViewConfiguratorTests.swift */; }; - C9A57515C9A57515C9A57515 /* SidebarScrollViewResolverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57516C9A57516C9A57516 /* SidebarScrollViewResolverView.swift */; }; E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */; }; F57072635F25EBCA741E125D /* SidebarState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1614EAD3CCF70A177A51BD1 /* SidebarState.swift */; }; D7AB34300000000000000105 /* SidebarTabDropIndicatorPredicateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000106 /* SidebarTabDropIndicatorPredicateTests.swift */; }; @@ -1397,9 +1395,7 @@ B9000131A1B2C3D4E5F60719 /* SidebarPullRequestInteractivityUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarPullRequestInteractivityUITests.swift; sourceTree = ""; }; 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = ""; }; C0DE35010000000000000002 /* SidebarScrim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarScrim.swift; sourceTree = ""; }; - C9A57512C9A57512C9A57512 /* SidebarScrollViewConfigurator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarScrollViewConfigurator.swift; sourceTree = ""; }; C9A57514C9A57514C9A57514 /* SidebarScrollViewConfiguratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarScrollViewConfiguratorTests.swift; sourceTree = ""; }; - C9A57516C9A57516C9A57516 /* SidebarScrollViewResolverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarScrollViewResolverView.swift; sourceTree = ""; }; 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionState.swift; sourceTree = ""; }; D1614EAD3CCF70A177A51BD1 /* SidebarState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarState.swift; sourceTree = ""; }; D7AB34300000000000000106 /* SidebarTabDropIndicatorPredicateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarTabDropIndicatorPredicateTests.swift; sourceTree = ""; }; @@ -1874,8 +1870,6 @@ C9A5710AC9A5710AC9A5710A /* SidebarWorkspaceGroupingMetrics.swift */, D5037010000000000000002 /* RenderableSystemSymbol.swift */, C9A57202C9A57202C9A57202 /* SidebarWorkspaceRenderItem.swift */, - C9A57512C9A57512C9A57512 /* SidebarScrollViewConfigurator.swift */, - C9A57516C9A57516C9A57516 /* SidebarScrollViewResolverView.swift */, C9A57502C9A57502C9A57502 /* SidebarWorkspaceRowsHeightPreferenceKey.swift */, C9A57504C9A57504C9A57504 /* SidebarWorkspaceRowsMeasurement.swift */, C9A5720CC9A5720CC9A5720C /* TabItemView+WorkspaceGroups.swift */, @@ -3206,8 +3200,6 @@ EA1F00000000000000000001 /* SidebarPathFormatter.swift in Sources */, C0DEF0A30000000000000001 /* SidebarPortDisplayText.swift in Sources */, C0DE35010000000000000001 /* SidebarScrim.swift in Sources */, - C9A57511C9A57511C9A57511 /* SidebarScrollViewConfigurator.swift in Sources */, - C9A57515C9A57515C9A57515 /* SidebarScrollViewResolverView.swift in Sources */, E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */, F57072635F25EBCA741E125D /* SidebarState.swift in Sources */, C9A57103C9A57103C9A57103 /* SidebarWorkspaceGroupConfigOpener.swift in Sources */, From ff864cf98145308499eddf5088fd548befffe040 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 12 Jun 2026 22:31:40 -0700 Subject: [PATCH 09/31] ContentView drain: retarget SidebarScrollViewConfiguratorTests to CmuxAppKitSupportUI (stack E) The app-host XCTest SidebarScrollViewConfiguratorTests (regression coverage for the #3241 stuck-scrollbar bug) referenced SidebarScrollViewConfigurator and SidebarScrollViewResolverView, which moved into CmuxAppKitSupportUI in the prior commit. test-depot (the only gate that compiles cmuxTests) went red because the symbols no longer live in the app module. Add 'import CmuxAppKitSupportUI' hoisted above the canImport(cmux_DEV) block (matching the SentryEventScrubberTests/CmuxFoundation precedent and avoiding the dead-conditional-import trap). All test logic and assertions are byte-identical; this is a pure faithful retarget of the call sites, no coverage weakened. Co-Authored-By: Claude Fable 5 --- cmuxTests/SidebarScrollViewConfiguratorTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/cmuxTests/SidebarScrollViewConfiguratorTests.swift b/cmuxTests/SidebarScrollViewConfiguratorTests.swift index dc3ff68c9a4..111f34963be 100644 --- a/cmuxTests/SidebarScrollViewConfiguratorTests.swift +++ b/cmuxTests/SidebarScrollViewConfiguratorTests.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxAppKitSupportUI import Testing #if canImport(cmux_DEV) From fdc6420733c37c8d8515c8d1620d85671fdbb5b1 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 12 Jun 2026 22:46:38 -0700 Subject: [PATCH 10/31] ContentView drain: lift feedback composer domain into CmuxFeedback (stack E, Wave 2) Lifts the feedback-composer domain vertical out of ContentView into a new CmuxFeedback package: FeedbackComposerSettings, FeedbackComposerAttachment, PreparedFeedbackComposerAttachment, FeedbackComposerAppMetadata, FeedbackComposerSubmissionError, FeedbackComposerClient, FeedbackComposerBridgeError, FeedbackComposerBridge. Bodies are byte-identical; the only deltas are the expected cross-module ones (public/public import, explicit public visibility, @MainActor on openComposer which reads NSApp, and any Error for ExistentialAny). The .feedbackComposerRequested Notification.Name is redeclared in the package with the identical raw value 'cmux.feedbackComposerRequested' so posts from the package interoperate with the app's existing observers (the app-side constant in TabManager.swift is unchanged). Defaults key 'sidebarHelpFeedbackEmail' and the endpoint env override are preserved byte-identical. Consumers re-import CmuxFeedback: ContentView (composer sheet UI half, still app-side), CmuxHelpCommands, TerminalController, and TerminalController+ControlSystemContext. AppDelegate only posts the notification via its own constant and needs no import. The feedback UI half (SidebarFeedbackComposerSheet + message-editor NSViews) stays in ContentView as a later CmuxFeedbackUI Wave-3 lift. Package: swift build + 5 behavior tests green. App: full bounded xcodebuild BUILD SUCCEEDED. pbxproj wired with 6 mirrored entries, normalized + checked. Co-Authored-By: Claude Fable 5 --- .github/swift-file-length-budget.tsv | 4 +- Packages/CmuxFeedback/Package.swift | 35 ++ .../Bridge/FeedbackComposerBridge.swift | 101 ++++ .../Bridge/FeedbackComposerBridgeError.swift | 28 + .../Client/FeedbackComposerClient.swift | 244 +++++++++ .../FeedbackComposerSubmissionError.swift | 13 + .../Models/FeedbackComposerAppMetadata.swift | 76 +++ .../Models/FeedbackComposerAttachment.swift | 37 ++ .../PreparedFeedbackComposerAttachment.swift | 9 + .../FeedbackComposerNotification.swift | 9 + .../Settings/FeedbackComposerSettings.swift | 28 + .../FeedbackComposerBridgeTests.swift | 50 ++ Sources/App/CmuxHelpCommands.swift | 1 + Sources/ContentView.swift | 495 +----------------- ...minalController+ControlSystemContext.swift | 1 + Sources/TerminalController.swift | 1 + cmux.xcodeproj/project.pbxproj | 12 + 17 files changed, 648 insertions(+), 496 deletions(-) create mode 100644 Packages/CmuxFeedback/Package.swift create mode 100644 Packages/CmuxFeedback/Sources/CmuxFeedback/Bridge/FeedbackComposerBridge.swift create mode 100644 Packages/CmuxFeedback/Sources/CmuxFeedback/Bridge/FeedbackComposerBridgeError.swift create mode 100644 Packages/CmuxFeedback/Sources/CmuxFeedback/Client/FeedbackComposerClient.swift create mode 100644 Packages/CmuxFeedback/Sources/CmuxFeedback/Client/FeedbackComposerSubmissionError.swift create mode 100644 Packages/CmuxFeedback/Sources/CmuxFeedback/Models/FeedbackComposerAppMetadata.swift create mode 100644 Packages/CmuxFeedback/Sources/CmuxFeedback/Models/FeedbackComposerAttachment.swift create mode 100644 Packages/CmuxFeedback/Sources/CmuxFeedback/Models/PreparedFeedbackComposerAttachment.swift create mode 100644 Packages/CmuxFeedback/Sources/CmuxFeedback/Notifications/FeedbackComposerNotification.swift create mode 100644 Packages/CmuxFeedback/Sources/CmuxFeedback/Settings/FeedbackComposerSettings.swift create mode 100644 Packages/CmuxFeedback/Tests/CmuxFeedbackTests/FeedbackComposerBridgeTests.swift diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index cab502cb40f..0ffa00d3c44 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -3,10 +3,10 @@ # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 33285 CLI/cmux.swift 19991 Sources/Workspace.swift -18662 Sources/ContentView.swift +18169 Sources/ContentView.swift 18118 Sources/AppDelegate.swift 16675 Sources/GhosttyTerminalView.swift -14622 Sources/TerminalController.swift +14623 Sources/TerminalController.swift 13606 Sources/Panels/BrowserPanel.swift 12044 cmuxTests/AppDelegateShortcutRoutingTests.swift 10020 Sources/TabManager.swift diff --git a/Packages/CmuxFeedback/Package.swift b/Packages/CmuxFeedback/Package.swift new file mode 100644 index 00000000000..cc4625ffea6 --- /dev/null +++ b/Packages/CmuxFeedback/Package.swift @@ -0,0 +1,35 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "CmuxFeedback", + platforms: [ + .macOS(.v14), + ], + products: [ + .library( + name: "CmuxFeedback", + targets: ["CmuxFeedback"] + ), + ], + targets: [ + .target( + name: "CmuxFeedback", + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + .testTarget( + name: "CmuxFeedbackTests", + dependencies: ["CmuxFeedback"], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + ] +) diff --git a/Packages/CmuxFeedback/Sources/CmuxFeedback/Bridge/FeedbackComposerBridge.swift b/Packages/CmuxFeedback/Sources/CmuxFeedback/Bridge/FeedbackComposerBridge.swift new file mode 100644 index 00000000000..d40c2b5a860 --- /dev/null +++ b/Packages/CmuxFeedback/Sources/CmuxFeedback/Bridge/FeedbackComposerBridge.swift @@ -0,0 +1,101 @@ +public import AppKit +import Foundation + +/// Validates feedback input, drives ``FeedbackComposerClient`` to upload it, and +/// posts the ``Notification/Name/feedbackComposerRequested`` request to present +/// the composer. The public entry points the app and command surfaces call. +public enum FeedbackComposerBridge { + /// Requests the feedback composer be presented, targeting `window` (defaults + /// to the key/main window). `@MainActor` because it reads `NSApp` and posts a + /// window-scoped notification. + @MainActor + public static func openComposer(in window: NSWindow? = NSApp.keyWindow ?? NSApp.mainWindow) { + NotificationCenter.default.post(name: .feedbackComposerRequested, object: window) + } + + /// Validates and submits feedback, persisting the email on success. Returns + /// the attachment count that was uploaded. + public static func submit( + email: String, + message: String, + imagePaths: [String] + ) async throws -> Int { + let trimmedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines) + + guard isValidEmail(trimmedEmail) else { + throw FeedbackComposerBridgeError.invalidEmail + } + guard normalizedMessage.isEmpty == false else { + throw FeedbackComposerBridgeError.emptyMessage + } + guard message.count <= FeedbackComposerSettings.maxMessageLength else { + throw FeedbackComposerBridgeError.messageTooLong + } + guard imagePaths.count <= FeedbackComposerSettings.maxAttachmentCount else { + throw FeedbackComposerBridgeError.tooManyImages + } + + let attachments = try imagePaths.map { rawPath in + let resolvedURL = URL(fileURLWithPath: rawPath).standardizedFileURL + do { + return try FeedbackComposerAttachment(url: resolvedURL) + } catch { + throw FeedbackComposerBridgeError.invalidImagePath(resolvedURL.path) + } + } + + do { + try await FeedbackComposerClient.submit( + email: trimmedEmail, + message: normalizedMessage, + attachments: attachments + ) + } catch { + throw FeedbackComposerBridgeError.submissionFailed(userFacingMessage(for: error)) + } + + UserDefaults.standard.set(trimmedEmail, forKey: FeedbackComposerSettings.storedEmailKey) + return attachments.count + } + + private static func isValidEmail(_ rawValue: String) -> Bool { + let email = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard email.isEmpty == false else { return false } + let pattern = #"^[A-Z0-9a-z._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$"# + return NSPredicate(format: "SELF MATCHES %@", pattern).evaluate(with: email) + } + + private static func userFacingMessage(for error: any Error) -> String { + guard let submissionError = error as? FeedbackComposerSubmissionError else { + return "Couldn't send feedback. Please try again." + } + + switch submissionError { + case .invalidEndpoint: + return "Feedback is unavailable right now. Email founders@manaflow.com instead." + case .invalidResponse: + return "Couldn't send feedback. Please try again." + case .attachmentReadFailed: + return "One of the selected files could not be attached." + case .attachmentPreparationFailed: + return "These images are too large to send together. Remove a few and try again." + case .transport(let transportError): + if transportError.code == .notConnectedToInternet || transportError.code == .networkConnectionLost { + return "Couldn't send feedback. Check your connection and try again." + } + return "Couldn't send feedback. Please try again." + case .rejected(let statusCode): + switch statusCode { + case 400, 413, 415: + return "Check your message and attachments, then try again." + case 429: + return "Too many feedback attempts. Please try again later." + case 500...599: + return "Feedback is unavailable right now. Email founders@manaflow.com instead." + default: + return "Couldn't send feedback. Please try again." + } + } + } +} diff --git a/Packages/CmuxFeedback/Sources/CmuxFeedback/Bridge/FeedbackComposerBridgeError.swift b/Packages/CmuxFeedback/Sources/CmuxFeedback/Bridge/FeedbackComposerBridgeError.swift new file mode 100644 index 00000000000..8c0ef1d3a26 --- /dev/null +++ b/Packages/CmuxFeedback/Sources/CmuxFeedback/Bridge/FeedbackComposerBridgeError.swift @@ -0,0 +1,28 @@ +public import Foundation + +/// User-facing validation and submission failures for the feedback composer. +public enum FeedbackComposerBridgeError: LocalizedError { + case invalidEmail + case emptyMessage + case messageTooLong + case tooManyImages + case invalidImagePath(String) + case submissionFailed(String) + + public var errorDescription: String? { + switch self { + case .invalidEmail: + return "Enter a valid email address." + case .emptyMessage: + return "Enter a message before sending." + case .messageTooLong: + return "Your message is too long." + case .tooManyImages: + return "You can attach up to 10 images." + case .invalidImagePath(let path): + return "Could not attach image: \(path)" + case .submissionFailed(let message): + return message + } + } +} diff --git a/Packages/CmuxFeedback/Sources/CmuxFeedback/Client/FeedbackComposerClient.swift b/Packages/CmuxFeedback/Sources/CmuxFeedback/Client/FeedbackComposerClient.swift new file mode 100644 index 00000000000..934748dd617 --- /dev/null +++ b/Packages/CmuxFeedback/Sources/CmuxFeedback/Client/FeedbackComposerClient.swift @@ -0,0 +1,244 @@ +import AppKit +import CoreGraphics +import Foundation +import ImageIO + +/// Builds and uploads the feedback multipart request: gathers app metadata, +/// downsamples/optimizes image attachments to fit the upload budget, and posts +/// to the resolved endpoint. Surfaces failures as ``FeedbackComposerSubmissionError``. +public enum FeedbackComposerClient { + private static let passthroughAttachmentMIMETypes: Set = [ + "image/gif", + "image/heic", + "image/heif", + "image/jpeg", + "image/png", + "image/tiff", + "image/webp", + ] + private static let optimizedAttachmentDimensions: [Int] = [2800, 2400, 2000, 1600, 1280, 1024, 768, 640, 512] + private static let optimizedAttachmentQualities: [CGFloat] = [0.82, 0.72, 0.62, 0.52, 0.42, 0.32] + private static let optimizedAttachmentMIMEType = "image/jpeg" + + public static func submit( + email: String, + message: String, + attachments: [FeedbackComposerAttachment] + ) async throws { + guard let endpointURL = FeedbackComposerSettings.endpointURL() else { + throw FeedbackComposerSubmissionError.invalidEndpoint + } + + let metadata = FeedbackComposerAppMetadata.current + let boundary = "Boundary-\(UUID().uuidString)" + let preparedAttachments = try prepareAttachmentsForUpload(attachments) + + var request = URLRequest(url: endpointURL) + request.httpMethod = "POST" + request.timeoutInterval = 30 + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + var body = Data() + appendField("email", value: email, to: &body, boundary: boundary) + appendField("message", value: message, to: &body, boundary: boundary) + appendField("appVersion", value: metadata.appVersion, to: &body, boundary: boundary) + appendField("appBuild", value: metadata.appBuild, to: &body, boundary: boundary) + appendField("appCommit", value: metadata.appCommit, to: &body, boundary: boundary) + appendField("bundleIdentifier", value: metadata.bundleIdentifier, to: &body, boundary: boundary) + appendField("osVersion", value: metadata.osVersion, to: &body, boundary: boundary) + appendField("locale", value: metadata.localeIdentifier, to: &body, boundary: boundary) + appendField("hardwareModel", value: metadata.hardwareModel, to: &body, boundary: boundary) + appendField("chip", value: metadata.chip, to: &body, boundary: boundary) + appendField("memoryGB", value: metadata.memoryGB, to: &body, boundary: boundary) + appendField("architecture", value: metadata.architecture, to: &body, boundary: boundary) + appendField("displayInfo", value: metadata.displayInfo, to: &body, boundary: boundary) + + for attachment in preparedAttachments { + appendFile( + named: "attachments", + attachment: attachment, + to: &body, + boundary: boundary + ) + } + + body.append(Data("--\(boundary)--\r\n".utf8)) + request.httpBody = body + + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch let error as URLError { + throw FeedbackComposerSubmissionError.transport(error) + } catch { + throw FeedbackComposerSubmissionError.invalidResponse + } + + guard let httpResponse = response as? HTTPURLResponse else { + throw FeedbackComposerSubmissionError.invalidResponse + } + + guard (200..<300).contains(httpResponse.statusCode) else { + if let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let errorMessage = payload["error"] as? String, + errorMessage.isEmpty == false { + #if DEBUG + NSLog("feedback.submit.rejected status=%@ error=%@", String(httpResponse.statusCode), errorMessage) + #endif + } + throw FeedbackComposerSubmissionError.rejected(statusCode: httpResponse.statusCode) + } + } + + private static func appendField( + _ name: String, + value: String, + to body: inout Data, + boundary: String + ) { + body.append(Data("--\(boundary)\r\n".utf8)) + body.append(Data("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".utf8)) + body.append(Data(value.utf8)) + body.append(Data("\r\n".utf8)) + } + + private static func prepareAttachmentsForUpload( + _ attachments: [FeedbackComposerAttachment] + ) throws -> [PreparedFeedbackComposerAttachment] { + guard attachments.isEmpty == false else { return [] } + + struct IndexedAttachment { + let index: Int + let attachment: FeedbackComposerAttachment + } + + let sortedAttachments = attachments.enumerated() + .map { IndexedAttachment(index: $0.offset, attachment: $0.element) } + .sorted { lhs, rhs in + lhs.attachment.fileSize > rhs.attachment.fileSize + } + + var preparedByIndex: [Int: PreparedFeedbackComposerAttachment] = [:] + var remainingBudget = FeedbackComposerSettings.targetTotalAttachmentUploadBytes + var remainingCount = sortedAttachments.count + + for item in sortedAttachments { + let perAttachmentBudget = max(1, remainingBudget / max(remainingCount, 1)) + let preparedAttachment = try prepareAttachmentForUpload( + item.attachment, + maximumByteCount: perAttachmentBudget + ) + preparedByIndex[item.index] = preparedAttachment + remainingBudget -= preparedAttachment.data.count + remainingCount -= 1 + } + + let preparedAttachments = attachments.indices.compactMap { preparedByIndex[$0] } + let totalBytes = preparedAttachments.reduce(0) { $0 + $1.data.count } + guard totalBytes <= FeedbackComposerSettings.targetTotalAttachmentUploadBytes else { + throw FeedbackComposerSubmissionError.attachmentPreparationFailed + } + return preparedAttachments + } + + private static func prepareAttachmentForUpload( + _ attachment: FeedbackComposerAttachment, + maximumByteCount: Int + ) throws -> PreparedFeedbackComposerAttachment { + if attachment.fileSize > 0, + attachment.fileSize <= Int64(maximumByteCount), + passthroughAttachmentMIMETypes.contains(attachment.mimeType), + let fileData = try? Data(contentsOf: attachment.url, options: .mappedIfSafe) { + return PreparedFeedbackComposerAttachment( + fileName: attachment.fileName, + mimeType: attachment.mimeType, + data: fileData + ) + } + + guard let imageSource = CGImageSourceCreateWithURL(attachment.url as CFURL, nil) else { + throw FeedbackComposerSubmissionError.attachmentReadFailed + } + + for maxPixelDimension in optimizedAttachmentDimensions { + guard let cgImage = downsampledImage( + from: imageSource, + maxPixelDimension: maxPixelDimension + ) else { continue } + + for compressionQuality in optimizedAttachmentQualities { + guard let jpegData = jpegData( + from: cgImage, + compressionQuality: compressionQuality + ) else { continue } + guard jpegData.count <= maximumByteCount else { continue } + + return PreparedFeedbackComposerAttachment( + fileName: optimizedFileName(for: attachment), + mimeType: optimizedAttachmentMIMEType, + data: jpegData + ) + } + } + + throw FeedbackComposerSubmissionError.attachmentPreparationFailed + } + + private static func downsampledImage( + from imageSource: CGImageSource, + maxPixelDimension: Int + ) -> CGImage? { + CGImageSourceCreateThumbnailAtIndex( + imageSource, + 0, + [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCache: false, + kCGImageSourceShouldCacheImmediately: false, + kCGImageSourceThumbnailMaxPixelSize: maxPixelDimension, + ] as CFDictionary + ) + } + + private static func jpegData( + from image: CGImage, + compressionQuality: CGFloat + ) -> Data? { + let bitmap = NSBitmapImageRep(cgImage: image) + return bitmap.representation( + using: .jpeg, + properties: [ + .compressionFactor: compressionQuality, + ] + ) + } + + private static func optimizedFileName( + for attachment: FeedbackComposerAttachment + ) -> String { + let baseName = (attachment.fileName as NSString).deletingPathExtension + return "\(baseName.isEmpty ? "feedback-image" : baseName).jpg" + } + + private static func appendFile( + named fieldName: String, + attachment: PreparedFeedbackComposerAttachment, + to body: inout Data, + boundary: String + ) { + let sanitizedFileName = attachment.fileName.replacingOccurrences(of: "\"", with: "") + + body.append(Data("--\(boundary)\r\n".utf8)) + body.append( + Data( + "Content-Disposition: form-data; name=\"\(fieldName)\"; filename=\"\(sanitizedFileName)\"\r\n".utf8 + ) + ) + body.append(Data("Content-Type: \(attachment.mimeType)\r\n\r\n".utf8)) + body.append(attachment.data) + body.append(Data("\r\n".utf8)) + } +} diff --git a/Packages/CmuxFeedback/Sources/CmuxFeedback/Client/FeedbackComposerSubmissionError.swift b/Packages/CmuxFeedback/Sources/CmuxFeedback/Client/FeedbackComposerSubmissionError.swift new file mode 100644 index 00000000000..97dd7206e6a --- /dev/null +++ b/Packages/CmuxFeedback/Sources/CmuxFeedback/Client/FeedbackComposerSubmissionError.swift @@ -0,0 +1,13 @@ +public import Foundation + +/// Low-level failure modes raised by ``FeedbackComposerClient`` while preparing +/// and uploading a feedback submission. ``FeedbackComposerBridge`` maps these to +/// user-facing messages; the composer sheet maps them to localized strings. +public enum FeedbackComposerSubmissionError: Error { + case invalidEndpoint + case invalidResponse + case rejected(statusCode: Int) + case attachmentReadFailed + case attachmentPreparationFailed + case transport(URLError) +} diff --git a/Packages/CmuxFeedback/Sources/CmuxFeedback/Models/FeedbackComposerAppMetadata.swift b/Packages/CmuxFeedback/Sources/CmuxFeedback/Models/FeedbackComposerAppMetadata.swift new file mode 100644 index 00000000000..49e6e6c6f55 --- /dev/null +++ b/Packages/CmuxFeedback/Sources/CmuxFeedback/Models/FeedbackComposerAppMetadata.swift @@ -0,0 +1,76 @@ +import AppKit +import Foundation + +/// Host/app environment metadata attached to every feedback submission so the +/// founders can triage by version, OS, hardware, and display configuration. +struct FeedbackComposerAppMetadata { + let appVersion: String + let appBuild: String + let appCommit: String + let bundleIdentifier: String + let osVersion: String + let localeIdentifier: String + let hardwareModel: String + let chip: String + let memoryGB: String + let architecture: String + let displayInfo: String + + static var current: FeedbackComposerAppMetadata { + let infoDictionary = Bundle.main.infoDictionary ?? [:] + let env = ProcessInfo.processInfo.environment + let commit = (infoDictionary["CMUXCommit"] as? String).flatMap { value in + value.isEmpty ? nil : value + } ?? env["CMUX_COMMIT"] + + return FeedbackComposerAppMetadata( + appVersion: infoDictionary["CFBundleShortVersionString"] as? String ?? "", + appBuild: infoDictionary["CFBundleVersion"] as? String ?? "", + appCommit: commit ?? "", + bundleIdentifier: Bundle.main.bundleIdentifier ?? "", + osVersion: ProcessInfo.processInfo.operatingSystemVersionString, + localeIdentifier: Locale.preferredLanguages.first ?? Locale.current.identifier, + hardwareModel: sysctlString("hw.model") ?? "", + chip: sysctlString("machdep.cpu.brand_string") ?? "", + memoryGB: formatMemoryGB(), + architecture: currentArchitecture(), + displayInfo: currentDisplayInfo() + ) + } + + private static func sysctlString(_ name: String) -> String? { + var size = 0 + guard sysctlbyname(name, nil, &size, nil, 0) == 0, size > 0 else { return nil } + var buffer = [CChar](repeating: 0, count: size) + guard sysctlbyname(name, &buffer, &size, nil, 0) == 0 else { return nil } + return String(cString: buffer).trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func formatMemoryGB() -> String { + let bytes = ProcessInfo.processInfo.physicalMemory + let gb = Double(bytes) / (1024 * 1024 * 1024) + return "\(Int(gb)) GB" + } + + private static func currentArchitecture() -> String { + #if arch(arm64) + return "arm64" + #elseif arch(x86_64) + return "x86_64" + #else + return "unknown" + #endif + } + + private static func currentDisplayInfo() -> String { + let screens = NSScreen.screens + let descriptions = screens.map { screen -> String in + let frame = screen.frame + let scale = screen.backingScaleFactor + return "\(Int(frame.width))x\(Int(frame.height)) @\(Int(scale))x" + } + let count = screens.count + let prefix = "\(count) display\(count == 1 ? "" : "s")" + return "\(prefix), \(descriptions.joined(separator: "; "))" + } +} diff --git a/Packages/CmuxFeedback/Sources/CmuxFeedback/Models/FeedbackComposerAttachment.swift b/Packages/CmuxFeedback/Sources/CmuxFeedback/Models/FeedbackComposerAttachment.swift new file mode 100644 index 00000000000..118c6b21d61 --- /dev/null +++ b/Packages/CmuxFeedback/Sources/CmuxFeedback/Models/FeedbackComposerAttachment.swift @@ -0,0 +1,37 @@ +public import Foundation + +/// A user-selected file to attach to a feedback submission, carrying the +/// resolved name, size, and MIME type read from the URL's resource values. +public struct FeedbackComposerAttachment: Identifiable { + public let id = UUID() + public let url: URL + public let fileName: String + public let fileSize: Int64 + public let mimeType: String + + public var standardizedPath: String { + url.standardizedFileURL.path + } + + public var displaySize: String { + ByteCountFormatter.string(fromByteCount: fileSize, countStyle: .file) + } + + /// Reads the file's resource values, rejecting non-regular files. + public init(url: URL) throws { + let resourceValues = try url.resourceValues(forKeys: [ + .contentTypeKey, + .fileSizeKey, + .isRegularFileKey, + .nameKey, + ]) + guard resourceValues.isRegularFile != false else { + throw CocoaError(.fileReadUnknown) + } + + self.url = url + self.fileName = resourceValues.name ?? url.lastPathComponent + self.fileSize = Int64(resourceValues.fileSize ?? 0) + self.mimeType = resourceValues.contentType?.preferredMIMEType ?? "application/octet-stream" + } +} diff --git a/Packages/CmuxFeedback/Sources/CmuxFeedback/Models/PreparedFeedbackComposerAttachment.swift b/Packages/CmuxFeedback/Sources/CmuxFeedback/Models/PreparedFeedbackComposerAttachment.swift new file mode 100644 index 00000000000..f958d883aa5 --- /dev/null +++ b/Packages/CmuxFeedback/Sources/CmuxFeedback/Models/PreparedFeedbackComposerAttachment.swift @@ -0,0 +1,9 @@ +import Foundation + +/// An attachment after upload preparation: re-encoded/optimized image data ready +/// to append to the multipart request body. +struct PreparedFeedbackComposerAttachment { + let fileName: String + let mimeType: String + let data: Data +} diff --git a/Packages/CmuxFeedback/Sources/CmuxFeedback/Notifications/FeedbackComposerNotification.swift b/Packages/CmuxFeedback/Sources/CmuxFeedback/Notifications/FeedbackComposerNotification.swift new file mode 100644 index 00000000000..afe95b0ba69 --- /dev/null +++ b/Packages/CmuxFeedback/Sources/CmuxFeedback/Notifications/FeedbackComposerNotification.swift @@ -0,0 +1,9 @@ +public import Foundation + +extension Notification.Name { + /// Posted to request that the feedback composer be presented (optionally + /// targeting a specific window passed as the notification `object`). The + /// raw value matches the app-side declaration so posts from this package and + /// observers registered in the app interoperate. + public static let feedbackComposerRequested = Notification.Name("cmux.feedbackComposerRequested") +} diff --git a/Packages/CmuxFeedback/Sources/CmuxFeedback/Settings/FeedbackComposerSettings.swift b/Packages/CmuxFeedback/Sources/CmuxFeedback/Settings/FeedbackComposerSettings.swift new file mode 100644 index 00000000000..0a2a16d03ff --- /dev/null +++ b/Packages/CmuxFeedback/Sources/CmuxFeedback/Settings/FeedbackComposerSettings.swift @@ -0,0 +1,28 @@ +public import Foundation + +/// Static configuration for the feedback composer: the persisted-email defaults +/// key, the upload endpoint (env-overridable), size limits, and the founders +/// fallback address. Values are byte-identical to the originals lifted from the +/// app's `ContentView`. +public enum FeedbackComposerSettings { + public static let storedEmailKey = "sidebarHelpFeedbackEmail" + public static let endpointEnvironmentKey = "CMUX_FEEDBACK_API_URL" + public static let defaultEndpoint = "https://cmux.com/api/feedback" + public static let foundersEmail = "founders@manaflow.com" + public static let maxMessageLength = 4_000 + public static let maxAttachmentCount = 10 + // Keep the multipart body below Vercel's 4.5 MB request limit. + public static let maxTotalAttachmentBytes = 4 * 1_024 * 1_024 + public static let targetTotalAttachmentUploadBytes = 3_500_000 + + /// Resolves the feedback endpoint, honoring the `CMUX_FEEDBACK_API_URL` + /// environment override and falling back to the production endpoint. + public static func endpointURL() -> URL? { + let env = ProcessInfo.processInfo.environment + if let override = env[endpointEnvironmentKey]?.trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty { + return URL(string: override) + } + return URL(string: defaultEndpoint) + } +} diff --git a/Packages/CmuxFeedback/Tests/CmuxFeedbackTests/FeedbackComposerBridgeTests.swift b/Packages/CmuxFeedback/Tests/CmuxFeedbackTests/FeedbackComposerBridgeTests.swift new file mode 100644 index 00000000000..a220beb2193 --- /dev/null +++ b/Packages/CmuxFeedback/Tests/CmuxFeedbackTests/FeedbackComposerBridgeTests.swift @@ -0,0 +1,50 @@ +import Foundation +import Testing + +@testable import CmuxFeedback + +@Suite("Feedback composer bridge") +struct FeedbackComposerBridgeTests { + @Test func emptyMessageIsRejectedBeforeAnyNetwork() async { + await #expect(throws: FeedbackComposerBridgeError.self) { + _ = try await FeedbackComposerBridge.submit( + email: "valid@example.com", + message: " ", + imagePaths: [] + ) + } + } + + @Test func invalidEmailIsRejectedBeforeAnyNetwork() async { + await #expect(throws: FeedbackComposerBridgeError.self) { + _ = try await FeedbackComposerBridge.submit( + email: "not-an-email", + message: "Real message", + imagePaths: [] + ) + } + } + + @Test func tooManyImagesIsRejectedBeforeAnyNetwork() async { + let paths = (0..<(FeedbackComposerSettings.maxAttachmentCount + 1)).map { "/tmp/feedback-\($0).png" } + await #expect(throws: FeedbackComposerBridgeError.self) { + _ = try await FeedbackComposerBridge.submit( + email: "valid@example.com", + message: "Real message", + imagePaths: paths + ) + } + } + + @Test func endpointHonorsEnvironmentOverride() { + // The override is read from the process environment; with no override set + // the resolved endpoint falls back to the production default. + if ProcessInfo.processInfo.environment[FeedbackComposerSettings.endpointEnvironmentKey] == nil { + #expect(FeedbackComposerSettings.endpointURL()?.absoluteString == FeedbackComposerSettings.defaultEndpoint) + } + } + + @Test func composerRequestedNotificationNameMatchesAppContract() { + #expect(Notification.Name.feedbackComposerRequested.rawValue == "cmux.feedbackComposerRequested") + } +} diff --git a/Sources/App/CmuxHelpCommands.swift b/Sources/App/CmuxHelpCommands.swift index 041a3f9519d..ceb5a2c6f65 100644 --- a/Sources/App/CmuxHelpCommands.swift +++ b/Sources/App/CmuxHelpCommands.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxFeedback import SwiftUI extension cmuxApp { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 86b41f68316..de4e3b7e9cd 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1,5 +1,6 @@ import AppKit import CmuxAppKitSupportUI +import CmuxFeedback import CmuxFoundation import CmuxSocketControl import CmuxCommandPalette @@ -12622,384 +12623,6 @@ enum DevBuildBannerDebugSettings { } } -private enum FeedbackComposerSettings { - static let storedEmailKey = "sidebarHelpFeedbackEmail" - static let endpointEnvironmentKey = "CMUX_FEEDBACK_API_URL" - static let defaultEndpoint = "https://cmux.com/api/feedback" - static let foundersEmail = "founders@manaflow.com" - static let maxMessageLength = 4_000 - static let maxAttachmentCount = 10 - // Keep the multipart body below Vercel's 4.5 MB request limit. - static let maxTotalAttachmentBytes = 4 * 1_024 * 1_024 - static let targetTotalAttachmentUploadBytes = 3_500_000 - - static func endpointURL() -> URL? { - let env = ProcessInfo.processInfo.environment - if let override = env[endpointEnvironmentKey]?.trimmingCharacters(in: .whitespacesAndNewlines), - !override.isEmpty { - return URL(string: override) - } - return URL(string: defaultEndpoint) - } -} - -private struct FeedbackComposerAttachment: Identifiable { - let id = UUID() - let url: URL - let fileName: String - let fileSize: Int64 - let mimeType: String - - var standardizedPath: String { - url.standardizedFileURL.path - } - - var displaySize: String { - ByteCountFormatter.string(fromByteCount: fileSize, countStyle: .file) - } - - init(url: URL) throws { - let resourceValues = try url.resourceValues(forKeys: [ - .contentTypeKey, - .fileSizeKey, - .isRegularFileKey, - .nameKey, - ]) - guard resourceValues.isRegularFile != false else { - throw CocoaError(.fileReadUnknown) - } - - self.url = url - self.fileName = resourceValues.name ?? url.lastPathComponent - self.fileSize = Int64(resourceValues.fileSize ?? 0) - self.mimeType = resourceValues.contentType?.preferredMIMEType ?? "application/octet-stream" - } -} - -private struct PreparedFeedbackComposerAttachment { - let fileName: String - let mimeType: String - let data: Data -} - -private struct FeedbackComposerAppMetadata { - let appVersion: String - let appBuild: String - let appCommit: String - let bundleIdentifier: String - let osVersion: String - let localeIdentifier: String - let hardwareModel: String - let chip: String - let memoryGB: String - let architecture: String - let displayInfo: String - - static var current: FeedbackComposerAppMetadata { - let infoDictionary = Bundle.main.infoDictionary ?? [:] - let env = ProcessInfo.processInfo.environment - let commit = (infoDictionary["CMUXCommit"] as? String).flatMap { value in - value.isEmpty ? nil : value - } ?? env["CMUX_COMMIT"] - - return FeedbackComposerAppMetadata( - appVersion: infoDictionary["CFBundleShortVersionString"] as? String ?? "", - appBuild: infoDictionary["CFBundleVersion"] as? String ?? "", - appCommit: commit ?? "", - bundleIdentifier: Bundle.main.bundleIdentifier ?? "", - osVersion: ProcessInfo.processInfo.operatingSystemVersionString, - localeIdentifier: Locale.preferredLanguages.first ?? Locale.current.identifier, - hardwareModel: sysctlString("hw.model") ?? "", - chip: sysctlString("machdep.cpu.brand_string") ?? "", - memoryGB: formatMemoryGB(), - architecture: currentArchitecture(), - displayInfo: currentDisplayInfo() - ) - } - - private static func sysctlString(_ name: String) -> String? { - var size = 0 - guard sysctlbyname(name, nil, &size, nil, 0) == 0, size > 0 else { return nil } - var buffer = [CChar](repeating: 0, count: size) - guard sysctlbyname(name, &buffer, &size, nil, 0) == 0 else { return nil } - return String(cString: buffer).trimmingCharacters(in: .whitespacesAndNewlines) - } - - private static func formatMemoryGB() -> String { - let bytes = ProcessInfo.processInfo.physicalMemory - let gb = Double(bytes) / (1024 * 1024 * 1024) - return "\(Int(gb)) GB" - } - - private static func currentArchitecture() -> String { - #if arch(arm64) - return "arm64" - #elseif arch(x86_64) - return "x86_64" - #else - return "unknown" - #endif - } - - private static func currentDisplayInfo() -> String { - let screens = NSScreen.screens - let descriptions = screens.map { screen -> String in - let frame = screen.frame - let scale = screen.backingScaleFactor - return "\(Int(frame.width))x\(Int(frame.height)) @\(Int(scale))x" - } - let count = screens.count - let prefix = "\(count) display\(count == 1 ? "" : "s")" - return "\(prefix), \(descriptions.joined(separator: "; "))" - } -} - -private enum FeedbackComposerSubmissionError: Error { - case invalidEndpoint - case invalidResponse - case rejected(statusCode: Int) - case attachmentReadFailed - case attachmentPreparationFailed - case transport(URLError) -} - -private enum FeedbackComposerClient { - private static let passthroughAttachmentMIMETypes: Set = [ - "image/gif", - "image/heic", - "image/heif", - "image/jpeg", - "image/png", - "image/tiff", - "image/webp", - ] - private static let optimizedAttachmentDimensions: [Int] = [2800, 2400, 2000, 1600, 1280, 1024, 768, 640, 512] - private static let optimizedAttachmentQualities: [CGFloat] = [0.82, 0.72, 0.62, 0.52, 0.42, 0.32] - private static let optimizedAttachmentMIMEType = "image/jpeg" - - static func submit( - email: String, - message: String, - attachments: [FeedbackComposerAttachment] - ) async throws { - guard let endpointURL = FeedbackComposerSettings.endpointURL() else { - throw FeedbackComposerSubmissionError.invalidEndpoint - } - - let metadata = FeedbackComposerAppMetadata.current - let boundary = "Boundary-\(UUID().uuidString)" - let preparedAttachments = try prepareAttachmentsForUpload(attachments) - - var request = URLRequest(url: endpointURL) - request.httpMethod = "POST" - request.timeoutInterval = 30 - request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") - request.setValue("application/json", forHTTPHeaderField: "Accept") - - var body = Data() - appendField("email", value: email, to: &body, boundary: boundary) - appendField("message", value: message, to: &body, boundary: boundary) - appendField("appVersion", value: metadata.appVersion, to: &body, boundary: boundary) - appendField("appBuild", value: metadata.appBuild, to: &body, boundary: boundary) - appendField("appCommit", value: metadata.appCommit, to: &body, boundary: boundary) - appendField("bundleIdentifier", value: metadata.bundleIdentifier, to: &body, boundary: boundary) - appendField("osVersion", value: metadata.osVersion, to: &body, boundary: boundary) - appendField("locale", value: metadata.localeIdentifier, to: &body, boundary: boundary) - appendField("hardwareModel", value: metadata.hardwareModel, to: &body, boundary: boundary) - appendField("chip", value: metadata.chip, to: &body, boundary: boundary) - appendField("memoryGB", value: metadata.memoryGB, to: &body, boundary: boundary) - appendField("architecture", value: metadata.architecture, to: &body, boundary: boundary) - appendField("displayInfo", value: metadata.displayInfo, to: &body, boundary: boundary) - - for attachment in preparedAttachments { - appendFile( - named: "attachments", - attachment: attachment, - to: &body, - boundary: boundary - ) - } - - body.append(Data("--\(boundary)--\r\n".utf8)) - request.httpBody = body - - let data: Data - let response: URLResponse - do { - (data, response) = try await URLSession.shared.data(for: request) - } catch let error as URLError { - throw FeedbackComposerSubmissionError.transport(error) - } catch { - throw FeedbackComposerSubmissionError.invalidResponse - } - - guard let httpResponse = response as? HTTPURLResponse else { - throw FeedbackComposerSubmissionError.invalidResponse - } - - guard (200..<300).contains(httpResponse.statusCode) else { - if let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let errorMessage = payload["error"] as? String, - errorMessage.isEmpty == false { - #if DEBUG - NSLog("feedback.submit.rejected status=%@ error=%@", String(httpResponse.statusCode), errorMessage) - #endif - } - throw FeedbackComposerSubmissionError.rejected(statusCode: httpResponse.statusCode) - } - } - - private static func appendField( - _ name: String, - value: String, - to body: inout Data, - boundary: String - ) { - body.append(Data("--\(boundary)\r\n".utf8)) - body.append(Data("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".utf8)) - body.append(Data(value.utf8)) - body.append(Data("\r\n".utf8)) - } - - private static func prepareAttachmentsForUpload( - _ attachments: [FeedbackComposerAttachment] - ) throws -> [PreparedFeedbackComposerAttachment] { - guard attachments.isEmpty == false else { return [] } - - struct IndexedAttachment { - let index: Int - let attachment: FeedbackComposerAttachment - } - - let sortedAttachments = attachments.enumerated() - .map { IndexedAttachment(index: $0.offset, attachment: $0.element) } - .sorted { lhs, rhs in - lhs.attachment.fileSize > rhs.attachment.fileSize - } - - var preparedByIndex: [Int: PreparedFeedbackComposerAttachment] = [:] - var remainingBudget = FeedbackComposerSettings.targetTotalAttachmentUploadBytes - var remainingCount = sortedAttachments.count - - for item in sortedAttachments { - let perAttachmentBudget = max(1, remainingBudget / max(remainingCount, 1)) - let preparedAttachment = try prepareAttachmentForUpload( - item.attachment, - maximumByteCount: perAttachmentBudget - ) - preparedByIndex[item.index] = preparedAttachment - remainingBudget -= preparedAttachment.data.count - remainingCount -= 1 - } - - let preparedAttachments = attachments.indices.compactMap { preparedByIndex[$0] } - let totalBytes = preparedAttachments.reduce(0) { $0 + $1.data.count } - guard totalBytes <= FeedbackComposerSettings.targetTotalAttachmentUploadBytes else { - throw FeedbackComposerSubmissionError.attachmentPreparationFailed - } - return preparedAttachments - } - - private static func prepareAttachmentForUpload( - _ attachment: FeedbackComposerAttachment, - maximumByteCount: Int - ) throws -> PreparedFeedbackComposerAttachment { - if attachment.fileSize > 0, - attachment.fileSize <= Int64(maximumByteCount), - passthroughAttachmentMIMETypes.contains(attachment.mimeType), - let fileData = try? Data(contentsOf: attachment.url, options: .mappedIfSafe) { - return PreparedFeedbackComposerAttachment( - fileName: attachment.fileName, - mimeType: attachment.mimeType, - data: fileData - ) - } - - guard let imageSource = CGImageSourceCreateWithURL(attachment.url as CFURL, nil) else { - throw FeedbackComposerSubmissionError.attachmentReadFailed - } - - for maxPixelDimension in optimizedAttachmentDimensions { - guard let cgImage = downsampledImage( - from: imageSource, - maxPixelDimension: maxPixelDimension - ) else { continue } - - for compressionQuality in optimizedAttachmentQualities { - guard let jpegData = jpegData( - from: cgImage, - compressionQuality: compressionQuality - ) else { continue } - guard jpegData.count <= maximumByteCount else { continue } - - return PreparedFeedbackComposerAttachment( - fileName: optimizedFileName(for: attachment), - mimeType: optimizedAttachmentMIMEType, - data: jpegData - ) - } - } - - throw FeedbackComposerSubmissionError.attachmentPreparationFailed - } - - private static func downsampledImage( - from imageSource: CGImageSource, - maxPixelDimension: Int - ) -> CGImage? { - CGImageSourceCreateThumbnailAtIndex( - imageSource, - 0, - [ - kCGImageSourceCreateThumbnailFromImageAlways: true, - kCGImageSourceCreateThumbnailWithTransform: true, - kCGImageSourceShouldCache: false, - kCGImageSourceShouldCacheImmediately: false, - kCGImageSourceThumbnailMaxPixelSize: maxPixelDimension, - ] as CFDictionary - ) - } - - private static func jpegData( - from image: CGImage, - compressionQuality: CGFloat - ) -> Data? { - let bitmap = NSBitmapImageRep(cgImage: image) - return bitmap.representation( - using: .jpeg, - properties: [ - .compressionFactor: compressionQuality, - ] - ) - } - - private static func optimizedFileName( - for attachment: FeedbackComposerAttachment - ) -> String { - let baseName = (attachment.fileName as NSString).deletingPathExtension - return "\(baseName.isEmpty ? "feedback-image" : baseName).jpg" - } - - private static func appendFile( - named fieldName: String, - attachment: PreparedFeedbackComposerAttachment, - to body: inout Data, - boundary: String - ) { - let sanitizedFileName = attachment.fileName.replacingOccurrences(of: "\"", with: "") - - body.append(Data("--\(boundary)\r\n".utf8)) - body.append( - Data( - "Content-Disposition: form-data; name=\"\(fieldName)\"; filename=\"\(sanitizedFileName)\"\r\n".utf8 - ) - ) - body.append(Data("Content-Type: \(attachment.mimeType)\r\n\r\n".utf8)) - body.append(attachment.data) - body.append(Data("\r\n".utf8)) - } -} - enum SidebarDragLifecycleNotification { static let stateDidChange = Notification.Name("cmux.sidebarDragStateDidChange") static let requestClear = Notification.Name("cmux.sidebarDragRequestClear") @@ -14114,122 +13737,6 @@ private struct SidebarFeedbackComposerSheet: View { } } -enum FeedbackComposerBridgeError: LocalizedError { - case invalidEmail - case emptyMessage - case messageTooLong - case tooManyImages - case invalidImagePath(String) - case submissionFailed(String) - - var errorDescription: String? { - switch self { - case .invalidEmail: - return "Enter a valid email address." - case .emptyMessage: - return "Enter a message before sending." - case .messageTooLong: - return "Your message is too long." - case .tooManyImages: - return "You can attach up to 10 images." - case .invalidImagePath(let path): - return "Could not attach image: \(path)" - case .submissionFailed(let message): - return message - } - } -} - -enum FeedbackComposerBridge { - static func openComposer(in window: NSWindow? = NSApp.keyWindow ?? NSApp.mainWindow) { - NotificationCenter.default.post(name: .feedbackComposerRequested, object: window) - } - - static func submit( - email: String, - message: String, - imagePaths: [String] - ) async throws -> Int { - let trimmedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines) - let normalizedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines) - - guard isValidEmail(trimmedEmail) else { - throw FeedbackComposerBridgeError.invalidEmail - } - guard normalizedMessage.isEmpty == false else { - throw FeedbackComposerBridgeError.emptyMessage - } - guard message.count <= FeedbackComposerSettings.maxMessageLength else { - throw FeedbackComposerBridgeError.messageTooLong - } - guard imagePaths.count <= FeedbackComposerSettings.maxAttachmentCount else { - throw FeedbackComposerBridgeError.tooManyImages - } - - let attachments = try imagePaths.map { rawPath in - let resolvedURL = URL(fileURLWithPath: rawPath).standardizedFileURL - do { - return try FeedbackComposerAttachment(url: resolvedURL) - } catch { - throw FeedbackComposerBridgeError.invalidImagePath(resolvedURL.path) - } - } - - do { - try await FeedbackComposerClient.submit( - email: trimmedEmail, - message: normalizedMessage, - attachments: attachments - ) - } catch { - throw FeedbackComposerBridgeError.submissionFailed(userFacingMessage(for: error)) - } - - UserDefaults.standard.set(trimmedEmail, forKey: FeedbackComposerSettings.storedEmailKey) - return attachments.count - } - - private static func isValidEmail(_ rawValue: String) -> Bool { - let email = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) - guard email.isEmpty == false else { return false } - let pattern = #"^[A-Z0-9a-z._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$"# - return NSPredicate(format: "SELF MATCHES %@", pattern).evaluate(with: email) - } - - private static func userFacingMessage(for error: Error) -> String { - guard let submissionError = error as? FeedbackComposerSubmissionError else { - return "Couldn't send feedback. Please try again." - } - - switch submissionError { - case .invalidEndpoint: - return "Feedback is unavailable right now. Email founders@manaflow.com instead." - case .invalidResponse: - return "Couldn't send feedback. Please try again." - case .attachmentReadFailed: - return "One of the selected files could not be attached." - case .attachmentPreparationFailed: - return "These images are too large to send together. Remove a few and try again." - case .transport(let transportError): - if transportError.code == .notConnectedToInternet || transportError.code == .networkConnectionLost { - return "Couldn't send feedback. Check your connection and try again." - } - return "Couldn't send feedback. Please try again." - case .rejected(let statusCode): - switch statusCode { - case 400, 413, 415: - return "Check your message and attachments, then try again." - case 429: - return "Too many feedback attempts. Please try again later." - case 500...599: - return "Feedback is unavailable right now. Email founders@manaflow.com instead." - default: - return "Couldn't send feedback. Please try again." - } - } - } -} - private struct SidebarHelpMenuButton: View { private let docsURL = URL(string: "https://cmux.com/docs") private let changelogURL = URL(string: "https://cmux.com/docs/changelog") diff --git a/Sources/TerminalController+ControlSystemContext.swift b/Sources/TerminalController+ControlSystemContext.swift index 3cd05a781fb..db083f32c7a 100644 --- a/Sources/TerminalController+ControlSystemContext.swift +++ b/Sources/TerminalController+ControlSystemContext.swift @@ -1,6 +1,7 @@ import AppKit import Bonsplit import CmuxControlSocket +import CmuxFeedback import Foundation /// The system-domain witnesses: the byte-faithful bodies of the former diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 071305f059c..8053cbaed2a 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1,5 +1,6 @@ import AppKit import CmuxAuthRuntime +import CmuxFeedback import CmuxControlSocket import CmuxSettings import CmuxSocketControl diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index c8689c0a868..fe0e48d5b61 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -193,6 +193,7 @@ C0DE46000000000000000001 /* CMUXExtensionHostSupport in Frameworks */ = {isa = PBXBuildFile; productRef = C0DE46000000000000000002 /* CMUXExtensionHostSupport */; }; C0DE45000000000000000001 /* CmuxExtensionKit in Frameworks */ = {isa = PBXBuildFile; productRef = C0DE45000000000000000002 /* CmuxExtensionKit */; }; C0DE49000000000000000001 /* CmuxExtensionSidebarExamples in Frameworks */ = {isa = PBXBuildFile; productRef = C0DE49000000000000000002 /* CmuxExtensionSidebarExamples */; }; + C9A2C00000000000000000C3 /* CmuxFeedback in Frameworks */ = {isa = PBXBuildFile; productRef = C9A2C00000000000000000C2 /* CmuxFeedback */; }; C8000313C8000313C8000313 /* CmuxFileWatch in Frameworks */ = {isa = PBXBuildFile; productRef = C8000312C8000312C8000312 /* CmuxFileWatch */; }; C8000314C8000314C8000314 /* CmuxFileWatch in Frameworks */ = {isa = PBXBuildFile; productRef = C8000312C8000312C8000312 /* CmuxFileWatch */; }; 5E2701040000000000000004 /* CmuxFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 5E2701040000000000000002 /* CmuxFoundation */; }; @@ -1599,6 +1600,7 @@ C0DE46000000000000000001 /* CMUXExtensionHostSupport in Frameworks */, C0DE45000000000000000001 /* CmuxExtensionKit in Frameworks */, C0DE49000000000000000001 /* CmuxExtensionSidebarExamples in Frameworks */, + C9A2C00000000000000000C3 /* CmuxFeedback in Frameworks */, C8000313C8000313C8000313 /* CmuxFileWatch in Frameworks */, CF00F00000000000000000A3 /* CmuxFoundation in Frameworks */, C617000000000000000000A3 /* CmuxGit in Frameworks */, @@ -2708,6 +2710,7 @@ C750100000000000000000A1 /* XCLocalSwiftPackageReference "CmuxControlSocket" */, C9A1C00000000000000000A1 /* XCLocalSwiftPackageReference "CmuxCommandPalette" */, C9A2B00000000000000000B1 /* XCLocalSwiftPackageReference "CmuxAppKitSupportUI" */, + C9A2C00000000000000000C1 /* XCLocalSwiftPackageReference "CmuxFeedback" */, CD0CFE5100000000CD0CFE51 /* XCLocalSwiftPackageReference "CmuxSettings" */, CD0CFE5400000000CD0CFE54 /* XCLocalSwiftPackageReference "CmuxSettingsUI" */, CDFEED0100000000CDFEED01 /* XCLocalSwiftPackageReference "CmuxUpdater" */, @@ -4119,6 +4122,10 @@ isa = XCLocalSwiftPackageReference; relativePath = Packages/CmuxAppKitSupportUI; }; + C9A2C00000000000000000C1 /* XCLocalSwiftPackageReference "CmuxFeedback" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/CmuxFeedback; + }; CD0CFE5100000000CD0CFE51 /* XCLocalSwiftPackageReference "CmuxSettings" */ = { isa = XCLocalSwiftPackageReference; relativePath = Packages/CmuxSettings; @@ -4296,6 +4303,11 @@ package = C9A2B00000000000000000B1 /* XCLocalSwiftPackageReference "CmuxAppKitSupportUI" */; productName = CmuxAppKitSupportUI; }; + C9A2C00000000000000000C2 /* CmuxFeedback */ = { + isa = XCSwiftPackageProductDependency; + package = C9A2C00000000000000000C1 /* XCLocalSwiftPackageReference "CmuxFeedback" */; + productName = CmuxFeedback; + }; C8000302C8000302C8000302 /* CmuxProcess */ = { isa = XCSwiftPackageProductDependency; package = C8000301C8000301C8000301 /* XCLocalSwiftPackageReference "CmuxProcess" */; From 06ff56573e264b70a39190c57a3cc7a2eeaa95d8 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 12 Jun 2026 22:52:53 -0700 Subject: [PATCH 11/31] ContentView drain: lift feedback composer message-editor views into CmuxFeedbackUI (stack E, Wave 3) Lifts the four self-contained feedback message-editor view primitives out of ContentView into a new CmuxFeedbackUI package: FeedbackComposerMessageEditor (NSViewRepresentable), FeedbackComposerMessageEditorView (self-sizing NSView), FeedbackComposerMessageScrollView, and FeedbackComposerPassthroughLabel (package-internal). View bodies are byte-identical; the only deltas are the expected cross-module ones: an explicit public init on the representable (the implicit memberwise init is no longer visible across the boundary) and per-file AppKit/SwiftUI imports. These views have no ContentView coupling (they take @Binding text + plain strings). SidebarFeedbackComposerSheet stays app-side and now constructs FeedbackComposerMessageEditor through the package; its call site is unchanged because the explicit init's first label (text:) matches the former memberwise init. The FeedbackComposerMessageEditorView layout-behavior test moves faithfully from cmuxTests into CmuxFeedbackUITests (XCTest, assertions byte-identical) and is removed from the app host test file, so the package owns its own coverage. Package: swift build + 2 behavior tests green. App: full bounded xcodebuild BUILD SUCCEEDED. pbxproj wired with 6 mirrored entries, normalized + checked. Co-Authored-By: Claude Fable 5 --- .github/swift-file-length-budget.tsv | 4 +- Packages/CmuxFeedbackUI/Package.swift | 35 +++ .../FeedbackComposerMessageEditor.swift | 63 +++++ .../FeedbackComposerMessageEditorView.swift | 181 +++++++++++++ .../FeedbackComposerMessageScrollView.swift | 14 + .../FeedbackComposerPassthroughLabel.swift | 7 + ...edbackComposerMessageEditorViewTests.swift | 48 ++++ Sources/ContentView.swift | 239 +----------------- cmux.xcodeproj/project.pbxproj | 12 + cmuxTests/TerminalAndGhosttyTests.swift | 45 ---- 10 files changed, 363 insertions(+), 285 deletions(-) create mode 100644 Packages/CmuxFeedbackUI/Package.swift create mode 100644 Packages/CmuxFeedbackUI/Sources/CmuxFeedbackUI/Composer/FeedbackComposerMessageEditor.swift create mode 100644 Packages/CmuxFeedbackUI/Sources/CmuxFeedbackUI/Composer/FeedbackComposerMessageEditorView.swift create mode 100644 Packages/CmuxFeedbackUI/Sources/CmuxFeedbackUI/Composer/FeedbackComposerMessageScrollView.swift create mode 100644 Packages/CmuxFeedbackUI/Sources/CmuxFeedbackUI/Composer/FeedbackComposerPassthroughLabel.swift create mode 100644 Packages/CmuxFeedbackUI/Tests/CmuxFeedbackUITests/FeedbackComposerMessageEditorViewTests.swift diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 0ffa00d3c44..b0c4c15d6cd 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -3,8 +3,8 @@ # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 33285 CLI/cmux.swift 19991 Sources/Workspace.swift -18169 Sources/ContentView.swift 18118 Sources/AppDelegate.swift +17932 Sources/ContentView.swift 16675 Sources/GhosttyTerminalView.swift 14623 Sources/TerminalController.swift 13606 Sources/Panels/BrowserPanel.swift @@ -16,7 +16,7 @@ 6948 cmuxTests/WorkspaceRemoteConnectionTests.swift 6543 cmuxTests/GhosttyConfigTests.swift 6329 cmuxTests/SessionPersistenceTests.swift -6300 cmuxTests/TerminalAndGhosttyTests.swift +6255 cmuxTests/TerminalAndGhosttyTests.swift 6153 CLI/cmux_open.swift 6071 Sources/TextBoxInput.swift 5482 cmuxTests/BrowserConfigTests.swift diff --git a/Packages/CmuxFeedbackUI/Package.swift b/Packages/CmuxFeedbackUI/Package.swift new file mode 100644 index 00000000000..76faaeb42ae --- /dev/null +++ b/Packages/CmuxFeedbackUI/Package.swift @@ -0,0 +1,35 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "CmuxFeedbackUI", + platforms: [ + .macOS(.v14), + ], + products: [ + .library( + name: "CmuxFeedbackUI", + targets: ["CmuxFeedbackUI"] + ), + ], + targets: [ + .target( + name: "CmuxFeedbackUI", + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + .testTarget( + name: "CmuxFeedbackUITests", + dependencies: ["CmuxFeedbackUI"], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + ] +) diff --git a/Packages/CmuxFeedbackUI/Sources/CmuxFeedbackUI/Composer/FeedbackComposerMessageEditor.swift b/Packages/CmuxFeedbackUI/Sources/CmuxFeedbackUI/Composer/FeedbackComposerMessageEditor.swift new file mode 100644 index 00000000000..89cdba1e1af --- /dev/null +++ b/Packages/CmuxFeedbackUI/Sources/CmuxFeedbackUI/Composer/FeedbackComposerMessageEditor.swift @@ -0,0 +1,63 @@ +public import SwiftUI + +/// SwiftUI wrapper around ``FeedbackComposerMessageEditorView`` that binds the +/// editor's text and forwards accessibility metadata. +public struct FeedbackComposerMessageEditor: NSViewRepresentable { + @Binding var text: String + let placeholder: String + let accessibilityLabel: String + let accessibilityIdentifier: String + + /// Explicit public initializer: the implicit memberwise init is not visible + /// across the module boundary now that this type lives in a package. + public init( + text: Binding, + placeholder: String, + accessibilityLabel: String, + accessibilityIdentifier: String + ) { + self._text = text + self.placeholder = placeholder + self.accessibilityLabel = accessibilityLabel + self.accessibilityIdentifier = accessibilityIdentifier + } + + public func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + public func makeNSView(context: Context) -> FeedbackComposerMessageEditorView { + let view = FeedbackComposerMessageEditorView() + view.placeholder = placeholder + view.textView.string = text + view.textView.delegate = context.coordinator + view.textView.setAccessibilityLabel(accessibilityLabel) + view.textView.setAccessibilityIdentifier(accessibilityIdentifier) + view.setAccessibilityIdentifier(accessibilityIdentifier) + return view + } + + public func updateNSView(_ nsView: FeedbackComposerMessageEditorView, context: Context) { + if nsView.textView.string != text { + nsView.textView.string = text + nsView.refreshTextLayout() + } + nsView.placeholder = placeholder + nsView.textView.setAccessibilityLabel(accessibilityLabel) + nsView.textView.setAccessibilityIdentifier(accessibilityIdentifier) + nsView.setAccessibilityIdentifier(accessibilityIdentifier) + } + + public final class Coordinator: NSObject, NSTextViewDelegate { + var parent: FeedbackComposerMessageEditor + + init(parent: FeedbackComposerMessageEditor) { + self.parent = parent + } + + public func textDidChange(_ notification: Notification) { + guard let textView = notification.object as? NSTextView else { return } + parent.text = textView.string + } + } +} diff --git a/Packages/CmuxFeedbackUI/Sources/CmuxFeedbackUI/Composer/FeedbackComposerMessageEditorView.swift b/Packages/CmuxFeedbackUI/Sources/CmuxFeedbackUI/Composer/FeedbackComposerMessageEditorView.swift new file mode 100644 index 00000000000..cd33295b5ab --- /dev/null +++ b/Packages/CmuxFeedbackUI/Sources/CmuxFeedbackUI/Composer/FeedbackComposerMessageEditorView.swift @@ -0,0 +1,181 @@ +public import AppKit + +/// A self-sizing, scrollable multiline message editor used by the feedback +/// composer. Grows its document height with content (with an overlay scroller +/// once it exceeds the visible area) and shows a placeholder while empty. +public final class FeedbackComposerMessageEditorView: NSView { + private static let font = NSFont.systemFont(ofSize: 12) + private static let textInset = NSSize(width: 10, height: 10) + private static let minimumDocumentHeight: CGFloat = { + let lineHeight = ceil(font.ascender - font.descender + font.leading) + return lineHeight + textInset.height * 2 + }() + + public let scrollView = FeedbackComposerMessageScrollView() + public let textView = NSTextView() + private let placeholderField = FeedbackComposerPassthroughLabel(labelWithString: "") + + public var placeholder: String = "" { + didSet { + placeholderField.stringValue = placeholder + updatePlaceholderVisibility() + } + } + + public override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + wantsLayer = true + layer?.cornerRadius = 8 + layer?.borderWidth = 1 + layer?.borderColor = NSColor.separatorColor.cgColor + layer?.backgroundColor = NSColor.textBackgroundColor.cgColor + + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.borderType = .noBorder + scrollView.drawsBackground = false + scrollView.automaticallyAdjustsContentInsets = false + scrollView.hasHorizontalScroller = false + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + scrollView.scrollerStyle = .overlay + scrollView.focusTextView = textView + + textView.translatesAutoresizingMaskIntoConstraints = false + textView.isEditable = true + textView.isSelectable = true + textView.isRichText = false + textView.importsGraphics = false + textView.isHorizontallyResizable = false + textView.isVerticallyResizable = true + textView.autoresizingMask = [.width] + textView.backgroundColor = .clear + textView.drawsBackground = false + textView.font = Self.font + textView.textColor = .labelColor + textView.insertionPointColor = .labelColor + textView.textContainerInset = Self.textInset + textView.textContainer?.lineFragmentPadding = 0 + textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude) + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.heightTracksTextView = false + textView.minSize = NSSize(width: 0, height: Self.minimumDocumentHeight) + textView.maxSize = NSSize( + width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude + ) + + scrollView.documentView = textView + addSubview(scrollView) + + placeholderField.translatesAutoresizingMaskIntoConstraints = false + placeholderField.font = Self.font + placeholderField.textColor = .secondaryLabelColor + placeholderField.lineBreakMode = .byWordWrapping + placeholderField.maximumNumberOfLines = 0 + scrollView.contentView.addSubview(placeholderField) + + NotificationCenter.default.addObserver( + self, + selector: #selector(textDidChange(_:)), + name: NSText.didChangeNotification, + object: textView + ) + + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: topAnchor), + scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), + scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), + + placeholderField.topAnchor.constraint( + equalTo: scrollView.contentView.topAnchor, + constant: Self.textInset.height + ), + placeholderField.leadingAnchor.constraint( + equalTo: scrollView.contentView.leadingAnchor, + constant: Self.textInset.width + ), + placeholderField.trailingAnchor.constraint( + lessThanOrEqualTo: scrollView.contentView.trailingAnchor, + constant: -Self.textInset.width + ), + ]) + + updatePlaceholderVisibility() + } + + public override func layout() { + super.layout() + syncTextViewFrameToContentSize() + } + + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + @objc + private func textDidChange(_ notification: Notification) { + refreshTextLayout(scrollSelection: true) + } + + private func updatePlaceholderVisibility() { + placeholderField.isHidden = textView.string.isEmpty == false + } + + public func refreshTextLayout(scrollSelection: Bool = false) { + updatePlaceholderVisibility() + needsLayout = true + layoutSubtreeIfNeeded() + syncTextViewFrameToContentSize() + if scrollSelection { + textView.scrollRangeToVisible(textView.selectedRange()) + } + } + + private func naturalDocumentHeight(for width: CGFloat) -> CGFloat { + guard let layoutManager = textView.layoutManager, + let textContainer = textView.textContainer else { + return Self.minimumDocumentHeight + } + + let textWidth = max(width - Self.textInset.width * 2, 1) + textContainer.containerSize = NSSize( + width: textWidth, + height: CGFloat.greatestFiniteMagnitude + ) + layoutManager.ensureLayout(for: textContainer) + let usedRect = layoutManager.usedRect(for: textContainer) + let extraLineHeight: CGFloat + if layoutManager.extraLineFragmentTextContainer === textContainer { + extraLineHeight = ceil(layoutManager.extraLineFragmentRect.height) + } else { + extraLineHeight = 0 + } + let lineHeight = ceil(Self.font.ascender - Self.font.descender + Self.font.leading) + let contentHeight = max(lineHeight, ceil(usedRect.height) + extraLineHeight) + return max( + Self.minimumDocumentHeight, + ceil(contentHeight + Self.textInset.height * 2) + ) + } + + private func syncTextViewFrameToContentSize() { + let contentSize = scrollView.contentSize + guard contentSize.width > 0, contentSize.height > 0 else { return } + + textView.minSize = NSSize(width: 0, height: contentSize.height) + let naturalHeight = naturalDocumentHeight(for: contentSize.width) + let targetSize = NSSize( + width: contentSize.width, + height: max(naturalHeight, contentSize.height) + ) + if textView.frame.size != targetSize { + textView.frame = NSRect(origin: .zero, size: targetSize) + } + } +} diff --git a/Packages/CmuxFeedbackUI/Sources/CmuxFeedbackUI/Composer/FeedbackComposerMessageScrollView.swift b/Packages/CmuxFeedbackUI/Sources/CmuxFeedbackUI/Composer/FeedbackComposerMessageScrollView.swift new file mode 100644 index 00000000000..5df83313b2f --- /dev/null +++ b/Packages/CmuxFeedbackUI/Sources/CmuxFeedbackUI/Composer/FeedbackComposerMessageScrollView.swift @@ -0,0 +1,14 @@ +public import AppKit + +/// Scroll view backing the feedback message editor. Redirects mouse-down hits to +/// the document text view so clicking anywhere in the field focuses the editor. +public final class FeedbackComposerMessageScrollView: NSScrollView { + weak var focusTextView: NSTextView? + + public override func mouseDown(with event: NSEvent) { + if let focusTextView { + _ = window?.makeFirstResponder(focusTextView) + } + super.mouseDown(with: event) + } +} diff --git a/Packages/CmuxFeedbackUI/Sources/CmuxFeedbackUI/Composer/FeedbackComposerPassthroughLabel.swift b/Packages/CmuxFeedbackUI/Sources/CmuxFeedbackUI/Composer/FeedbackComposerPassthroughLabel.swift new file mode 100644 index 00000000000..c6ae0139f41 --- /dev/null +++ b/Packages/CmuxFeedbackUI/Sources/CmuxFeedbackUI/Composer/FeedbackComposerPassthroughLabel.swift @@ -0,0 +1,7 @@ +import AppKit + +/// Placeholder label that ignores hit-testing so clicks pass through to the +/// editor's text view underneath. +final class FeedbackComposerPassthroughLabel: NSTextField { + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} diff --git a/Packages/CmuxFeedbackUI/Tests/CmuxFeedbackUITests/FeedbackComposerMessageEditorViewTests.swift b/Packages/CmuxFeedbackUI/Tests/CmuxFeedbackUITests/FeedbackComposerMessageEditorViewTests.swift new file mode 100644 index 00000000000..1f211667ecd --- /dev/null +++ b/Packages/CmuxFeedbackUI/Tests/CmuxFeedbackUITests/FeedbackComposerMessageEditorViewTests.swift @@ -0,0 +1,48 @@ +import AppKit +import XCTest + +@testable import CmuxFeedbackUI + +@MainActor +final class FeedbackComposerMessageEditorViewTests: XCTestCase { + func testLongMessageCreatesScrollableDocumentContent() { + let editor = FeedbackComposerMessageEditorView( + frame: NSRect(x: 0, y: 0, width: 360, height: 120) + ) + editor.placeholder = "Message" + editor.layoutSubtreeIfNeeded() + + editor.textView.string = (0..<80) + .map { "feedback line \($0)" } + .joined(separator: "\n") + editor.refreshTextLayout() + editor.layoutSubtreeIfNeeded() + + XCTAssertGreaterThan( + editor.textView.frame.height, + editor.scrollView.contentSize.height + 40 + ) + } + + func testTrailingBlankLineContributesToScrollableDocumentHeight() { + let editor = FeedbackComposerMessageEditorView( + frame: NSRect(x: 0, y: 0, width: 360, height: 120) + ) + editor.layoutSubtreeIfNeeded() + + let messageWithoutTrailingBlankLine = (0..<20) + .map { "feedback line \($0)" } + .joined(separator: "\n") + editor.textView.string = messageWithoutTrailingBlankLine + editor.refreshTextLayout() + let heightWithoutTrailingBlankLine = editor.textView.frame.height + + editor.textView.string = messageWithoutTrailingBlankLine + "\n" + editor.refreshTextLayout() + + XCTAssertGreaterThan( + editor.textView.frame.height, + heightWithoutTrailingBlankLine + 5 + ) + } +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index de4e3b7e9cd..410b96584b6 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1,6 +1,7 @@ import AppKit import CmuxAppKitSupportUI import CmuxFeedback +import CmuxFeedbackUI import CmuxFoundation import CmuxSocketControl import CmuxCommandPalette @@ -13104,244 +13105,6 @@ private struct SidebarFooterButtons: View { } } -private struct FeedbackComposerMessageEditor: NSViewRepresentable { - @Binding var text: String - let placeholder: String - let accessibilityLabel: String - let accessibilityIdentifier: String - - func makeCoordinator() -> Coordinator { - Coordinator(parent: self) - } - - func makeNSView(context: Context) -> FeedbackComposerMessageEditorView { - let view = FeedbackComposerMessageEditorView() - view.placeholder = placeholder - view.textView.string = text - view.textView.delegate = context.coordinator - view.textView.setAccessibilityLabel(accessibilityLabel) - view.textView.setAccessibilityIdentifier(accessibilityIdentifier) - view.setAccessibilityIdentifier(accessibilityIdentifier) - return view - } - - func updateNSView(_ nsView: FeedbackComposerMessageEditorView, context: Context) { - if nsView.textView.string != text { - nsView.textView.string = text - nsView.refreshTextLayout() - } - nsView.placeholder = placeholder - nsView.textView.setAccessibilityLabel(accessibilityLabel) - nsView.textView.setAccessibilityIdentifier(accessibilityIdentifier) - nsView.setAccessibilityIdentifier(accessibilityIdentifier) - } - - final class Coordinator: NSObject, NSTextViewDelegate { - var parent: FeedbackComposerMessageEditor - - init(parent: FeedbackComposerMessageEditor) { - self.parent = parent - } - - func textDidChange(_ notification: Notification) { - guard let textView = notification.object as? NSTextView else { return } - parent.text = textView.string - } - } -} - -private final class FeedbackComposerPassthroughLabel: NSTextField { - override func hitTest(_ point: NSPoint) -> NSView? { nil } -} - -final class FeedbackComposerMessageScrollView: NSScrollView { - weak var focusTextView: NSTextView? - - override func mouseDown(with event: NSEvent) { - if let focusTextView { - _ = window?.makeFirstResponder(focusTextView) - } - super.mouseDown(with: event) - } -} - -final class FeedbackComposerMessageEditorView: NSView { - private static let font = NSFont.systemFont(ofSize: 12) - private static let textInset = NSSize(width: 10, height: 10) - private static let minimumDocumentHeight: CGFloat = { - let lineHeight = ceil(font.ascender - font.descender + font.leading) - return lineHeight + textInset.height * 2 - }() - - let scrollView = FeedbackComposerMessageScrollView() - let textView = NSTextView() - private let placeholderField = FeedbackComposerPassthroughLabel(labelWithString: "") - - var placeholder: String = "" { - didSet { - placeholderField.stringValue = placeholder - updatePlaceholderVisibility() - } - } - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - - wantsLayer = true - layer?.cornerRadius = 8 - layer?.borderWidth = 1 - layer?.borderColor = NSColor.separatorColor.cgColor - layer?.backgroundColor = NSColor.textBackgroundColor.cgColor - - scrollView.translatesAutoresizingMaskIntoConstraints = false - scrollView.borderType = .noBorder - scrollView.drawsBackground = false - scrollView.automaticallyAdjustsContentInsets = false - scrollView.hasHorizontalScroller = false - scrollView.hasVerticalScroller = true - scrollView.autohidesScrollers = true - scrollView.scrollerStyle = .overlay - scrollView.focusTextView = textView - - textView.translatesAutoresizingMaskIntoConstraints = false - textView.isEditable = true - textView.isSelectable = true - textView.isRichText = false - textView.importsGraphics = false - textView.isHorizontallyResizable = false - textView.isVerticallyResizable = true - textView.autoresizingMask = [.width] - textView.backgroundColor = .clear - textView.drawsBackground = false - textView.font = Self.font - textView.textColor = .labelColor - textView.insertionPointColor = .labelColor - textView.textContainerInset = Self.textInset - textView.textContainer?.lineFragmentPadding = 0 - textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude) - textView.textContainer?.widthTracksTextView = true - textView.textContainer?.heightTracksTextView = false - textView.minSize = NSSize(width: 0, height: Self.minimumDocumentHeight) - textView.maxSize = NSSize( - width: CGFloat.greatestFiniteMagnitude, - height: CGFloat.greatestFiniteMagnitude - ) - - scrollView.documentView = textView - addSubview(scrollView) - - placeholderField.translatesAutoresizingMaskIntoConstraints = false - placeholderField.font = Self.font - placeholderField.textColor = .secondaryLabelColor - placeholderField.lineBreakMode = .byWordWrapping - placeholderField.maximumNumberOfLines = 0 - scrollView.contentView.addSubview(placeholderField) - - NotificationCenter.default.addObserver( - self, - selector: #selector(textDidChange(_:)), - name: NSText.didChangeNotification, - object: textView - ) - - NSLayoutConstraint.activate([ - scrollView.topAnchor.constraint(equalTo: topAnchor), - scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), - scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), - - placeholderField.topAnchor.constraint( - equalTo: scrollView.contentView.topAnchor, - constant: Self.textInset.height - ), - placeholderField.leadingAnchor.constraint( - equalTo: scrollView.contentView.leadingAnchor, - constant: Self.textInset.width - ), - placeholderField.trailingAnchor.constraint( - lessThanOrEqualTo: scrollView.contentView.trailingAnchor, - constant: -Self.textInset.width - ), - ]) - - updatePlaceholderVisibility() - } - - override func layout() { - super.layout() - syncTextViewFrameToContentSize() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - NotificationCenter.default.removeObserver(self) - } - - @objc - private func textDidChange(_ notification: Notification) { - refreshTextLayout(scrollSelection: true) - } - - private func updatePlaceholderVisibility() { - placeholderField.isHidden = textView.string.isEmpty == false - } - - func refreshTextLayout(scrollSelection: Bool = false) { - updatePlaceholderVisibility() - needsLayout = true - layoutSubtreeIfNeeded() - syncTextViewFrameToContentSize() - if scrollSelection { - textView.scrollRangeToVisible(textView.selectedRange()) - } - } - - private func naturalDocumentHeight(for width: CGFloat) -> CGFloat { - guard let layoutManager = textView.layoutManager, - let textContainer = textView.textContainer else { - return Self.minimumDocumentHeight - } - - let textWidth = max(width - Self.textInset.width * 2, 1) - textContainer.containerSize = NSSize( - width: textWidth, - height: CGFloat.greatestFiniteMagnitude - ) - layoutManager.ensureLayout(for: textContainer) - let usedRect = layoutManager.usedRect(for: textContainer) - let extraLineHeight: CGFloat - if layoutManager.extraLineFragmentTextContainer === textContainer { - extraLineHeight = ceil(layoutManager.extraLineFragmentRect.height) - } else { - extraLineHeight = 0 - } - let lineHeight = ceil(Self.font.ascender - Self.font.descender + Self.font.leading) - let contentHeight = max(lineHeight, ceil(usedRect.height) + extraLineHeight) - return max( - Self.minimumDocumentHeight, - ceil(contentHeight + Self.textInset.height * 2) - ) - } - - private func syncTextViewFrameToContentSize() { - let contentSize = scrollView.contentSize - guard contentSize.width > 0, contentSize.height > 0 else { return } - - textView.minSize = NSSize(width: 0, height: contentSize.height) - let naturalHeight = naturalDocumentHeight(for: contentSize.width) - let targetSize = NSSize( - width: contentSize.width, - height: max(naturalHeight, contentSize.height) - ) - if textView.frame.size != targetSize { - textView.frame = NSRect(origin: .zero, size: targetSize) - } - } -} - private enum SidebarHelpMenuAction { case importBrowserData case keyboardShortcuts diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index fe0e48d5b61..39ea4fd7ca7 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -194,6 +194,7 @@ C0DE45000000000000000001 /* CmuxExtensionKit in Frameworks */ = {isa = PBXBuildFile; productRef = C0DE45000000000000000002 /* CmuxExtensionKit */; }; C0DE49000000000000000001 /* CmuxExtensionSidebarExamples in Frameworks */ = {isa = PBXBuildFile; productRef = C0DE49000000000000000002 /* CmuxExtensionSidebarExamples */; }; C9A2C00000000000000000C3 /* CmuxFeedback in Frameworks */ = {isa = PBXBuildFile; productRef = C9A2C00000000000000000C2 /* CmuxFeedback */; }; + C9A2D00000000000000000D3 /* CmuxFeedbackUI in Frameworks */ = {isa = PBXBuildFile; productRef = C9A2D00000000000000000D2 /* CmuxFeedbackUI */; }; C8000313C8000313C8000313 /* CmuxFileWatch in Frameworks */ = {isa = PBXBuildFile; productRef = C8000312C8000312C8000312 /* CmuxFileWatch */; }; C8000314C8000314C8000314 /* CmuxFileWatch in Frameworks */ = {isa = PBXBuildFile; productRef = C8000312C8000312C8000312 /* CmuxFileWatch */; }; 5E2701040000000000000004 /* CmuxFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 5E2701040000000000000002 /* CmuxFoundation */; }; @@ -1601,6 +1602,7 @@ C0DE45000000000000000001 /* CmuxExtensionKit in Frameworks */, C0DE49000000000000000001 /* CmuxExtensionSidebarExamples in Frameworks */, C9A2C00000000000000000C3 /* CmuxFeedback in Frameworks */, + C9A2D00000000000000000D3 /* CmuxFeedbackUI in Frameworks */, C8000313C8000313C8000313 /* CmuxFileWatch in Frameworks */, CF00F00000000000000000A3 /* CmuxFoundation in Frameworks */, C617000000000000000000A3 /* CmuxGit in Frameworks */, @@ -2711,6 +2713,7 @@ C9A1C00000000000000000A1 /* XCLocalSwiftPackageReference "CmuxCommandPalette" */, C9A2B00000000000000000B1 /* XCLocalSwiftPackageReference "CmuxAppKitSupportUI" */, C9A2C00000000000000000C1 /* XCLocalSwiftPackageReference "CmuxFeedback" */, + C9A2D00000000000000000D1 /* XCLocalSwiftPackageReference "CmuxFeedbackUI" */, CD0CFE5100000000CD0CFE51 /* XCLocalSwiftPackageReference "CmuxSettings" */, CD0CFE5400000000CD0CFE54 /* XCLocalSwiftPackageReference "CmuxSettingsUI" */, CDFEED0100000000CDFEED01 /* XCLocalSwiftPackageReference "CmuxUpdater" */, @@ -4126,6 +4129,10 @@ isa = XCLocalSwiftPackageReference; relativePath = Packages/CmuxFeedback; }; + C9A2D00000000000000000D1 /* XCLocalSwiftPackageReference "CmuxFeedbackUI" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/CmuxFeedbackUI; + }; CD0CFE5100000000CD0CFE51 /* XCLocalSwiftPackageReference "CmuxSettings" */ = { isa = XCLocalSwiftPackageReference; relativePath = Packages/CmuxSettings; @@ -4308,6 +4315,11 @@ package = C9A2C00000000000000000C1 /* XCLocalSwiftPackageReference "CmuxFeedback" */; productName = CmuxFeedback; }; + C9A2D00000000000000000D2 /* CmuxFeedbackUI */ = { + isa = XCSwiftPackageProductDependency; + package = C9A2D00000000000000000D1 /* XCLocalSwiftPackageReference "CmuxFeedbackUI" */; + productName = CmuxFeedbackUI; + }; C8000302C8000302C8000302 /* CmuxProcess */ = { isa = XCSwiftPackageProductDependency; package = C8000301C8000301C8000301 /* XCLocalSwiftPackageReference "CmuxProcess" */; diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index de08ea6246f..fb238dac822 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -1770,51 +1770,6 @@ final class TerminalOffscreenStartupTests: XCTestCase { } } -@MainActor -final class FeedbackComposerMessageEditorViewTests: XCTestCase { - func testLongMessageCreatesScrollableDocumentContent() { - let editor = FeedbackComposerMessageEditorView( - frame: NSRect(x: 0, y: 0, width: 360, height: 120) - ) - editor.placeholder = "Message" - editor.layoutSubtreeIfNeeded() - - editor.textView.string = (0..<80) - .map { "feedback line \($0)" } - .joined(separator: "\n") - editor.refreshTextLayout() - editor.layoutSubtreeIfNeeded() - - XCTAssertGreaterThan( - editor.textView.frame.height, - editor.scrollView.contentSize.height + 40 - ) - } - - func testTrailingBlankLineContributesToScrollableDocumentHeight() { - let editor = FeedbackComposerMessageEditorView( - frame: NSRect(x: 0, y: 0, width: 360, height: 120) - ) - editor.layoutSubtreeIfNeeded() - - let messageWithoutTrailingBlankLine = (0..<20) - .map { "feedback line \($0)" } - .joined(separator: "\n") - editor.textView.string = messageWithoutTrailingBlankLine - editor.refreshTextLayout() - let heightWithoutTrailingBlankLine = editor.textView.frame.height - - editor.textView.string = messageWithoutTrailingBlankLine + "\n" - editor.refreshTextLayout() - - XCTAssertGreaterThan( - editor.textView.frame.height, - heightWithoutTrailingBlankLine + 5 - ) - } -} - - final class TerminalKeyboardCopyModeActionTests: XCTestCase { func testCopyModeBypassAllowsOnlyCommandShortcuts() { XCTAssertTrue(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.command])) From c9ac520a79eb63a4b2d36cc0b2e648cff2d834c3 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 12 Jun 2026 22:56:32 -0700 Subject: [PATCH 12/31] ContentView drain: link CmuxAppKitSupportUI into cmuxTests target (stack E) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior commit added 'import CmuxAppKitSupportUI' to the retargeted SidebarScrollViewConfiguratorTests, but the cmuxTests target was not linked against the package, so test-depot (which compiles cmuxTests) failed to resolve the module. App-host XCTest files must link every package they import directly; only test-depot exercises this — swift build and 'xcodebuild build' compile the app, not the test target. Wire the 4 standard pbxproj entries for the test target, mirroring the existing CmuxFoundation linkage that SentryEventScrubberTests already relies on: PBXBuildFile, the cmuxTests Frameworks-phase listing, the packageProductDependencies listing, and an XCSwiftPackageProductDependency def pointing at the existing CmuxAppKitSupportUI local package reference. Normalized; check-pbxproj and lint-pbxproj-test-wiring both exit 0. Co-Authored-By: Claude Fable 5 --- cmux.xcodeproj/project.pbxproj | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 39ea4fd7ca7..2ac95d693ca 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -152,6 +152,7 @@ C4160A010000000000000001 /* cmuxApp+HistoryMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4160A010000000000000002 /* cmuxApp+HistoryMenu.swift */; }; A5001001 /* cmuxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001011 /* cmuxApp.swift */; }; C9A2B00000000000000000B3 /* CmuxAppKitSupportUI in Frameworks */ = {isa = PBXBuildFile; productRef = C9A2B00000000000000000B2 /* CmuxAppKitSupportUI */; }; + C9A2B00000000000000000C3 /* CmuxAppKitSupportUI in Frameworks */ = {isa = PBXBuildFile; productRef = C9A2B00000000000000000C2 /* CmuxAppKitSupportUI */; }; D35110010000000000000001 /* CmuxApplicationSupportDirectories.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35110010000000000000002 /* CmuxApplicationSupportDirectories.swift */; }; D35110010000000000000003 /* CmuxApplicationSupportDirectories.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35110010000000000000002 /* CmuxApplicationSupportDirectories.swift */; }; 5EDB6027B346C46521A93C74 /* CMUXAuthCore in Frameworks */ = {isa = PBXBuildFile; productRef = 29813FE5A6CBC1019289A251 /* CMUXAuthCore */; }; @@ -1667,6 +1668,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + C9A2B00000000000000000C3 /* CmuxAppKitSupportUI in Frameworks */, 5E2701040000000000000004 /* CmuxFoundation in Frameworks */, EFB18E3C0000000000000001 /* CMUXMobileCore in Frameworks */, C52830000000000000000002 /* CmuxTerminalCopyMode in Frameworks */, @@ -2642,6 +2644,7 @@ C52830000000000000000004 /* CmuxTerminalCopyMode */, 5E2701040000000000000001 /* Sentry */, 5E2701040000000000000002 /* CmuxFoundation */, + C9A2B00000000000000000C2 /* CmuxAppKitSupportUI */, ); productName = cmuxTests; productReference = F1000002A1B2C3D4E5F60718 /* cmuxTests.xctest */; @@ -4370,6 +4373,11 @@ package = CF00F00000000000000000A1 /* XCLocalSwiftPackageReference "CmuxFoundation" */; productName = CmuxFoundation; }; + C9A2B00000000000000000C2 /* CmuxAppKitSupportUI */ = { + isa = XCSwiftPackageProductDependency; + package = C9A2B00000000000000000B1 /* XCLocalSwiftPackageReference "CmuxAppKitSupportUI" */; + productName = CmuxAppKitSupportUI; + }; A5001261 /* Bonsplit */ = { isa = XCSwiftPackageProductDependency; package = A5001260 /* XCLocalSwiftPackageReference "bonsplit" */; From 656773c7a2a1eaa7db9303a0f6f3fb61d328cb40 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 12 Jun 2026 23:04:29 -0700 Subject: [PATCH 13/31] ContentView drain: retarget app-host palette tests to CmuxCommandPalette (stack E CI fix) A prior leg lifted CommandPaletteContextSnapshot, CommandPaletteContextKeys, and the CommandPaletteSwitcherFingerprint{Context,Workspace,Surface} value types from ContentView into the CmuxCommandPalette package, but three app-host test files still referenced them as nested ContentView.CommandPalette* members. The local app build never compiles cmuxTests, so this only surfaced on the cmux-unit gate ('type ContentView has no member CommandPaletteContextSnapshot'). De-qualify the lifted type references to their package names and add the hoisted import CmuxCommandPalette where missing. The app-side methods the tests exercise (commandPaletteRightSidebarModeCommandContributions, commandPaletteShortcutAction, etc.) stay on ContentView and are unchanged. All assertions are untouched. Co-Authored-By: Claude Fable 5 --- .../CommandPaletteSearchEngineTests.swift | 30 +++++++++---------- .../RightSidebarCommandPaletteTests.swift | 3 +- .../ShortcutAndCommandPaletteTests.swift | 21 ++++++------- 3 files changed, 28 insertions(+), 26 deletions(-) diff --git a/cmuxTests/CommandPaletteSearchEngineTests.swift b/cmuxTests/CommandPaletteSearchEngineTests.swift index b5525765e1f..7be8d0b7ac9 100644 --- a/cmuxTests/CommandPaletteSearchEngineTests.swift +++ b/cmuxTests/CommandPaletteSearchEngineTests.swift @@ -2066,12 +2066,12 @@ final class CommandPaletteSearchEngineTests: XCTestCase { let workspaceID = UUID() let base = ContentView.commandPaletteSwitcherFingerprint( windowContexts: [ - ContentView.CommandPaletteSwitcherFingerprintContext( + CommandPaletteSwitcherFingerprintContext( windowId: windowID, windowLabel: "Window 2", selectedWorkspaceId: workspaceID, workspaces: [ - ContentView.CommandPaletteSwitcherFingerprintWorkspace( + CommandPaletteSwitcherFingerprintWorkspace( id: workspaceID, displayName: "Workspace Alpha", metadata: CommandPaletteSwitcherSearchMetadata( @@ -2087,12 +2087,12 @@ final class CommandPaletteSearchEngineTests: XCTestCase { ) let changedMetadata = ContentView.commandPaletteSwitcherFingerprint( windowContexts: [ - ContentView.CommandPaletteSwitcherFingerprintContext( + CommandPaletteSwitcherFingerprintContext( windowId: windowID, windowLabel: "Window 2", selectedWorkspaceId: workspaceID, workspaces: [ - ContentView.CommandPaletteSwitcherFingerprintWorkspace( + CommandPaletteSwitcherFingerprintWorkspace( id: workspaceID, displayName: "Workspace Alpha", metadata: CommandPaletteSwitcherSearchMetadata( @@ -2108,12 +2108,12 @@ final class CommandPaletteSearchEngineTests: XCTestCase { ) let changedDisplayName = ContentView.commandPaletteSwitcherFingerprint( windowContexts: [ - ContentView.CommandPaletteSwitcherFingerprintContext( + CommandPaletteSwitcherFingerprintContext( windowId: windowID, windowLabel: "Window 2", selectedWorkspaceId: workspaceID, workspaces: [ - ContentView.CommandPaletteSwitcherFingerprintWorkspace( + CommandPaletteSwitcherFingerprintWorkspace( id: workspaceID, displayName: "Workspace Beta", metadata: CommandPaletteSwitcherSearchMetadata( @@ -2139,17 +2139,17 @@ final class CommandPaletteSearchEngineTests: XCTestCase { let base = ContentView.commandPaletteSwitcherFingerprint( windowContexts: [ - ContentView.CommandPaletteSwitcherFingerprintContext( + CommandPaletteSwitcherFingerprintContext( windowId: windowID, windowLabel: nil, selectedWorkspaceId: workspaceID, workspaces: [ - ContentView.CommandPaletteSwitcherFingerprintWorkspace( + CommandPaletteSwitcherFingerprintWorkspace( id: workspaceID, displayName: "Workspace Alpha", metadata: CommandPaletteSwitcherSearchMetadata(), surfaces: [ - ContentView.CommandPaletteSwitcherFingerprintSurface( + CommandPaletteSwitcherFingerprintSurface( id: surfaceID, displayName: "Terminal", kindLabel: "Terminal", @@ -2167,17 +2167,17 @@ final class CommandPaletteSearchEngineTests: XCTestCase { ) let changedSurfaceMetadata = ContentView.commandPaletteSwitcherFingerprint( windowContexts: [ - ContentView.CommandPaletteSwitcherFingerprintContext( + CommandPaletteSwitcherFingerprintContext( windowId: windowID, windowLabel: nil, selectedWorkspaceId: workspaceID, workspaces: [ - ContentView.CommandPaletteSwitcherFingerprintWorkspace( + CommandPaletteSwitcherFingerprintWorkspace( id: workspaceID, displayName: "Workspace Alpha", metadata: CommandPaletteSwitcherSearchMetadata(), surfaces: [ - ContentView.CommandPaletteSwitcherFingerprintSurface( + CommandPaletteSwitcherFingerprintSurface( id: surfaceID, displayName: "Terminal", kindLabel: "Terminal", @@ -2195,17 +2195,17 @@ final class CommandPaletteSearchEngineTests: XCTestCase { ) let changedSurfaceKind = ContentView.commandPaletteSwitcherFingerprint( windowContexts: [ - ContentView.CommandPaletteSwitcherFingerprintContext( + CommandPaletteSwitcherFingerprintContext( windowId: windowID, windowLabel: nil, selectedWorkspaceId: workspaceID, workspaces: [ - ContentView.CommandPaletteSwitcherFingerprintWorkspace( + CommandPaletteSwitcherFingerprintWorkspace( id: workspaceID, displayName: "Workspace Alpha", metadata: CommandPaletteSwitcherSearchMetadata(), surfaces: [ - ContentView.CommandPaletteSwitcherFingerprintSurface( + CommandPaletteSwitcherFingerprintSurface( id: surfaceID, displayName: "Terminal", kindLabel: "Browser", diff --git a/cmuxTests/RightSidebarCommandPaletteTests.swift b/cmuxTests/RightSidebarCommandPaletteTests.swift index 4ef05c72d36..e1d621f6504 100644 --- a/cmuxTests/RightSidebarCommandPaletteTests.swift +++ b/cmuxTests/RightSidebarCommandPaletteTests.swift @@ -1,3 +1,4 @@ +import CmuxCommandPalette import Foundation import XCTest @@ -15,7 +16,7 @@ final class RightSidebarCommandPaletteTests: XCTestCase { defaults.removeObject(forKey: RightSidebarBetaFeatureSettings.dockEnabledKey) let contributions = ContentView.commandPaletteRightSidebarModeCommandContributions() let contributionsByID = Dictionary(uniqueKeysWithValues: contributions.map { ($0.commandId, $0) }) - let context = ContentView.CommandPaletteContextSnapshot() + let context = CommandPaletteContextSnapshot() for mode in RightSidebarMode.availableModes() { let commandID = ContentView.commandPaletteRightSidebarModeCommandID(mode) diff --git a/cmuxTests/ShortcutAndCommandPaletteTests.swift b/cmuxTests/ShortcutAndCommandPaletteTests.swift index 07f9c15bbf3..006b59c3f73 100644 --- a/cmuxTests/ShortcutAndCommandPaletteTests.swift +++ b/cmuxTests/ShortcutAndCommandPaletteTests.swift @@ -1,3 +1,4 @@ +import CmuxCommandPalette import XCTest import AppKit import SwiftUI @@ -826,9 +827,9 @@ final class CommandPaletteRenameSelectionSettingsTests: XCTestCase { final class CommandPaletteAuthCommandTests: XCTestCase { func testSignedOutContextShowsSignInCommandOnly() { - var context = ContentView.CommandPaletteContextSnapshot() - context.setBool(ContentView.CommandPaletteContextKeys.authSignedIn, false) - context.setBool(ContentView.CommandPaletteContextKeys.authWorking, false) + var context = CommandPaletteContextSnapshot() + context.setBool(CommandPaletteContextKeys.authSignedIn, false) + context.setBool(CommandPaletteContextKeys.authWorking, false) let visibleCommandIds = visibleAuthCommandIds(context) @@ -836,9 +837,9 @@ final class CommandPaletteAuthCommandTests: XCTestCase { } func testSignedInContextShowsSignOutCommandOnly() { - var context = ContentView.CommandPaletteContextSnapshot() - context.setBool(ContentView.CommandPaletteContextKeys.authSignedIn, true) - context.setBool(ContentView.CommandPaletteContextKeys.authWorking, false) + var context = CommandPaletteContextSnapshot() + context.setBool(CommandPaletteContextKeys.authSignedIn, true) + context.setBool(CommandPaletteContextKeys.authWorking, false) let visibleCommandIds = visibleAuthCommandIds(context) @@ -847,15 +848,15 @@ final class CommandPaletteAuthCommandTests: XCTestCase { func testWorkingAuthContextHidesSignInAndSignOutCommands() { for signedIn in [false, true] { - var context = ContentView.CommandPaletteContextSnapshot() - context.setBool(ContentView.CommandPaletteContextKeys.authSignedIn, signedIn) - context.setBool(ContentView.CommandPaletteContextKeys.authWorking, true) + var context = CommandPaletteContextSnapshot() + context.setBool(CommandPaletteContextKeys.authSignedIn, signedIn) + context.setBool(CommandPaletteContextKeys.authWorking, true) XCTAssertTrue(visibleAuthCommandIds(context).isEmpty) } } - private func visibleAuthCommandIds(_ context: ContentView.CommandPaletteContextSnapshot) -> [String] { + private func visibleAuthCommandIds(_ context: CommandPaletteContextSnapshot) -> [String] { ContentView.commandPaletteAuthCommandContributions() .filter { $0.when(context) } .map(\.commandId) From 48f9614a6e0eeabc874aa508655182325155d326 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 12 Jun 2026 23:06:14 -0700 Subject: [PATCH 14/31] ContentView drain: make SidebarScrollViewResolverView.resolveScrollView() nonisolated (stack E CI fix) test-depot failed compiling CmuxAppKitSupportUI: 'call to main actor-isolated instance method resolveScrollView() in a synchronous nonisolated context' at the NotificationCenter observer closure. NSView is @MainActor under the package's Swift 6 strict concurrency, so the class (and the method) inherited main-actor isolation; the addObserver(queue:.main) closure is @Sendable and cannot call a @MainActor method synchronously. The app target compiled it before only because it isn't in strict-concurrency mode (the Stack E app-test-compile gap). resolveScrollView()'s body only schedules a @MainActor Task and performs no isolated work itself, so marking it nonisolated is the faithful fix: it stays callable from the observer closure and from the @MainActor lifecycle overrides, and the actual resolution still runs on the main actor inside the Task. No MainActor.assumeIsolated (banned). Package swift build + test green; app xcodebuild BUILD SUCCEEDED. Co-Authored-By: Claude Fable 5 --- .../Scroll/SidebarScrollViewResolverView.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarScrollViewResolverView.swift b/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarScrollViewResolverView.swift index a2f76ec55d4..396f64e3d2b 100644 --- a/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarScrollViewResolverView.swift +++ b/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarScrollViewResolverView.swift @@ -60,7 +60,12 @@ public final class SidebarScrollViewResolverView: NSView { /// Resolves the enclosing scroll view after one deferred main-actor hop so /// the view hierarchy settles and any AppKit scroller-style reset lands /// before the configuration is re-applied. - public func resolveScrollView() { + /// + /// `nonisolated` so it can be invoked from the `NotificationCenter` observer + /// closure (a `@Sendable` context) without a synchronous main-actor call; + /// the body only schedules a `@MainActor` `Task`, so it performs no isolated + /// work itself and the actual resolution still runs on the main actor. + public nonisolated func resolveScrollView() { // Deferred one main-actor hop so the view hierarchy settles before // enclosingScrollView is resolved and, on scroller-style changes, // AppKit's own synchronous per-scroll-view reset lands before the From 8373ccc3a83bc3a5ed7bda2fcd07d38b00063f9f Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 12 Jun 2026 23:07:39 -0700 Subject: [PATCH 15/31] ContentView drain: link CmuxCommandPalette into cmuxTests target (stack E CI fix) The palette test retarget added 'import CmuxCommandPalette' to three app-host test files (RightSidebarCommandPaletteTests, CommandPaletteSearchEngineTests, ShortcutAndCommandPaletteTests), but the cmuxTests target was not linked against the package. App-host XCTest files must link every package they import directly; only test-depot (scheme cmux-unit) compiles cmuxTests, so swift build and 'xcodebuild build' do not catch this. Wire the 4 standard pbxproj entries for the test target, mirroring the existing CmuxAppKitSupportUI test linkage: PBXBuildFile, the cmuxTests Frameworks-phase listing, the packageProductDependencies listing, and an XCSwiftPackageProductDependency def pointing at the existing CmuxCommandPalette local package reference. Normalized; check-pbxproj and lint-pbxproj-test-wiring both exit 0. Co-Authored-By: Claude Fable 5 --- cmux.xcodeproj/project.pbxproj | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 2ac95d693ca..47664ec2fb0 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -178,6 +178,7 @@ C0DE31390000000000000105 /* CMUXCLIErrorOutputRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE31390000000000000106 /* CMUXCLIErrorOutputRegressionTests.swift */; }; A72C9F4179B54DF38E99A021 /* CmuxCLIPathInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4FE96C3F394FC6A6D4B018 /* CmuxCLIPathInstaller.swift */; }; C9A1C00000000000000000A3 /* CmuxCommandPalette in Frameworks */ = {isa = PBXBuildFile; productRef = C9A1C00000000000000000A2 /* CmuxCommandPalette */; }; + C9A1C00000000000000000A4 /* CmuxCommandPalette in Frameworks */ = {isa = PBXBuildFile; productRef = C9A1C00000000000000000A5 /* CmuxCommandPalette */; }; A5001650 /* CmuxConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001651 /* CmuxConfig.swift */; }; C0DEF0A40000000000000001 /* CmuxConfigContextMenuTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0A40000000000000002 /* CmuxConfigContextMenuTests.swift */; }; A5001652 /* CmuxConfigExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001653 /* CmuxConfigExecutor.swift */; }; @@ -1669,6 +1670,7 @@ buildActionMask = 2147483647; files = ( C9A2B00000000000000000C3 /* CmuxAppKitSupportUI in Frameworks */, + C9A1C00000000000000000A4 /* CmuxCommandPalette in Frameworks */, 5E2701040000000000000004 /* CmuxFoundation in Frameworks */, EFB18E3C0000000000000001 /* CMUXMobileCore in Frameworks */, C52830000000000000000002 /* CmuxTerminalCopyMode in Frameworks */, @@ -2645,6 +2647,7 @@ 5E2701040000000000000001 /* Sentry */, 5E2701040000000000000002 /* CmuxFoundation */, C9A2B00000000000000000C2 /* CmuxAppKitSupportUI */, + C9A1C00000000000000000A5 /* CmuxCommandPalette */, ); productName = cmuxTests; productReference = F1000002A1B2C3D4E5F60718 /* cmuxTests.xctest */; @@ -4308,6 +4311,11 @@ package = C9A1C00000000000000000A1 /* XCLocalSwiftPackageReference "CmuxCommandPalette" */; productName = CmuxCommandPalette; }; + C9A1C00000000000000000A5 /* CmuxCommandPalette */ = { + isa = XCSwiftPackageProductDependency; + package = C9A1C00000000000000000A1 /* XCLocalSwiftPackageReference "CmuxCommandPalette" */; + productName = CmuxCommandPalette; + }; C9A2B00000000000000000B2 /* CmuxAppKitSupportUI */ = { isa = XCSwiftPackageProductDependency; package = C9A2B00000000000000000B1 /* XCLocalSwiftPackageReference "CmuxAppKitSupportUI" */; From fc6d171e49049b0dbbb66354e5ac9adef2c5c537 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 12 Jun 2026 23:13:04 -0700 Subject: [PATCH 16/31] ContentView drain: fix CmuxAppKitSupportUI strict-concurrency errors surfaced by CI (stack E CI fix) test-depot (xcodebuild, which compiles packages with strict settings that promote these to errors) failed on two issues my plain 'swift build' only emitted as warnings: 1. SidebarScrollViewConfigurator.apply(to:) read/mutated NSScrollView's main-actor-isolated scroller properties from a non-isolated static func -> marked @MainActor. Every caller (the resolver onResolve callback, main-thread driven) is already on the main actor. 2. SidebarScrollViewResolverView's observer token used the bare existential 'NSObjectProtocol?' while the package enables the ExistentialAny upcoming feature -> 'any NSObjectProtocol?'. Both are faithful (no behavior change): the app target compiled the originals only because it isn't in strict-concurrency mode. Verified with 'swift build/test -Xswiftc -warnings-as-errors -Xswiftc -strict-concurrency=complete' (clean) plus app xcodebuild BUILD SUCCEEDED. Co-Authored-By: Claude Fable 5 --- .../Scroll/SidebarScrollViewConfigurator.swift | 6 ++++++ .../Scroll/SidebarScrollViewResolverView.swift | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarScrollViewConfigurator.swift b/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarScrollViewConfigurator.swift index c314807205a..0dc60db3b6d 100644 --- a/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarScrollViewConfigurator.swift +++ b/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarScrollViewConfigurator.swift @@ -12,6 +12,12 @@ public enum SidebarScrollViewConfigurator { /// Forces the scroll view into the stable overlay-scroller configuration, /// writing each property only when it differs to avoid cancelling an /// in-flight scroller fade. + /// + /// `@MainActor` because it reads and mutates `NSScrollView`'s + /// main-actor-isolated scroller properties; every caller (the resolver's + /// `onResolve` callback, driven from the main thread) is already on the main + /// actor. + @MainActor public static func apply(to scrollView: NSScrollView) { if scrollView.hasHorizontalScroller { scrollView.hasHorizontalScroller = false diff --git a/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarScrollViewResolverView.swift b/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarScrollViewResolverView.swift index 396f64e3d2b..a58170cfab0 100644 --- a/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarScrollViewResolverView.swift +++ b/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarScrollViewResolverView.swift @@ -13,7 +13,7 @@ public final class SidebarScrollViewResolverView: NSView { // nonisolated deinit, which is safe because deinit runs after all main-thread // access has ceased. `nonisolated(unsafe)` keeps that one cross-isolation // read legal under Swift 6 without weakening the type. - private nonisolated(unsafe) var scrollerStyleObserver: NSObjectProtocol? + private nonisolated(unsafe) var scrollerStyleObserver: (any NSObjectProtocol)? /// Creates the resolver view and begins observing scroller-style changes. public override init(frame frameRect: NSRect) { From b561670f0be9e2ec7e52e0b322a1550c1bd41098 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 12 Jun 2026 23:24:48 -0700 Subject: [PATCH 17/31] ContentView drain: refresh file-length budget for palette-test import (stack E) The palette-test retarget added one 'import CmuxCommandPalette' line to ShortcutAndCommandPaletteTests.swift (2087->2088), tripping the file-length budget gate. Refresh the budget tsv to absorb the necessary +1 import line, per the established cutover precedent. Co-Authored-By: Claude Fable 5 --- .github/swift-file-length-budget.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index b0c4c15d6cd..df2a52c15e7 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -45,7 +45,7 @@ 2260 Sources/TerminalWindowPortal.swift 2198 Sources/SessionPersistence.swift 2117 cmuxTests/CmuxConfigTests.swift -2087 cmuxTests/ShortcutAndCommandPaletteTests.swift +2088 cmuxTests/ShortcutAndCommandPaletteTests.swift 2030 Sources/KeyboardShortcutSettingsFileStore.swift 1949 Sources/Panels/BrowserWebAuthnSupport.swift 1941 Sources/TerminalNotificationStore.swift From 48bcea50a4e076f01f24a33310b1abf9b5dc178e Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 12 Jun 2026 23:43:44 -0700 Subject: [PATCH 18/31] ContentView drain: import CmuxCommandPalette in CommandPaletteNucleoFixtures (stack E CI fix) The cmux-unit gate caught a third app-host test file that references lifted CmuxCommandPalette types (CommandPaletteSearchCorpusEntry, CommandPaletteSearchEngine, CommandPaletteSwitcherSearchIndexer, CommandPaletteSwitcherSearchMetadata) without importing the package: 'cannot find type CommandPaletteSearchCorpusEntry in scope'. The local 'xcodebuild build' never compiles cmuxTests, so only test-depot surfaces it. Add the hoisted import (above the canImport block). No app-side duplicates of these types exist, so no ambiguity is introduced; a sibling test (CommandPaletteSearchEngineTests) already uses the same types through this import. Co-Authored-By: Claude Fable 5 --- cmuxTests/CommandPaletteNucleoFixtures.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/cmuxTests/CommandPaletteNucleoFixtures.swift b/cmuxTests/CommandPaletteNucleoFixtures.swift index 073422ba993..ca93ada63c1 100644 --- a/cmuxTests/CommandPaletteNucleoFixtures.swift +++ b/cmuxTests/CommandPaletteNucleoFixtures.swift @@ -1,3 +1,4 @@ +import CmuxCommandPalette import Darwin import Foundation From 393fe3059b3c77995bb91cd94de5a83002dd7b0d Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 12 Jun 2026 23:53:37 -0700 Subject: [PATCH 19/31] ContentView drain: retarget palette fingerprint test calls to package statics (stack E CI fix) The cmux-unit gate caught the prior leg lifted ContentView.commandPaletteContextFingerprint and ContentView.commandPaletteSwitcherFingerprint into CmuxCommandPalette as static methods on CommandPaletteContextSnapshot and CommandPaletteSwitcherFingerprintContext, but CommandPaletteSearchEngineTests still called them as ContentView members ('type ContentView has no member commandPaletteContextFingerprint'). Retarget the 3 + 6 call sites to CommandPaletteContextSnapshot.fingerprint(boolValues:stringValues:) and CommandPaletteSwitcherFingerprintContext.fingerprint(windowContexts:); the package signatures match the originals exactly and the file already imports CmuxCommandPalette. The app-side ContentView.commandPaletteForkSnapshotFingerprint (a distinct method) is untouched. A reliable fixed-string sweep confirms no other ContentView. reference in cmuxTests/cmuxUITests is missing from Sources. Co-Authored-By: Claude Fable 5 --- .../CommandPaletteSearchEngineTests.swift | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/cmuxTests/CommandPaletteSearchEngineTests.swift b/cmuxTests/CommandPaletteSearchEngineTests.swift index 7be8d0b7ac9..c22072d89b4 100644 --- a/cmuxTests/CommandPaletteSearchEngineTests.swift +++ b/cmuxTests/CommandPaletteSearchEngineTests.swift @@ -2023,7 +2023,7 @@ final class CommandPaletteSearchEngineTests: XCTestCase { } func testCommandContextFingerprintTracksExactContextValues() { - let base = ContentView.commandPaletteContextFingerprint( + let base = CommandPaletteContextSnapshot.fingerprint( boolValues: [ "workspace.hasPullRequests": true, "panel.hasUnread": false, @@ -2034,7 +2034,7 @@ final class CommandPaletteSearchEngineTests: XCTestCase { "panel.name": "Main", ] ) - let unreadChanged = ContentView.commandPaletteContextFingerprint( + let unreadChanged = CommandPaletteContextSnapshot.fingerprint( boolValues: [ "workspace.hasPullRequests": true, "panel.hasUnread": true, @@ -2045,7 +2045,7 @@ final class CommandPaletteSearchEngineTests: XCTestCase { "panel.name": "Main", ] ) - let renamed = ContentView.commandPaletteContextFingerprint( + let renamed = CommandPaletteContextSnapshot.fingerprint( boolValues: [ "workspace.hasPullRequests": true, "panel.hasUnread": false, @@ -2064,7 +2064,7 @@ final class CommandPaletteSearchEngineTests: XCTestCase { func testSwitcherFingerprintTracksMetadataValuesAtSameCardinality() { let windowID = UUID() let workspaceID = UUID() - let base = ContentView.commandPaletteSwitcherFingerprint( + let base = CommandPaletteSwitcherFingerprintContext.fingerprint( windowContexts: [ CommandPaletteSwitcherFingerprintContext( windowId: windowID, @@ -2085,7 +2085,7 @@ final class CommandPaletteSearchEngineTests: XCTestCase { ) ] ) - let changedMetadata = ContentView.commandPaletteSwitcherFingerprint( + let changedMetadata = CommandPaletteSwitcherFingerprintContext.fingerprint( windowContexts: [ CommandPaletteSwitcherFingerprintContext( windowId: windowID, @@ -2106,7 +2106,7 @@ final class CommandPaletteSearchEngineTests: XCTestCase { ) ] ) - let changedDisplayName = ContentView.commandPaletteSwitcherFingerprint( + let changedDisplayName = CommandPaletteSwitcherFingerprintContext.fingerprint( windowContexts: [ CommandPaletteSwitcherFingerprintContext( windowId: windowID, @@ -2137,7 +2137,7 @@ final class CommandPaletteSearchEngineTests: XCTestCase { let workspaceID = UUID() let surfaceID = UUID() - let base = ContentView.commandPaletteSwitcherFingerprint( + let base = CommandPaletteSwitcherFingerprintContext.fingerprint( windowContexts: [ CommandPaletteSwitcherFingerprintContext( windowId: windowID, @@ -2165,7 +2165,7 @@ final class CommandPaletteSearchEngineTests: XCTestCase { ) ] ) - let changedSurfaceMetadata = ContentView.commandPaletteSwitcherFingerprint( + let changedSurfaceMetadata = CommandPaletteSwitcherFingerprintContext.fingerprint( windowContexts: [ CommandPaletteSwitcherFingerprintContext( windowId: windowID, @@ -2193,7 +2193,7 @@ final class CommandPaletteSearchEngineTests: XCTestCase { ) ] ) - let changedSurfaceKind = ContentView.commandPaletteSwitcherFingerprint( + let changedSurfaceKind = CommandPaletteSwitcherFingerprintContext.fingerprint( windowContexts: [ CommandPaletteSwitcherFingerprintContext( windowId: windowID, From 5cdf4bdd11a9e251f716e4bb39615441de8c4068 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Sat, 13 Jun 2026 00:03:21 -0700 Subject: [PATCH 20/31] ContentView drain: lift sidebar drag auto-scroll domain to CmuxAppKitSupportUI (stack E) Faithfully lifts SidebarAutoScrollDirection, SidebarAutoScrollPlan, SidebarDragAutoScrollPlanner, and SidebarDragAutoScrollController out of ContentView.swift into CmuxAppKitSupportUI/Scroll, alongside the existing sidebar scroll-view resolver. Bodies are byte-identical; the only additions are access modifiers, public imports (CoreGraphics/AppKit/Combine for the public API surface), and an explicit public memberwise init on the plan struct (synthesized memberwise init is internal cross-module). SidebarWorkspaceGroupHeaderView and SidebarOrderingTests retargeted to the package import; planner tests unchanged. ContentView 17932 -> 17761 lines. Co-Authored-By: Claude Fable 5 --- .github/swift-file-length-budget.tsv | 4 +- .../SidebarDragAutoScrollController.swift | 142 +++++++++++++++ .../Scroll/SidebarDragAutoScrollPlanner.swift | 49 +++++ Sources/ContentView.swift | 171 ------------------ Sources/SidebarWorkspaceGroupHeaderView.swift | 1 + cmuxTests/SidebarOrderingTests.swift | 1 + 6 files changed, 195 insertions(+), 173 deletions(-) create mode 100644 Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarDragAutoScrollController.swift create mode 100644 Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarDragAutoScrollPlanner.swift diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index df2a52c15e7..8fe2df9ce1d 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -4,7 +4,7 @@ 33285 CLI/cmux.swift 19991 Sources/Workspace.swift 18118 Sources/AppDelegate.swift -17932 Sources/ContentView.swift +17761 Sources/ContentView.swift 16675 Sources/GhosttyTerminalView.swift 14623 Sources/TerminalController.swift 13606 Sources/Panels/BrowserPanel.swift @@ -73,7 +73,7 @@ 1216 Packages/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteSearchEngineTests.swift 1205 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift 1197 cmuxTests/CodexAppServerSessionTests.swift -1156 cmuxTests/SidebarOrderingTests.swift +1157 cmuxTests/SidebarOrderingTests.swift 1144 Sources/VaultAgentProcessScanner.swift 1139 cmuxTests/PiVaultAgentPersistenceTests.swift 1126 cmuxTests/FileExplorerStoreTests.swift diff --git a/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarDragAutoScrollController.swift b/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarDragAutoScrollController.swift new file mode 100644 index 00000000000..8a7820eb1cd --- /dev/null +++ b/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarDragAutoScrollController.swift @@ -0,0 +1,142 @@ +public import AppKit +public import Combine + +/// Drives sidebar auto-scrolling while a drag hovers near the scroll view's top +/// or bottom edge. Prefers AppKit's native `NSClipView.autoscroll(with:)` when a +/// drag event is available and falls back to a manual per-tick scroll computed +/// from `SidebarDragAutoScrollPlanner`. +@MainActor +public final class SidebarDragAutoScrollController: ObservableObject { + private weak var scrollView: NSScrollView? + private var timer: Timer? + private var activePlan: SidebarAutoScrollPlan? + + public init() {} + + public func attach(scrollView: NSScrollView?) { + self.scrollView = scrollView + } + + public func updateFromDragLocation() { + guard let scrollView else { + stop() + return + } + guard let plan = plan(for: scrollView) else { + stop() + return + } + activePlan = plan + startTimerIfNeeded() + } + + public func stop() { + timer?.invalidate() + timer = nil + activePlan = nil + } + + private func startTimerIfNeeded() { + guard timer == nil else { return } + let timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + self?.tick() + } + } + self.timer = timer + RunLoop.main.add(timer, forMode: .eventTracking) + } + + private func tick() { + guard NSEvent.pressedMouseButtons != 0 else { + stop() + return + } + guard let scrollView else { + stop() + return + } + + // AppKit drag/drop autoscroll guidance recommends autoscroll(with:) + // when periodic drag updates are available; use it first. + if applyNativeAutoscroll(to: scrollView) { + activePlan = plan(for: scrollView) + if activePlan == nil { + stop() + } + return + } + + activePlan = self.plan(for: scrollView) + guard let plan = activePlan else { + stop() + return + } + _ = apply(plan: plan, to: scrollView) + } + + private func applyNativeAutoscroll(to scrollView: NSScrollView) -> Bool { + guard let event = NSApp.currentEvent else { return false } + switch event.type { + case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: + break + default: + return false + } + + let clipView = scrollView.contentView + let didScroll = clipView.autoscroll(with: event) + if didScroll { + scrollView.reflectScrolledClipView(clipView) + } + return didScroll + } + + private func distancesToEdges(mousePoint: CGPoint, viewportHeight: CGFloat, isFlipped: Bool) -> (top: CGFloat, bottom: CGFloat) { + if isFlipped { + return (top: mousePoint.y, bottom: viewportHeight - mousePoint.y) + } + return (top: viewportHeight - mousePoint.y, bottom: mousePoint.y) + } + + private func planForMousePoint(_ mousePoint: CGPoint, in clipView: NSClipView) -> SidebarAutoScrollPlan? { + let viewportHeight = clipView.bounds.height + guard viewportHeight > 0 else { return nil } + + let distances = distancesToEdges(mousePoint: mousePoint, viewportHeight: viewportHeight, isFlipped: clipView.isFlipped) + return SidebarDragAutoScrollPlanner.plan(distanceToTop: distances.top, distanceToBottom: distances.bottom) + } + + private func mousePoint(in clipView: NSClipView) -> CGPoint { + let mouseInWindow = clipView.window?.convertPoint(fromScreen: NSEvent.mouseLocation) ?? .zero + return clipView.convert(mouseInWindow, from: nil) + } + + private func currentPlan(for scrollView: NSScrollView) -> SidebarAutoScrollPlan? { + let clipView = scrollView.contentView + let mouse = mousePoint(in: clipView) + return planForMousePoint(mouse, in: clipView) + } + + private func plan(for scrollView: NSScrollView) -> SidebarAutoScrollPlan? { + currentPlan(for: scrollView) + } + + private func apply(plan: SidebarAutoScrollPlan, to scrollView: NSScrollView) -> Bool { + guard let documentView = scrollView.documentView else { return false } + let clipView = scrollView.contentView + let maxOriginY = max(0, documentView.bounds.height - clipView.bounds.height) + guard maxOriginY > 0 else { return false } + + let directionMultiplier: CGFloat = (plan.direction == .down) ? 1 : -1 + let flippedMultiplier: CGFloat = documentView.isFlipped ? 1 : -1 + let delta = directionMultiplier * flippedMultiplier * plan.pointsPerTick + let currentY = clipView.bounds.origin.y + let targetY = min(max(currentY + delta, 0), maxOriginY) + guard abs(targetY - currentY) > 0.01 else { return false } + + clipView.scroll(to: CGPoint(x: clipView.bounds.origin.x, y: targetY)) + scrollView.reflectScrolledClipView(clipView) + return true + } +} diff --git a/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarDragAutoScrollPlanner.swift b/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarDragAutoScrollPlanner.swift new file mode 100644 index 00000000000..96799a876d4 --- /dev/null +++ b/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarDragAutoScrollPlanner.swift @@ -0,0 +1,49 @@ +public import CoreGraphics + +/// Direction the sidebar should auto-scroll while a drag hovers near an edge. +public enum SidebarAutoScrollDirection: Equatable { + case up + case down +} + +/// Immutable plan describing how the sidebar should auto-scroll for the current +/// drag location: which direction and how many points to advance per tick. +public struct SidebarAutoScrollPlan: Equatable { + public let direction: SidebarAutoScrollDirection + public let pointsPerTick: CGFloat + + public init(direction: SidebarAutoScrollDirection, pointsPerTick: CGFloat) { + self.direction = direction + self.pointsPerTick = pointsPerTick + } +} + +/// Pure planner that maps a drag location's distance to the viewport edges into +/// an auto-scroll plan, ramping the per-tick step between `minStep` and +/// `maxStep` as the pointer approaches the edge. +public enum SidebarDragAutoScrollPlanner { + public static let edgeInset: CGFloat = 44 + public static let minStep: CGFloat = 2 + public static let maxStep: CGFloat = 12 + + public static func plan( + distanceToTop: CGFloat, + distanceToBottom: CGFloat, + edgeInset: CGFloat = SidebarDragAutoScrollPlanner.edgeInset, + minStep: CGFloat = SidebarDragAutoScrollPlanner.minStep, + maxStep: CGFloat = SidebarDragAutoScrollPlanner.maxStep + ) -> SidebarAutoScrollPlan? { + guard edgeInset > 0, maxStep >= minStep else { return nil } + if distanceToTop <= edgeInset { + let normalized = max(0, min(1, (edgeInset - distanceToTop) / edgeInset)) + let step = minStep + ((maxStep - minStep) * normalized) + return SidebarAutoScrollPlan(direction: .up, pointsPerTick: step) + } + if distanceToBottom <= edgeInset { + let normalized = max(0, min(1, (edgeInset - distanceToBottom) / edgeInset)) + let step = minStep + ((maxStep - minStep) * normalized) + return SidebarAutoScrollPlan(direction: .down, pointsPerTick: step) + } + return nil + } +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 410b96584b6..915e9290532 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -16278,177 +16278,6 @@ private struct SidebarMetadataMarkdownBlockRow: View { } } -enum SidebarAutoScrollDirection: Equatable { - case up - case down -} - -struct SidebarAutoScrollPlan: Equatable { - let direction: SidebarAutoScrollDirection - let pointsPerTick: CGFloat -} - -enum SidebarDragAutoScrollPlanner { - static let edgeInset: CGFloat = 44 - static let minStep: CGFloat = 2 - static let maxStep: CGFloat = 12 - - static func plan( - distanceToTop: CGFloat, - distanceToBottom: CGFloat, - edgeInset: CGFloat = SidebarDragAutoScrollPlanner.edgeInset, - minStep: CGFloat = SidebarDragAutoScrollPlanner.minStep, - maxStep: CGFloat = SidebarDragAutoScrollPlanner.maxStep - ) -> SidebarAutoScrollPlan? { - guard edgeInset > 0, maxStep >= minStep else { return nil } - if distanceToTop <= edgeInset { - let normalized = max(0, min(1, (edgeInset - distanceToTop) / edgeInset)) - let step = minStep + ((maxStep - minStep) * normalized) - return SidebarAutoScrollPlan(direction: .up, pointsPerTick: step) - } - if distanceToBottom <= edgeInset { - let normalized = max(0, min(1, (edgeInset - distanceToBottom) / edgeInset)) - let step = minStep + ((maxStep - minStep) * normalized) - return SidebarAutoScrollPlan(direction: .down, pointsPerTick: step) - } - return nil - } -} - -@MainActor -final class SidebarDragAutoScrollController: ObservableObject { - private weak var scrollView: NSScrollView? - private var timer: Timer? - private var activePlan: SidebarAutoScrollPlan? - - func attach(scrollView: NSScrollView?) { - self.scrollView = scrollView - } - - func updateFromDragLocation() { - guard let scrollView else { - stop() - return - } - guard let plan = plan(for: scrollView) else { - stop() - return - } - activePlan = plan - startTimerIfNeeded() - } - - func stop() { - timer?.invalidate() - timer = nil - activePlan = nil - } - - private func startTimerIfNeeded() { - guard timer == nil else { return } - let timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in - Task { @MainActor [weak self] in - self?.tick() - } - } - self.timer = timer - RunLoop.main.add(timer, forMode: .eventTracking) - } - - private func tick() { - guard NSEvent.pressedMouseButtons != 0 else { - stop() - return - } - guard let scrollView else { - stop() - return - } - - // AppKit drag/drop autoscroll guidance recommends autoscroll(with:) - // when periodic drag updates are available; use it first. - if applyNativeAutoscroll(to: scrollView) { - activePlan = plan(for: scrollView) - if activePlan == nil { - stop() - } - return - } - - activePlan = self.plan(for: scrollView) - guard let plan = activePlan else { - stop() - return - } - _ = apply(plan: plan, to: scrollView) - } - - private func applyNativeAutoscroll(to scrollView: NSScrollView) -> Bool { - guard let event = NSApp.currentEvent else { return false } - switch event.type { - case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: - break - default: - return false - } - - let clipView = scrollView.contentView - let didScroll = clipView.autoscroll(with: event) - if didScroll { - scrollView.reflectScrolledClipView(clipView) - } - return didScroll - } - - private func distancesToEdges(mousePoint: CGPoint, viewportHeight: CGFloat, isFlipped: Bool) -> (top: CGFloat, bottom: CGFloat) { - if isFlipped { - return (top: mousePoint.y, bottom: viewportHeight - mousePoint.y) - } - return (top: viewportHeight - mousePoint.y, bottom: mousePoint.y) - } - - private func planForMousePoint(_ mousePoint: CGPoint, in clipView: NSClipView) -> SidebarAutoScrollPlan? { - let viewportHeight = clipView.bounds.height - guard viewportHeight > 0 else { return nil } - - let distances = distancesToEdges(mousePoint: mousePoint, viewportHeight: viewportHeight, isFlipped: clipView.isFlipped) - return SidebarDragAutoScrollPlanner.plan(distanceToTop: distances.top, distanceToBottom: distances.bottom) - } - - private func mousePoint(in clipView: NSClipView) -> CGPoint { - let mouseInWindow = clipView.window?.convertPoint(fromScreen: NSEvent.mouseLocation) ?? .zero - return clipView.convert(mouseInWindow, from: nil) - } - - private func currentPlan(for scrollView: NSScrollView) -> SidebarAutoScrollPlan? { - let clipView = scrollView.contentView - let mouse = mousePoint(in: clipView) - return planForMousePoint(mouse, in: clipView) - } - - private func plan(for scrollView: NSScrollView) -> SidebarAutoScrollPlan? { - currentPlan(for: scrollView) - } - - private func apply(plan: SidebarAutoScrollPlan, to scrollView: NSScrollView) -> Bool { - guard let documentView = scrollView.documentView else { return false } - let clipView = scrollView.contentView - let maxOriginY = max(0, documentView.bounds.height - clipView.bounds.height) - guard maxOriginY > 0 else { return false } - - let directionMultiplier: CGFloat = (plan.direction == .down) ? 1 : -1 - let flippedMultiplier: CGFloat = documentView.isFlipped ? 1 : -1 - let delta = directionMultiplier * flippedMultiplier * plan.pointsPerTick - let currentY = clipView.bounds.origin.y - let targetY = min(max(currentY + delta, 0), maxOriginY) - guard abs(targetY - currentY) > 0.01 else { return false } - - clipView.scroll(to: CGPoint(x: clipView.bounds.origin.x, y: targetY)) - scrollView.reflectScrolledClipView(clipView) - return true - } -} - /// Immutable, equatable snapshot of the group list a row's "Move to Group" /// submenu can offer. Computed once per parent body eval and passed into /// each TabItemView so the row's `==` covers group changes (renames, adds, diff --git a/Sources/SidebarWorkspaceGroupHeaderView.swift b/Sources/SidebarWorkspaceGroupHeaderView.swift index 9c45d697145..7f8d0a1fe20 100644 --- a/Sources/SidebarWorkspaceGroupHeaderView.swift +++ b/Sources/SidebarWorkspaceGroupHeaderView.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxAppKitSupportUI import SwiftUI /// Collapsible group header that doubles as the anchor workspace row. diff --git a/cmuxTests/SidebarOrderingTests.swift b/cmuxTests/SidebarOrderingTests.swift index 7492656a172..c967f222925 100644 --- a/cmuxTests/SidebarOrderingTests.swift +++ b/cmuxTests/SidebarOrderingTests.swift @@ -5,6 +5,7 @@ import UniformTypeIdentifiers import WebKit import ObjectiveC.runtime import Bonsplit +import CmuxAppKitSupportUI import UserNotifications import Testing From b0c1ca6368bef51cb27a8e24680bb462ea23a275 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Sat, 13 Jun 2026 00:09:21 -0700 Subject: [PATCH 21/31] ContentView drain: lift sidebar drop planner to CmuxFoundation (stack E) Faithfully lifts the pure-logic sidebar drag/drop planning core (SidebarDropEdge, SidebarDropIndicator, SidebarDropPlanner with its nested WorkspaceDropTarget / WorkspaceDropAction) out of Sources/Sidebar into CmuxFoundation/SidebarDrop. Bodies are byte-identical; the only additions are access modifiers, public imports (Foundation/CoreGraphics for the public API), and explicit public inits on the two value structs (synthesized memberwise inits are internal cross-module). Retargets the eight call sites (ContentView, ContentView+MoveTabToNewWorkspace, SidebarBonsplitTabWorkspaceDropOverlay, SidebarWorkspaceGroupHeaderView, TerminalController, and four test files) to the CmuxFoundation import. Removes the app-target pbxproj wiring for the deleted file (4 entries, IDs collision- checked). This also unblocks future lifts of the drop-indicator predicate and the browser-stack drop planner, which depend on these types. Co-Authored-By: Claude Fable 5 --- .github/swift-file-length-budget.tsv | 4 +- .../SidebarDrop/SidebarDropIndicator.swift | 20 ++++++++ .../SidebarDrop}/SidebarDropPlanner.swift | 48 +++++++++---------- .../ContentView+MoveTabToNewWorkspace.swift | 1 + ...debarBonsplitTabWorkspaceDropOverlay.swift | 1 + Sources/SidebarWorkspaceGroupHeaderView.swift | 1 + Sources/TerminalController.swift | 1 + cmux.xcodeproj/project.pbxproj | 4 -- ...idebarTabDropIndicatorPredicateTests.swift | 2 + .../SidebarWorkspaceDropPlannerTests.swift | 2 + cmuxTests/WorkspaceGroupTests.swift | 2 + 11 files changed, 56 insertions(+), 30 deletions(-) create mode 100644 Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarDropIndicator.swift rename {Sources/Sidebar => Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop}/SidebarDropPlanner.swift (92%) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 8fe2df9ce1d..17668a3b80d 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -6,7 +6,7 @@ 18118 Sources/AppDelegate.swift 17761 Sources/ContentView.swift 16675 Sources/GhosttyTerminalView.swift -14623 Sources/TerminalController.swift +14624 Sources/TerminalController.swift 13606 Sources/Panels/BrowserPanel.swift 12044 cmuxTests/AppDelegateShortcutRoutingTests.swift 10020 Sources/TabManager.swift @@ -92,7 +92,7 @@ 944 Sources/App/ShortcutRoutingSupport.swift 937 Sources/TextBoxMentionIndexStore.swift 924 Sources/DockPanelView.swift -913 cmuxTests/WorkspaceGroupTests.swift +915 cmuxTests/WorkspaceGroupTests.swift 905 Sources/CmuxSSHURLRequest.swift 897 Sources/CommandPalette/CommandPaletteSettingsToggle.swift 879 Sources/WorkspaceContentView.swift diff --git a/Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarDropIndicator.swift b/Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarDropIndicator.swift new file mode 100644 index 00000000000..59d215bcfc4 --- /dev/null +++ b/Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarDropIndicator.swift @@ -0,0 +1,20 @@ +public import Foundation + +/// Which edge of a sidebar row a drop indicator is drawn against. +public enum SidebarDropEdge: Equatable { + case top + case bottom +} + +/// Where the sidebar should render the drop indicator during a tab/workspace +/// drag: against the `top` or `bottom` edge of the row identified by `tabId`, +/// or at the end of the list when `tabId` is `nil`. +public struct SidebarDropIndicator: Equatable { + public let tabId: UUID? + public let edge: SidebarDropEdge + + public init(tabId: UUID?, edge: SidebarDropEdge) { + self.tabId = tabId + self.edge = edge + } +} diff --git a/Sources/Sidebar/SidebarDropPlanner.swift b/Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarDropPlanner.swift similarity index 92% rename from Sources/Sidebar/SidebarDropPlanner.swift rename to Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarDropPlanner.swift index d0fa4dfdf26..c8c19119088 100644 --- a/Sources/Sidebar/SidebarDropPlanner.swift +++ b/Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarDropPlanner.swift @@ -1,18 +1,12 @@ -import CoreGraphics -import Foundation +public import CoreGraphics +public import Foundation -enum SidebarDropEdge: Equatable { - case top - case bottom -} - -struct SidebarDropIndicator: Equatable { - let tabId: UUID? - let edge: SidebarDropEdge -} - -enum SidebarDropPlanner { - static func indicator( +/// Pure planner for sidebar tab/workspace drag-and-drop. Computes the drop +/// indicator to render, the final insertion index, cross-window insertion +/// landing, and workspace drop-target hit testing, all clamped to the legal +/// pinned/unpinned regions. No UI or AppKit dependencies. +public enum SidebarDropPlanner { + public static func indicator( draggedTabId: UUID?, targetTabId: UUID?, tabIds: [UUID], @@ -54,7 +48,7 @@ enum SidebarDropPlanner { return indicatorForInsertionPosition(legalInsertionPosition, tabIds: tabIds) } - static func targetIndex( + public static func targetIndex( draggedTabId: UUID, targetTabId: UUID?, indicator: SidebarDropIndicator?, @@ -109,7 +103,7 @@ enum SidebarDropPlanner { /// - pointerY: Pointer y within the hovered row, used to pick the edge. /// - targetHeight: The hovered row's height, paired with `pointerY`. /// - Returns: The clamped insertion index and the indicator to render. - static func crossWindowInsertion( + public static func crossWindowInsertion( targetTabId: UUID?, draggedIsPinned: Bool, indicator: SidebarDropIndicator?, @@ -164,26 +158,32 @@ enum SidebarDropPlanner { return draggedIsPinned ? min(clampedInsertion, pinnedCount) : max(clampedInsertion, pinnedCount) } - struct WorkspaceDropTarget: Equatable { - let workspaceId: UUID - let isPinned: Bool - let frame: CGRect + public struct WorkspaceDropTarget: Equatable { + public let workspaceId: UUID + public let isPinned: Bool + public let frame: CGRect + + public init(workspaceId: UUID, isPinned: Bool, frame: CGRect) { + self.workspaceId = workspaceId + self.isPinned = isPinned + self.frame = frame + } } /// Returns whether sidebar rows should publish frame anchors for workspace drop targeting. - static func shouldCollectWorkspaceDropTargets( + public static func shouldCollectWorkspaceDropTargets( draggedTabId: UUID?, isBonsplitWorkspaceDropActive: Bool = false ) -> Bool { draggedTabId != nil || isBonsplitWorkspaceDropActive } - enum WorkspaceDropAction: Equatable { + public enum WorkspaceDropAction: Equatable { case newWorkspace(insertionIndex: Int, indicator: SidebarDropIndicator) case existingWorkspace(UUID) } - static func workspaceAction( + public static func workspaceAction( for point: CGPoint, targets: [WorkspaceDropTarget] ) -> WorkspaceDropAction? { @@ -308,7 +308,7 @@ enum SidebarDropPlanner { return clampedInsertion } - static func edgeForPointer(locationY: CGFloat, targetHeight: CGFloat) -> SidebarDropEdge { + public static func edgeForPointer(locationY: CGFloat, targetHeight: CGFloat) -> SidebarDropEdge { guard targetHeight > 0 else { return .top } let clampedY = min(max(locationY, 0), targetHeight) return clampedY < (targetHeight / 2) ? .top : .bottom diff --git a/Sources/ContentView+MoveTabToNewWorkspace.swift b/Sources/ContentView+MoveTabToNewWorkspace.swift index 4a7cf21c12f..ea7a1b15549 100644 --- a/Sources/ContentView+MoveTabToNewWorkspace.swift +++ b/Sources/ContentView+MoveTabToNewWorkspace.swift @@ -1,5 +1,6 @@ import CmuxCommandPalette import AppKit +import CmuxFoundation import SwiftUI extension ContentView { diff --git a/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift b/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift index 1e7a5812822..1e2109adcf3 100644 --- a/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift +++ b/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift @@ -1,5 +1,6 @@ import AppKit import Bonsplit +import CmuxFoundation import SwiftUI struct SidebarBonsplitTabWorkspaceDropOverlay: NSViewRepresentable { diff --git a/Sources/SidebarWorkspaceGroupHeaderView.swift b/Sources/SidebarWorkspaceGroupHeaderView.swift index 7f8d0a1fe20..5d32a593b73 100644 --- a/Sources/SidebarWorkspaceGroupHeaderView.swift +++ b/Sources/SidebarWorkspaceGroupHeaderView.swift @@ -1,5 +1,6 @@ import AppKit import CmuxAppKitSupportUI +import CmuxFoundation import SwiftUI /// Collapsible group header that doubles as the anchor workspace row. diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 8053cbaed2a..72756c9c0af 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -2,6 +2,7 @@ import AppKit import CmuxAuthRuntime import CmuxFeedback import CmuxControlSocket +import CmuxFoundation import CmuxSettings import CmuxSocketControl import CmuxSwiftRenderUI diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 47664ec2fb0..182f65410a2 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -589,7 +589,6 @@ 211A75777F433ED8BA5AE208 /* SidebarAppearanceSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 243632BA1DBBA36FD46E0610 /* SidebarAppearanceSupport.swift */; }; D7AB34300000000000000003 /* SidebarBonsplitTabWorkspaceDropOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */; }; EA1F00000000000000000003 /* SidebarDirectoryText.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1F00000000000000000004 /* SidebarDirectoryText.swift */; }; - D7AB34300000000000000001 /* SidebarDropPlanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000002 /* SidebarDropPlanner.swift */; }; B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */; }; 51D800000000000000000001 /* SidebarIdentifierFormattingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D800000000000000000002 /* SidebarIdentifierFormattingTests.swift */; }; 385C6BA7E78DB87460E5D930 /* SidebarMarkdownRendererTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C3F1DBF6BF5D7223C4A30C /* SidebarMarkdownRendererTests.swift */; }; @@ -1388,7 +1387,6 @@ 243632BA1DBBA36FD46E0610 /* SidebarAppearanceSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarAppearanceSupport.swift; sourceTree = ""; }; D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift; sourceTree = ""; }; EA1F00000000000000000004 /* SidebarDirectoryText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarDirectoryText.swift; sourceTree = ""; }; - D7AB34300000000000000002 /* SidebarDropPlanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarDropPlanner.swift; sourceTree = ""; }; B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = ""; }; 51D800000000000000000002 /* SidebarIdentifierFormattingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarIdentifierFormattingTests.swift; sourceTree = ""; }; F1C3F1DBF6BF5D7223C4A30C /* SidebarMarkdownRendererTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarMarkdownRendererTests.swift; sourceTree = ""; }; @@ -1857,7 +1855,6 @@ C3408A000000000000000006 /* ContentView+ViewCommandPalette.swift */, C0DEF0A90000000000000002 /* ContentView+ForkAgentConversation.swift */, D7AB00000000000000000004 /* ContentView+MoveTabToNewWorkspace.swift */, - D7AB34300000000000000002 /* SidebarDropPlanner.swift */, EA1F00000000000000000002 /* SidebarPathFormatter.swift */, EA1F00000000000000000004 /* SidebarDirectoryText.swift */, D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */, @@ -3208,7 +3205,6 @@ 211A75777F433ED8BA5AE208 /* SidebarAppearanceSupport.swift in Sources */, D7AB34300000000000000003 /* SidebarBonsplitTabWorkspaceDropOverlay.swift in Sources */, EA1F00000000000000000003 /* SidebarDirectoryText.swift in Sources */, - D7AB34300000000000000001 /* SidebarDropPlanner.swift in Sources */, EA1F00000000000000000001 /* SidebarPathFormatter.swift in Sources */, C0DEF0A30000000000000001 /* SidebarPortDisplayText.swift in Sources */, C0DE35010000000000000001 /* SidebarScrim.swift in Sources */, diff --git a/cmuxTests/SidebarTabDropIndicatorPredicateTests.swift b/cmuxTests/SidebarTabDropIndicatorPredicateTests.swift index b25ed1b7515..7600a7bcb22 100644 --- a/cmuxTests/SidebarTabDropIndicatorPredicateTests.swift +++ b/cmuxTests/SidebarTabDropIndicatorPredicateTests.swift @@ -1,5 +1,7 @@ import XCTest +import CmuxFoundation + #if canImport(cmux_DEV) @testable import cmux_DEV #elseif canImport(cmux) diff --git a/cmuxTests/SidebarWorkspaceDropPlannerTests.swift b/cmuxTests/SidebarWorkspaceDropPlannerTests.swift index 6b7a5fd6983..57310c9a434 100644 --- a/cmuxTests/SidebarWorkspaceDropPlannerTests.swift +++ b/cmuxTests/SidebarWorkspaceDropPlannerTests.swift @@ -1,6 +1,8 @@ import CoreGraphics import XCTest +import CmuxFoundation + #if canImport(cmux_DEV) @testable import cmux_DEV #elseif canImport(cmux) diff --git a/cmuxTests/WorkspaceGroupTests.swift b/cmuxTests/WorkspaceGroupTests.swift index bd02582aa6d..89e55ccd408 100644 --- a/cmuxTests/WorkspaceGroupTests.swift +++ b/cmuxTests/WorkspaceGroupTests.swift @@ -1,6 +1,8 @@ import Foundation import Testing +import CmuxFoundation + #if canImport(cmux_DEV) @testable import cmux_DEV #elseif canImport(cmux) From 67204287eca60352248a8b0e9518f997acbb2de3 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Sat, 13 Jun 2026 00:12:07 -0700 Subject: [PATCH 22/31] ContentView drain: lift sidebar tab drop-indicator predicate to CmuxFoundation (stack E) Faithfully lifts SidebarTabDropIndicatorPredicate (topVisible / emptyAreaTopVisible) out of ContentView.swift into CmuxFoundation/SidebarDrop, alongside the drop planner it shares SidebarDropIndicator/SidebarDropEdge with. Bodies are byte-identical; the only additions are access modifiers and a public Foundation import. Retargets VerticalTabsSidebar+WorkspaceGroups to the CmuxFoundation import (ContentView and the two test files already import it). ContentView 17761 -> 17723 lines. Co-Authored-By: Claude Fable 5 --- .github/swift-file-length-budget.tsv | 2 +- .../SidebarTabDropIndicatorPredicate.swift | 41 +++++++++++++++++++ Sources/ContentView.swift | 38 ----------------- .../VerticalTabsSidebar+WorkspaceGroups.swift | 1 + 4 files changed, 43 insertions(+), 39 deletions(-) create mode 100644 Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarTabDropIndicatorPredicate.swift diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 17668a3b80d..93bb00b3ec9 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -4,7 +4,7 @@ 33285 CLI/cmux.swift 19991 Sources/Workspace.swift 18118 Sources/AppDelegate.swift -17761 Sources/ContentView.swift +17723 Sources/ContentView.swift 16675 Sources/GhosttyTerminalView.swift 14624 Sources/TerminalController.swift 13606 Sources/Panels/BrowserPanel.swift diff --git a/Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarTabDropIndicatorPredicate.swift b/Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarTabDropIndicatorPredicate.swift new file mode 100644 index 00000000000..ec4f2e43f0f --- /dev/null +++ b/Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarTabDropIndicatorPredicate.swift @@ -0,0 +1,41 @@ +public import Foundation + +/// Pure predicates deciding when a sidebar row (or the empty area below all +/// rows) should render its "top" drop indicator for a given drag state. +public enum SidebarTabDropIndicatorPredicate { + public static func topVisible( + forTabId tabId: UUID, + draggedTabId: UUID?, + dropIndicator: SidebarDropIndicator?, + tabIds: [UUID] + ) -> Bool { + guard draggedTabId != nil, let indicator = dropIndicator else { return false } + if indicator.tabId == tabId && indicator.edge == .top { + return true + } + guard indicator.edge == .bottom, + let currentIndex = tabIds.firstIndex(of: tabId), + currentIndex > 0 + else { + return false + } + return tabIds[currentIndex - 1] == indicator.tabId + } + + /// Convenience used by `SidebarEmptyArea`: the empty area's "top" indicator + /// (drawn above the empty space below all rows) is visible when the drop + /// indicator targets nothing (end-of-list) or the bottom edge of the last + /// row. + public static func emptyAreaTopVisible( + draggedTabId: UUID?, + dropIndicator: SidebarDropIndicator?, + lastTabId: UUID? + ) -> Bool { + guard draggedTabId != nil, let indicator = dropIndicator else { return false } + if indicator.tabId == nil { + return true + } + guard indicator.edge == .bottom, let lastTabId else { return false } + return indicator.tabId == lastTabId + } +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 915e9290532..cd15cc926b2 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -10237,44 +10237,6 @@ enum SidebarDragStateRegistry { /// trivially unit-testable and the row's view subtree never reads the /// `@Observable` store directly. Same predicate that used to live inside /// `SidebarTabDropIndicatorOverlay`. -enum SidebarTabDropIndicatorPredicate { - static func topVisible( - forTabId tabId: UUID, - draggedTabId: UUID?, - dropIndicator: SidebarDropIndicator?, - tabIds: [UUID] - ) -> Bool { - guard draggedTabId != nil, let indicator = dropIndicator else { return false } - if indicator.tabId == tabId && indicator.edge == .top { - return true - } - guard indicator.edge == .bottom, - let currentIndex = tabIds.firstIndex(of: tabId), - currentIndex > 0 - else { - return false - } - return tabIds[currentIndex - 1] == indicator.tabId - } - - /// Convenience used by `SidebarEmptyArea`: the empty area's "top" indicator - /// (drawn above the empty space below all rows) is visible when the drop - /// indicator targets nothing (end-of-list) or the bottom edge of the last - /// row. - static func emptyAreaTopVisible( - draggedTabId: UUID?, - dropIndicator: SidebarDropIndicator?, - lastTabId: UUID? - ) -> Bool { - guard draggedTabId != nil, let indicator = dropIndicator else { return false } - if indicator.tabId == nil { - return true - } - guard indicator.edge == .bottom, let lastTabId else { return false } - return indicator.tabId == lastTabId - } -} - struct SidebarWorkspaceTopDropIndicator: View { let isVisible: Bool let isFirstRow: Bool diff --git a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift index 081132ac864..89c350b2758 100644 --- a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift +++ b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxFoundation import SwiftUI extension VerticalTabsSidebar { From ec97502dab5e6dedc39101456a880c2d6bfdf3bb Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Sat, 13 Jun 2026 00:15:57 -0700 Subject: [PATCH 23/31] ContentView drain: lift browser-stack drop planner to CmuxSidebarProviderKit (stack E) Faithfully lifts ExtensionSidebarBrowserStackDropRow and ExtensionSidebarBrowserStackDropPlanner (move / preferredSectionId / sectionBoundaryIndicator) out of ContentView.swift into CmuxSidebarProviderKit, the package that already owns CmuxSidebarProviderWorkspaceMove. Adds a CmuxFoundation path dependency to CmuxSidebarProviderKit so the planner can use the lifted SidebarDropIndicator/SidebarDropEdge/SidebarDropPlanner types. Bodies are byte-identical; the only additions are access modifiers, the CmuxFoundation/CoreGraphics imports for the public API, and an explicit public init on the row struct. The SwiftUI ExtensionSidebarBrowserStackDropDelegate stays in ContentView (view-layer). Retargets the planner test to import CmuxSidebarProviderKit; ContentView already imports it. ContentView 17723 -> 17623 lines. Co-Authored-By: Claude Fable 5 --- .github/swift-file-length-budget.tsv | 2 +- Packages/CmuxSidebarProviderKit/Package.swift | 6 +- ...ensionSidebarBrowserStackDropPlanner.swift | 102 ++++++++++++++++++ .../ExtensionSidebarBrowserStackDropRow.swift | 14 +++ Sources/ContentView.swift | 100 ----------------- .../SidebarWorkspaceDropPlannerTests.swift | 1 + 6 files changed, 123 insertions(+), 102 deletions(-) create mode 100644 Packages/CmuxSidebarProviderKit/Sources/CmuxSidebarProviderKit/Mutations/ExtensionSidebarBrowserStackDropPlanner.swift create mode 100644 Packages/CmuxSidebarProviderKit/Sources/CmuxSidebarProviderKit/Mutations/ExtensionSidebarBrowserStackDropRow.swift diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 93bb00b3ec9..206ba900e1b 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -4,7 +4,7 @@ 33285 CLI/cmux.swift 19991 Sources/Workspace.swift 18118 Sources/AppDelegate.swift -17723 Sources/ContentView.swift +17623 Sources/ContentView.swift 16675 Sources/GhosttyTerminalView.swift 14624 Sources/TerminalController.swift 13606 Sources/Panels/BrowserPanel.swift diff --git a/Packages/CmuxSidebarProviderKit/Package.swift b/Packages/CmuxSidebarProviderKit/Package.swift index 46e4bf49bf5..bab5b6b89f2 100644 --- a/Packages/CmuxSidebarProviderKit/Package.swift +++ b/Packages/CmuxSidebarProviderKit/Package.swift @@ -12,9 +12,13 @@ let package = Package( targets: ["CmuxSidebarProviderKit"] ), ], + dependencies: [ + .package(path: "../CmuxFoundation"), + ], targets: [ .target( - name: "CmuxSidebarProviderKit" + name: "CmuxSidebarProviderKit", + dependencies: ["CmuxFoundation"] ), ] ) diff --git a/Packages/CmuxSidebarProviderKit/Sources/CmuxSidebarProviderKit/Mutations/ExtensionSidebarBrowserStackDropPlanner.swift b/Packages/CmuxSidebarProviderKit/Sources/CmuxSidebarProviderKit/Mutations/ExtensionSidebarBrowserStackDropPlanner.swift new file mode 100644 index 00000000000..76591970aa8 --- /dev/null +++ b/Packages/CmuxSidebarProviderKit/Sources/CmuxSidebarProviderKit/Mutations/ExtensionSidebarBrowserStackDropPlanner.swift @@ -0,0 +1,102 @@ +import CmuxFoundation +import CoreGraphics +import Foundation + +/// Pure planner for browser-stack sidebar drag/drop: resolves the target +/// section + index for a workspace move, the preferred target section under an +/// indicator, and the section-boundary indicator to render while dragging +/// across sections. +public enum ExtensionSidebarBrowserStackDropPlanner { + public static func move( + draggedWorkspaceId: UUID, + insertionPosition: Int, + orderedRows: [ExtensionSidebarBrowserStackDropRow], + preferredTargetSectionId: String? = nil + ) -> CmuxSidebarProviderWorkspaceMove? { + guard let sourceIndex = orderedRows.firstIndex(where: { $0.workspaceId == draggedWorkspaceId }) else { + return nil + } + let sourceRow = orderedRows[sourceIndex] + let remainingRows = orderedRows.filter { $0.workspaceId != draggedWorkspaceId } + guard !remainingRows.isEmpty else { return nil } + let adjustedInsertionPosition = insertionPosition > sourceIndex + ? insertionPosition - 1 + : insertionPosition + let clampedInsertionPosition = min(max(adjustedInsertionPosition, 0), remainingRows.count) + + let targetSectionId: String + let targetIndex: Int + if let preferredTargetSectionId { + targetSectionId = preferredTargetSectionId + targetIndex = remainingRows[.. String? { + guard let targetIndex = orderedRows.firstIndex(where: { $0.workspaceId == targetWorkspaceId }) else { + return nil + } + let targetRow = orderedRows[targetIndex] + guard let indicator, + let indicatorWorkspaceId = indicator.tabId, + let indicatorIndex = orderedRows.firstIndex(where: { $0.workspaceId == indicatorWorkspaceId }) else { + return targetRow.sectionId + } + if indicatorWorkspaceId == targetWorkspaceId { + return targetRow.sectionId + } + if indicator.edge == .top, indicatorIndex == targetIndex + 1 { + return targetRow.sectionId + } + return orderedRows[indicatorIndex].sectionId + } + + public static func sectionBoundaryIndicator( + draggedWorkspaceId: UUID?, + targetWorkspaceId: UUID, + pointerY: CGFloat?, + targetHeight: CGFloat?, + orderedRows: [ExtensionSidebarBrowserStackDropRow] + ) -> SidebarDropIndicator? { + guard let draggedWorkspaceId, + let sourceIndex = orderedRows.firstIndex(where: { $0.workspaceId == draggedWorkspaceId }), + let targetIndex = orderedRows.firstIndex(where: { $0.workspaceId == targetWorkspaceId }), + orderedRows[sourceIndex].sectionId != orderedRows[targetIndex].sectionId else { + return nil + } + let edge: SidebarDropEdge + if let pointerY, let targetHeight { + edge = SidebarDropPlanner.edgeForPointer(locationY: pointerY, targetHeight: targetHeight) + } else { + edge = sourceIndex < targetIndex ? .top : .bottom + } + if sourceIndex + 1 == targetIndex, edge == .top { + return SidebarDropIndicator(tabId: targetWorkspaceId, edge: .top) + } + if targetIndex + 1 == sourceIndex, edge == .bottom { + return SidebarDropIndicator(tabId: targetWorkspaceId, edge: .bottom) + } + return nil + } +} diff --git a/Packages/CmuxSidebarProviderKit/Sources/CmuxSidebarProviderKit/Mutations/ExtensionSidebarBrowserStackDropRow.swift b/Packages/CmuxSidebarProviderKit/Sources/CmuxSidebarProviderKit/Mutations/ExtensionSidebarBrowserStackDropRow.swift new file mode 100644 index 00000000000..e9cc63dfb81 --- /dev/null +++ b/Packages/CmuxSidebarProviderKit/Sources/CmuxSidebarProviderKit/Mutations/ExtensionSidebarBrowserStackDropRow.swift @@ -0,0 +1,14 @@ +import Foundation + +/// An ordered browser-stack sidebar row, identified by its workspace and the +/// section it currently belongs to. Used by +/// `ExtensionSidebarBrowserStackDropPlanner` to compute cross-section drag moves. +public struct ExtensionSidebarBrowserStackDropRow: Equatable { + public let workspaceId: UUID + public let sectionId: String + + public init(workspaceId: UUID, sectionId: String) { + self.workspaceId = workspaceId + self.sectionId = sectionId + } +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index cd15cc926b2..ce40e5b5655 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -16943,106 +16943,6 @@ struct SidebarTabDropDelegate: DropDelegate { } } -struct ExtensionSidebarBrowserStackDropRow: Equatable { - let workspaceId: UUID - let sectionId: String -} - -enum ExtensionSidebarBrowserStackDropPlanner { - static func move( - draggedWorkspaceId: UUID, - insertionPosition: Int, - orderedRows: [ExtensionSidebarBrowserStackDropRow], - preferredTargetSectionId: String? = nil - ) -> CmuxSidebarProviderWorkspaceMove? { - guard let sourceIndex = orderedRows.firstIndex(where: { $0.workspaceId == draggedWorkspaceId }) else { - return nil - } - let sourceRow = orderedRows[sourceIndex] - let remainingRows = orderedRows.filter { $0.workspaceId != draggedWorkspaceId } - guard !remainingRows.isEmpty else { return nil } - let adjustedInsertionPosition = insertionPosition > sourceIndex - ? insertionPosition - 1 - : insertionPosition - let clampedInsertionPosition = min(max(adjustedInsertionPosition, 0), remainingRows.count) - - let targetSectionId: String - let targetIndex: Int - if let preferredTargetSectionId { - targetSectionId = preferredTargetSectionId - targetIndex = remainingRows[.. String? { - guard let targetIndex = orderedRows.firstIndex(where: { $0.workspaceId == targetWorkspaceId }) else { - return nil - } - let targetRow = orderedRows[targetIndex] - guard let indicator, - let indicatorWorkspaceId = indicator.tabId, - let indicatorIndex = orderedRows.firstIndex(where: { $0.workspaceId == indicatorWorkspaceId }) else { - return targetRow.sectionId - } - if indicatorWorkspaceId == targetWorkspaceId { - return targetRow.sectionId - } - if indicator.edge == .top, indicatorIndex == targetIndex + 1 { - return targetRow.sectionId - } - return orderedRows[indicatorIndex].sectionId - } - - static func sectionBoundaryIndicator( - draggedWorkspaceId: UUID?, - targetWorkspaceId: UUID, - pointerY: CGFloat?, - targetHeight: CGFloat?, - orderedRows: [ExtensionSidebarBrowserStackDropRow] - ) -> SidebarDropIndicator? { - guard let draggedWorkspaceId, - let sourceIndex = orderedRows.firstIndex(where: { $0.workspaceId == draggedWorkspaceId }), - let targetIndex = orderedRows.firstIndex(where: { $0.workspaceId == targetWorkspaceId }), - orderedRows[sourceIndex].sectionId != orderedRows[targetIndex].sectionId else { - return nil - } - let edge: SidebarDropEdge - if let pointerY, let targetHeight { - edge = SidebarDropPlanner.edgeForPointer(locationY: pointerY, targetHeight: targetHeight) - } else { - edge = sourceIndex < targetIndex ? .top : .bottom - } - if sourceIndex + 1 == targetIndex, edge == .top { - return SidebarDropIndicator(tabId: targetWorkspaceId, edge: .top) - } - if targetIndex + 1 == sourceIndex, edge == .bottom { - return SidebarDropIndicator(tabId: targetWorkspaceId, edge: .bottom) - } - return nil - } -} - private struct ExtensionSidebarBrowserStackDropDelegate: DropDelegate { let targetWorkspaceId: UUID let orderedRows: [ExtensionSidebarBrowserStackDropRow] diff --git a/cmuxTests/SidebarWorkspaceDropPlannerTests.swift b/cmuxTests/SidebarWorkspaceDropPlannerTests.swift index 57310c9a434..3d85f94f587 100644 --- a/cmuxTests/SidebarWorkspaceDropPlannerTests.swift +++ b/cmuxTests/SidebarWorkspaceDropPlannerTests.swift @@ -2,6 +2,7 @@ import CoreGraphics import XCTest import CmuxFoundation +import CmuxSidebarProviderKit #if canImport(cmux_DEV) @testable import cmux_DEV From 307fcfa76fbbcb1f24098e5c99d86fd6f4d85c3f Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Sat, 13 Jun 2026 00:18:59 -0700 Subject: [PATCH 24/31] ContentView drain: lift sidebar drag-lifecycle policies to CmuxFoundation (stack E) Faithfully lifts the pure-logic sidebar drag-lifecycle policies (SidebarShortcutHintFreezePolicy, SidebarOutsideDropResetPolicy, SidebarDragFailsafePolicy) out of ContentView.swift into CmuxFoundation/SidebarDrop. Bodies are byte-identical; the only additions are access modifiers and the public AppKit/Foundation imports (the failsafe policy classifies NSEvent.EventType). The @MainActor SidebarDragFailsafeMonitor stays in ContentView (AppKit event-monitor view layer). ContentView and the existing test already import CmuxFoundation. ContentView 17623 -> 17584 lines. Co-Authored-By: Claude Fable 5 --- .github/swift-file-length-budget.tsv | 2 +- .../SidebarDragLifecyclePolicies.swift | 47 +++++++++++++++++++ Sources/ContentView.swift | 39 --------------- 3 files changed, 48 insertions(+), 40 deletions(-) create mode 100644 Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarDragLifecyclePolicies.swift diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 206ba900e1b..f04749e4a2a 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -4,7 +4,7 @@ 33285 CLI/cmux.swift 19991 Sources/Workspace.swift 18118 Sources/AppDelegate.swift -17623 Sources/ContentView.swift +17584 Sources/ContentView.swift 16675 Sources/GhosttyTerminalView.swift 14624 Sources/TerminalController.swift 13606 Sources/Panels/BrowserPanel.swift diff --git a/Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarDragLifecyclePolicies.swift b/Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarDragLifecyclePolicies.swift new file mode 100644 index 00000000000..4364d9245ce --- /dev/null +++ b/Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarDragLifecyclePolicies.swift @@ -0,0 +1,47 @@ +public import AppKit +public import Foundation + +/// Decides whether a sidebar row's shortcut-hint visibility should use the +/// frozen value captured for a specific tab, or fall back to the live value. +public enum SidebarShortcutHintFreezePolicy { + public static func resolved( + live: Bool, + currentTabId: UUID, + frozenTabId: UUID?, + frozenValue: Bool + ) -> Bool { + if frozenTabId == currentTabId { + return frozenValue + } + return live + } +} + +/// Whether an in-flight sidebar drag should be reset when a drop lands outside +/// the sidebar. +public enum SidebarOutsideDropResetPolicy { + public static func shouldResetDrag(draggedTabId: UUID?, hasSidebarDragPayload: Bool) -> Bool { + draggedTabId != nil && hasSidebarDragPayload + } +} + +/// Failsafe rules for clearing a stuck sidebar drag (mouse released outside a +/// drop target, app resigned active, escape pressed). +public enum SidebarDragFailsafePolicy { + public static let clearDelay: TimeInterval = 0.15 + + public static func shouldRequestClear(isDragActive: Bool, isLeftMouseButtonDown: Bool) -> Bool { + isDragActive && !isLeftMouseButtonDown + } + + public static func shouldRequestClearWhenMonitoringStarts(isLeftMouseButtonDown: Bool) -> Bool { + shouldRequestClear( + isDragActive: true, + isLeftMouseButtonDown: isLeftMouseButtonDown + ) + } + + public static func shouldRequestClear(forMouseEventType eventType: NSEvent.EventType) -> Bool { + eventType == .leftMouseUp + } +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index ce40e5b5655..0d7d9cbf2b3 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -10257,20 +10257,6 @@ struct SidebarWorkspaceTopDropIndicator: View { /// so pressing/releasing the modifier key while the menu is up does not flip /// the underlying row's shortcut badges (which would be visible around the /// open context menu). All other rows transition live. -enum SidebarShortcutHintFreezePolicy { - static func resolved( - live: Bool, - currentTabId: UUID, - frozenTabId: UUID?, - frozenValue: Bool - ) -> Bool { - if frozenTabId == currentTabId { - return frozenValue - } - return live - } -} - struct VerticalTabsSidebar: View { var updateViewModel: UpdateStateModel @ObservedObject var fileExplorerState: FileExplorerState @@ -12621,31 +12607,6 @@ enum SidebarDragLifecycleNotification { } } -enum SidebarOutsideDropResetPolicy { - static func shouldResetDrag(draggedTabId: UUID?, hasSidebarDragPayload: Bool) -> Bool { - draggedTabId != nil && hasSidebarDragPayload - } -} - -enum SidebarDragFailsafePolicy { - static let clearDelay: TimeInterval = 0.15 - - static func shouldRequestClear(isDragActive: Bool, isLeftMouseButtonDown: Bool) -> Bool { - isDragActive && !isLeftMouseButtonDown - } - - static func shouldRequestClearWhenMonitoringStarts(isLeftMouseButtonDown: Bool) -> Bool { - shouldRequestClear( - isDragActive: true, - isLeftMouseButtonDown: isLeftMouseButtonDown - ) - } - - static func shouldRequestClear(forMouseEventType eventType: NSEvent.EventType) -> Bool { - eventType == .leftMouseUp - } -} - @MainActor private final class SidebarDragFailsafeMonitor: ObservableObject { private static let escapeKeyCode: UInt16 = 53 From cec299521750a635ee56fbf897d18697f2058bff Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Sat, 13 Jun 2026 00:27:25 -0700 Subject: [PATCH 25/31] ContentView drain: wire CmuxSidebarProviderKit into cmuxTests target (stack E CI fix) The browser-stack drop-planner lift retargeted SidebarWorkspaceDropPlannerTests to `import CmuxSidebarProviderKit`, but the cmuxTests target did not link that package, so the test target failed to compile on CI (the local app-scheme build does not compile the test target, so it was not caught locally). Mirrors the existing CmuxFoundation test-target wiring: adds a test-scoped XCSwiftPackageProductDependency for CmuxSidebarProviderKit, its Frameworks build-file, the Frameworks-phase entry, and the packageProductDependencies entry. pbxproj normalized; new IDs collision-checked. Co-Authored-By: Claude Fable 5 --- cmux.xcodeproj/project.pbxproj | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 182f65410a2..f2516c6fe58 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -229,6 +229,7 @@ C5A1FED200000000000000E1 /* CmuxSidebarInterpreterClient in Frameworks */ = {isa = PBXBuildFile; productRef = C5A1FED200000000000000E3 /* CmuxSidebarInterpreterClient */; }; C5A1FED200000000000000E2 /* CmuxSidebarInterpreterClient in Frameworks */ = {isa = PBXBuildFile; productRef = C5A1FED200000000000000E3 /* CmuxSidebarInterpreterClient */; }; C0DE4A000000000000000001 /* CmuxSidebarProviderKit in Frameworks */ = {isa = PBXBuildFile; productRef = C0DE4A000000000000000002 /* CmuxSidebarProviderKit */; }; + C0DE4A000000000000000005 /* CmuxSidebarProviderKit in Frameworks */ = {isa = PBXBuildFile; productRef = C0DE4A000000000000000004 /* CmuxSidebarProviderKit */; }; C5A1FED200000000000000E5 /* CmuxSidebarRemoteRender in Frameworks */ = {isa = PBXBuildFile; productRef = C5A1FED200000000000000E6 /* CmuxSidebarRemoteRender */; }; A5354303A5354303A5354303 /* CmuxSocketControl in Frameworks */ = {isa = PBXBuildFile; productRef = A5354305A5354305A5354305 /* CmuxSocketControl */; }; B900004CA1B2C3D4E5F60719 /* CmuxSocketControl in Frameworks */ = {isa = PBXBuildFile; productRef = A5354305A5354305A5354305 /* CmuxSocketControl */; }; @@ -1671,6 +1672,7 @@ C9A1C00000000000000000A4 /* CmuxCommandPalette in Frameworks */, 5E2701040000000000000004 /* CmuxFoundation in Frameworks */, EFB18E3C0000000000000001 /* CMUXMobileCore in Frameworks */, + C0DE4A000000000000000005 /* CmuxSidebarProviderKit in Frameworks */, C52830000000000000000002 /* CmuxTerminalCopyMode in Frameworks */, 5E2701040000000000000003 /* Sentry in Frameworks */, ); @@ -2645,6 +2647,7 @@ 5E2701040000000000000002 /* CmuxFoundation */, C9A2B00000000000000000C2 /* CmuxAppKitSupportUI */, C9A1C00000000000000000A5 /* CmuxCommandPalette */, + C0DE4A000000000000000004 /* CmuxSidebarProviderKit */, ); productName = cmuxTests; productReference = F1000002A1B2C3D4E5F60718 /* cmuxTests.xctest */; @@ -4232,6 +4235,11 @@ package = C0DE4A000000000000000003 /* XCLocalSwiftPackageReference "CmuxSidebarProviderKit" */; productName = CmuxSidebarProviderKit; }; + C0DE4A000000000000000004 /* CmuxSidebarProviderKit */ = { + isa = XCSwiftPackageProductDependency; + package = C0DE4A000000000000000003 /* XCLocalSwiftPackageReference "CmuxSidebarProviderKit" */; + productName = CmuxSidebarProviderKit; + }; 3069F1D10000000000000006 /* CMUXPasteboardFidelity */ = { isa = XCSwiftPackageProductDependency; package = 3069F1D10000000000000007 /* XCLocalSwiftPackageReference "CMUXPasteboardFidelity" */; From 57df08380f06ac4bc73c7ae6d22e7e326b290cc3 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Sat, 13 Jun 2026 00:37:26 -0700 Subject: [PATCH 26/31] ContentView drain: import CmuxFoundation in two lifted-type test consumers (stack E CI fix) Two test files referenced types that earlier tranches lifted into CmuxFoundation but were not in the retarget set, so the cmuxTests target failed to compile on test-depot (the local app-scheme build does not compile the test target): - SidebarOrderingTests.swift uses SidebarDropPlanner / SidebarDropIndicator (lifted in the drop-planner tranche) but only imported CmuxAppKitSupportUI. - SessionPersistenceTests.swift uses SidebarDragFailsafePolicy (lifted in the drag-lifecycle-policies tranche) with no CmuxFoundation import. Adds the CmuxFoundation import to both. A full sweep of every lifted public type against all cmuxTests and Sources consumers now reports zero missing owning-package imports. CmuxFoundation is already linked into the cmuxTests target, so no further pbxproj wiring is needed. Co-Authored-By: Claude Fable 5 --- .github/swift-file-length-budget.tsv | 4 ++-- cmuxTests/SessionPersistenceTests.swift | 1 + cmuxTests/SidebarOrderingTests.swift | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 8faa7971f95..17b5b2ab315 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -15,7 +15,7 @@ 7292 cmuxTests/WorkspaceUnitTests.swift 6948 cmuxTests/WorkspaceRemoteConnectionTests.swift 6543 cmuxTests/GhosttyConfigTests.swift -6329 cmuxTests/SessionPersistenceTests.swift +6330 cmuxTests/SessionPersistenceTests.swift 6255 cmuxTests/TerminalAndGhosttyTests.swift 6153 CLI/cmux_open.swift 6071 Sources/TextBoxInput.swift @@ -73,7 +73,7 @@ 1216 Packages/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteSearchEngineTests.swift 1205 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift 1197 cmuxTests/CodexAppServerSessionTests.swift -1157 cmuxTests/SidebarOrderingTests.swift +1158 cmuxTests/SidebarOrderingTests.swift 1144 Sources/VaultAgentProcessScanner.swift 1139 cmuxTests/PiVaultAgentPersistenceTests.swift 1126 cmuxTests/FileExplorerStoreTests.swift diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 6ffe59caaff..3708902277b 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -1,4 +1,5 @@ import CMUXAgentLaunch +import CmuxFoundation import Darwin import XCTest diff --git a/cmuxTests/SidebarOrderingTests.swift b/cmuxTests/SidebarOrderingTests.swift index c967f222925..595e9c47950 100644 --- a/cmuxTests/SidebarOrderingTests.swift +++ b/cmuxTests/SidebarOrderingTests.swift @@ -6,6 +6,7 @@ import WebKit import ObjectiveC.runtime import Bonsplit import CmuxAppKitSupportUI +import CmuxFoundation import UserNotifications import Testing From 1675a292625e94ddff236eeec67bdeb2f7bc8996 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Sat, 13 Jun 2026 00:44:09 -0700 Subject: [PATCH 27/31] ContentView drain: lift SidebarFeedbackComposerSheet to CmuxFeedbackUI Move the 382-line feedback composer modal out of ContentView into CmuxFeedbackUI as a public View. Body ported verbatim; the only changes are the expected module-boundary ones: public struct + public init(), `error: any Error` (package enables ExistentialAny), and `bundle: .module` on every String(localized:) so the 27 sidebar.help.feedback.* keys resolve from the package catalog. Localization preserved faithfully: the 27 keys (en/ja/ko/uk) are copied into a new package Localizable.xcstrings; Package.swift gains defaultLocalization "en" + resources processing + a CmuxFeedback dep. FeedbackComposerAttachment gains Sendable (immutable value type with all-Sendable stored properties) so the attachments array can cross into the nonisolated FeedbackComposerClient.submit boundary that the package's strict isolation surfaces. ContentView 17544 -> 17162. Co-Authored-By: Claude Fable 5 --- .github/swift-file-length-budget.tsv | 2 +- .../Models/FeedbackComposerAttachment.swift | 2 +- Packages/CmuxFeedbackUI/Package.swift | 10 + .../SidebarFeedbackComposerSheet.swift | 417 +++++++++ .../Resources/Localizable.xcstrings | 789 ++++++++++++++++++ Sources/ContentView.swift | 382 --------- 6 files changed, 1218 insertions(+), 384 deletions(-) create mode 100644 Packages/CmuxFeedbackUI/Sources/CmuxFeedbackUI/Composer/SidebarFeedbackComposerSheet.swift create mode 100644 Packages/CmuxFeedbackUI/Sources/CmuxFeedbackUI/Resources/Localizable.xcstrings diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 17b5b2ab315..b5d42fbcbf9 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -4,7 +4,7 @@ 33285 CLI/cmux.swift 19991 Sources/Workspace.swift 18118 Sources/AppDelegate.swift -17544 Sources/ContentView.swift +17162 Sources/ContentView.swift 16675 Sources/GhosttyTerminalView.swift 14624 Sources/TerminalController.swift 13606 Sources/Panels/BrowserPanel.swift diff --git a/Packages/CmuxFeedback/Sources/CmuxFeedback/Models/FeedbackComposerAttachment.swift b/Packages/CmuxFeedback/Sources/CmuxFeedback/Models/FeedbackComposerAttachment.swift index 118c6b21d61..ed8050d3f9a 100644 --- a/Packages/CmuxFeedback/Sources/CmuxFeedback/Models/FeedbackComposerAttachment.swift +++ b/Packages/CmuxFeedback/Sources/CmuxFeedback/Models/FeedbackComposerAttachment.swift @@ -2,7 +2,7 @@ public import Foundation /// A user-selected file to attach to a feedback submission, carrying the /// resolved name, size, and MIME type read from the URL's resource values. -public struct FeedbackComposerAttachment: Identifiable { +public struct FeedbackComposerAttachment: Identifiable, Sendable { public let id = UUID() public let url: URL public let fileName: String diff --git a/Packages/CmuxFeedbackUI/Package.swift b/Packages/CmuxFeedbackUI/Package.swift index 76faaeb42ae..7f62111cf19 100644 --- a/Packages/CmuxFeedbackUI/Package.swift +++ b/Packages/CmuxFeedbackUI/Package.swift @@ -4,6 +4,7 @@ import PackageDescription let package = Package( name: "CmuxFeedbackUI", + defaultLocalization: "en", platforms: [ .macOS(.v14), ], @@ -13,9 +14,18 @@ let package = Package( targets: ["CmuxFeedbackUI"] ), ], + dependencies: [ + .package(path: "../CmuxFeedback"), + ], targets: [ .target( name: "CmuxFeedbackUI", + dependencies: [ + .product(name: "CmuxFeedback", package: "CmuxFeedback"), + ], + resources: [ + .process("Resources"), + ], swiftSettings: [ .swiftLanguageMode(.v6), .enableUpcomingFeature("ExistentialAny"), diff --git a/Packages/CmuxFeedbackUI/Sources/CmuxFeedbackUI/Composer/SidebarFeedbackComposerSheet.swift b/Packages/CmuxFeedbackUI/Sources/CmuxFeedbackUI/Composer/SidebarFeedbackComposerSheet.swift new file mode 100644 index 00000000000..80da173d866 --- /dev/null +++ b/Packages/CmuxFeedbackUI/Sources/CmuxFeedbackUI/Composer/SidebarFeedbackComposerSheet.swift @@ -0,0 +1,417 @@ +public import SwiftUI +import AppKit +import CmuxFeedback + +/// Modal feedback composer presented from the sidebar help menu. +/// +/// Collects an email plus message and optional image attachments, submits them +/// through ``FeedbackComposerClient``, and renders submission/success/error +/// state inline. Self-contained: it owns all of its transient `@State` and only +/// depends on the `CmuxFeedback` domain package. +public struct SidebarFeedbackComposerSheet: View { + private static let formMaxHeight: CGFloat = 560 + + @AppStorage(FeedbackComposerSettings.storedEmailKey) private var email = "" + @Environment(\.dismiss) private var dismiss + + @State private var message = "" + @State private var attachments: [FeedbackComposerAttachment] = [] + @State private var isSubmitting = false + @State private var submissionErrorMessage: String? + @State private var didSend = false + + /// Explicit public initializer: the implicit memberwise init is not visible + /// across the module boundary now that this type lives in a package. + public init() {} + + private var trimmedMessage: String { + message.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var canSubmit: Bool { + isValidEmail(email) && + !trimmedMessage.isEmpty && + message.count <= FeedbackComposerSettings.maxMessageLength && + !isSubmitting && + !didSend + } + + public var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(String(localized: "sidebar.help.feedback.title", defaultValue: "Send Feedback", bundle: .module)) + .font(.title3.weight(.semibold)) + + if didSend { + successView + } else { + ScrollView { + formView + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.trailing, 4) + } + .frame(maxHeight: Self.formMaxHeight) + } + } + .padding(20) + .frame(width: 520) + .accessibilityIdentifier("SidebarFeedbackDialog") + } + + private var successView: some View { + VStack(alignment: .leading, spacing: 12) { + Text(String(localized: "sidebar.help.feedback.successTitle", defaultValue: "Thanks for the feedback.", bundle: .module)) + .font(.headline) + Text( + String( + localized: "sidebar.help.feedback.successBody", + defaultValue: "You can also reach us at founders@manaflow.com.", + bundle: .module + ) + ) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + + HStack { + Spacer() + Button(String(localized: "sidebar.help.feedback.done", defaultValue: "Done", bundle: .module)) { + dismiss() + } + .keyboardShortcut(.defaultAction) + } + } + } + + private var formView: some View { + VStack(alignment: .leading, spacing: 14) { + Text( + String( + localized: "sidebar.help.feedback.note", + defaultValue: "A human will read this! You can also reach us at founders@manaflow.com.", + bundle: .module + ) + ) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 6) { + Text(String(localized: "sidebar.help.feedback.email", defaultValue: "Your Email", bundle: .module)) + .font(.system(size: 12, weight: .medium)) + TextField( + String(localized: "sidebar.help.feedback.emailPlaceholder", defaultValue: "you@example.com", bundle: .module), + text: $email + ) + .textFieldStyle(.roundedBorder) + .accessibilityLabel(String(localized: "sidebar.help.feedback.email", defaultValue: "Your Email", bundle: .module)) + .accessibilityIdentifier("SidebarFeedbackEmailField") + } + + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline) { + Text(String(localized: "sidebar.help.feedback.message", defaultValue: "Message", bundle: .module)) + .font(.system(size: 12, weight: .medium)) + Spacer(minLength: 0) + Text("\(message.count)/\(FeedbackComposerSettings.maxMessageLength)") + .font(.system(size: 11)) + .foregroundStyle( + message.count > FeedbackComposerSettings.maxMessageLength + ? Color.red + : Color.secondary + ) + } + + FeedbackComposerMessageEditor( + text: $message, + placeholder: String( + localized: "sidebar.help.feedback.messagePlaceholder", + defaultValue: "Share feedback, feature requests, or issues.", + bundle: .module + ), + accessibilityLabel: String(localized: "sidebar.help.feedback.message", defaultValue: "Message", bundle: .module), + accessibilityIdentifier: "SidebarFeedbackMessageEditor" + ) + .frame(minHeight: 180) + } + + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + Button { + chooseAttachments() + } label: { + Label( + String(localized: "sidebar.help.feedback.attachImages", defaultValue: "Attach Images", bundle: .module), + systemImage: "paperclip" + ) + } + .accessibilityIdentifier("SidebarFeedbackAttachButton") + + Text( + String( + localized: "sidebar.help.feedback.attachmentsHint", + defaultValue: "Up to 10 images.", + bundle: .module + ) + ) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + + if attachments.isEmpty == false { + VStack(alignment: .leading, spacing: 6) { + ForEach(attachments) { attachment in + HStack(spacing: 8) { + Image(systemName: "photo") + .foregroundStyle(.secondary) + Text(attachment.fileName) + .font(.system(size: 12)) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 0) + Text(attachment.displaySize) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + Button( + String(localized: "sidebar.help.feedback.removeAttachment", defaultValue: "Remove", bundle: .module) + ) { + removeAttachment(attachment) + } + .buttonStyle(.link) + } + } + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.primary.opacity(0.04)) + ) + } + } + + if let submissionErrorMessage, submissionErrorMessage.isEmpty == false { + Text(submissionErrorMessage) + .font(.system(size: 12)) + .foregroundStyle(.red) + } + + HStack { + Spacer() + Button(String(localized: "sidebar.help.feedback.cancel", defaultValue: "Cancel", bundle: .module)) { + dismiss() + } + .keyboardShortcut(.cancelAction) + + Button { + Task { await submitFeedback() } + } label: { + if isSubmitting { + ProgressView() + .controlSize(.small) + } else { + Text(String(localized: "sidebar.help.feedback.send", defaultValue: "Send", bundle: .module)) + } + } + .keyboardShortcut(.defaultAction) + .disabled(!canSubmit) + .accessibilityIdentifier("SidebarFeedbackSendButton") + } + } + } + + private func chooseAttachments() { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = true + panel.allowedContentTypes = [.image] + panel.title = String( + localized: "sidebar.help.feedback.attachImages.title", + defaultValue: "Attach Images", + bundle: .module + ) + panel.prompt = String( + localized: "sidebar.help.feedback.attachImages.prompt", + defaultValue: "Attach", + bundle: .module + ) + + guard panel.runModal() == .OK else { return } + + var updatedAttachments = attachments + var knownPaths = Set(updatedAttachments.map(\.standardizedPath)) + var firstIssue: String? + + for url in panel.urls { + let normalizedPath = url.standardizedFileURL.path + if knownPaths.contains(normalizedPath) { + continue + } + if updatedAttachments.count >= FeedbackComposerSettings.maxAttachmentCount { + firstIssue = String( + localized: "sidebar.help.feedback.tooManyImages", + defaultValue: "You can attach up to 10 images.", + bundle: .module + ) + break + } + + guard let attachment = try? FeedbackComposerAttachment(url: url) else { + firstIssue = String( + localized: "sidebar.help.feedback.invalidImageSelection", + defaultValue: "One of the selected files could not be attached.", + bundle: .module + ) + continue + } + updatedAttachments.append(attachment) + knownPaths.insert(normalizedPath) + } + + attachments = updatedAttachments + submissionErrorMessage = firstIssue + } + + private func removeAttachment(_ attachment: FeedbackComposerAttachment) { + attachments.removeAll { $0.id == attachment.id } + submissionErrorMessage = nil + } + + private func submitFeedback() async { + let trimmedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedMessage = trimmedMessage + + guard isValidEmail(trimmedEmail) else { + submissionErrorMessage = String( + localized: "sidebar.help.feedback.invalidEmail", + defaultValue: "Enter a valid email address.", + bundle: .module + ) + return + } + + guard normalizedMessage.isEmpty == false else { + submissionErrorMessage = String( + localized: "sidebar.help.feedback.emptyMessage", + defaultValue: "Enter a message before sending.", + bundle: .module + ) + return + } + + guard message.count <= FeedbackComposerSettings.maxMessageLength else { + submissionErrorMessage = String( + localized: "sidebar.help.feedback.messageTooLong", + defaultValue: "Your message is too long.", + bundle: .module + ) + return + } + + await MainActor.run { + email = trimmedEmail + submissionErrorMessage = nil + isSubmitting = true + } + + do { + try await FeedbackComposerClient.submit( + email: trimmedEmail, + message: normalizedMessage, + attachments: attachments + ) + await MainActor.run { + isSubmitting = false + didSend = true + attachments = [] + } + } catch { + await MainActor.run { + isSubmitting = false + submissionErrorMessage = userFacingErrorMessage(for: error) + } + } + } + + private func isValidEmail(_ rawValue: String) -> Bool { + let email = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard email.isEmpty == false else { return false } + let pattern = #"^[A-Z0-9a-z._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$"# + return NSPredicate(format: "SELF MATCHES %@", pattern).evaluate(with: email) + } + + private func userFacingErrorMessage(for error: any Error) -> String { + guard let submissionError = error as? FeedbackComposerSubmissionError else { + return String( + localized: "sidebar.help.feedback.genericError", + defaultValue: "Couldn't send feedback. Please try again.", + bundle: .module + ) + } + + switch submissionError { + case .invalidEndpoint: + return String( + localized: "sidebar.help.feedback.endpointError", + defaultValue: "Feedback is unavailable right now. Email founders@manaflow.com instead.", + bundle: .module + ) + case .invalidResponse: + return String( + localized: "sidebar.help.feedback.genericError", + defaultValue: "Couldn't send feedback. Please try again.", + bundle: .module + ) + case .attachmentReadFailed: + return String( + localized: "sidebar.help.feedback.invalidImageSelection", + defaultValue: "One of the selected files could not be attached.", + bundle: .module + ) + case .attachmentPreparationFailed: + return String( + localized: "sidebar.help.feedback.totalImagesTooLarge", + defaultValue: "These images are too large to send together. Remove a few and try again.", + bundle: .module + ) + case .transport(let transportError): + if transportError.code == .notConnectedToInternet || transportError.code == .networkConnectionLost { + return String( + localized: "sidebar.help.feedback.connectionError", + defaultValue: "Couldn't send feedback. Check your connection and try again.", + bundle: .module + ) + } + return String( + localized: "sidebar.help.feedback.genericError", + defaultValue: "Couldn't send feedback. Please try again.", + bundle: .module + ) + case .rejected(let statusCode): + switch statusCode { + case 400, 413, 415: + return String( + localized: "sidebar.help.feedback.validationError", + defaultValue: "Check your message and attachments, then try again.", + bundle: .module + ) + case 429: + return String( + localized: "sidebar.help.feedback.rateLimited", + defaultValue: "Too many feedback attempts. Please try again later.", + bundle: .module + ) + case 500...599: + return String( + localized: "sidebar.help.feedback.endpointError", + defaultValue: "Feedback is unavailable right now. Email founders@manaflow.com instead.", + bundle: .module + ) + default: + return String( + localized: "sidebar.help.feedback.genericError", + defaultValue: "Couldn't send feedback. Please try again.", + bundle: .module + ) + } + } + } +} diff --git a/Packages/CmuxFeedbackUI/Sources/CmuxFeedbackUI/Resources/Localizable.xcstrings b/Packages/CmuxFeedbackUI/Sources/CmuxFeedbackUI/Resources/Localizable.xcstrings new file mode 100644 index 00000000000..dad2c6fff85 --- /dev/null +++ b/Packages/CmuxFeedbackUI/Sources/CmuxFeedbackUI/Resources/Localizable.xcstrings @@ -0,0 +1,789 @@ +{ + "sourceLanguage": "en", + "version": "1.0", + "strings": { + "sidebar.help.feedback.attachImages.prompt": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Attach" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "添付" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Прикріпити" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "첨부" + } + } + } + }, + "sidebar.help.feedback.attachImages.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Attach Images" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像を添付" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Прикріпити зображення" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이미지 첨부" + } + } + } + }, + "sidebar.help.feedback.attachImages": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Attach Images" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像を添付" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Прикріпити зображення" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이미지 첨부" + } + } + } + }, + "sidebar.help.feedback.attachmentsHint": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Up to 10 images." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像は最大10枚まで添付できます。" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "До 10 зображень." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최대 10장의 이미지." + } + } + } + }, + "sidebar.help.feedback.cancel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Скасувати" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "취소" + } + } + } + }, + "sidebar.help.feedback.connectionError": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn't send feedback. Check your connection and try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィードバックを送信できませんでした。接続を確認して、もう一度お試しください。" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Не вдалося надіслати відгук. Перевірте підключення та спробуйте ще раз." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "피드백을 보내지 못했습니다. 연결을 확인하고 다시 시도하세요." + } + } + } + }, + "sidebar.help.feedback.done": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Done" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "完了" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Готово" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "완료" + } + } + } + }, + "sidebar.help.feedback.email": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your Email" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メールアドレス" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Ваш email" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이메일 주소" + } + } + } + }, + "sidebar.help.feedback.emailPlaceholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "you@example.com" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "you@example.com" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "you@example.com" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "you@example.com" + } + } + } + }, + "sidebar.help.feedback.emptyMessage": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a message before sending." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "送信する前にメッセージを入力してください。" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Введіть повідомлення перед надсиланням." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "보내기 전에 메시지를 입력하세요." + } + } + } + }, + "sidebar.help.feedback.endpointError": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Feedback is unavailable right now. Email founders@manaflow.com instead." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在フィードバックを送信できません。代わりに founders@manaflow.com までメールしてください。" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Відгуки зараз недоступні. Напишіть на founders@manaflow.com." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 피드백을 사용할 수 없습니다. 대신 founders@manaflow.com으로 이메일을 보내주세요." + } + } + } + }, + "sidebar.help.feedback.genericError": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn't send feedback. Please try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィードバックを送信できませんでした。もう一度お試しください。" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Не вдалося надіслати відгук. Спробуйте ще раз." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "피드백을 보내지 못했습니다. 다시 시도하세요." + } + } + } + }, + "sidebar.help.feedback.invalidEmail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a valid email address." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "有効なメールアドレスを入力してください。" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Введіть дійсну електронну адресу." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "올바른 이메일 주소를 입력하세요." + } + } + } + }, + "sidebar.help.feedback.invalidImageSelection": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "One of the selected files could not be attached." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択したファイルのうち1つを添付できませんでした。" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Один з вибраних файлів не вдалося прикріпити." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "선택한 파일 중 하나를 첨부할 수 없습니다." + } + } + } + }, + "sidebar.help.feedback.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Message" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メッセージ" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Повідомлення" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "메시지" + } + } + } + }, + "sidebar.help.feedback.messagePlaceholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Share feedback, feature requests, or issues." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィードバック、機能要望、不具合をお知らせください。" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Поділіться відгуком, пропозиціями або повідомте про проблему." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "피드백, 기능 요청 또는 문제를 공유하세요." + } + } + } + }, + "sidebar.help.feedback.messageTooLong": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your message is too long." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メッセージが長すぎます。" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Ваше повідомлення занадто довге." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "메시지가 너무 깁니다." + } + } + } + }, + "sidebar.help.feedback.note": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You can also reach us at founders@manaflow.com." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "founders@manaflow.com 宛てに直接ご連絡いただくこともできます。" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Ви також можете написати нам на founders@manaflow.com." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "founders@manaflow.com으로도 연락하실 수 있습니다." + } + } + } + }, + "sidebar.help.feedback.rateLimited": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Too many feedback attempts. Please try again later." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィードバックの送信回数が多すぎます。しばらくしてからもう一度お試しください。" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Занадто багато спроб надсилання відгуку. Спробуйте пізніше." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "피드백 시도가 너무 많습니다. 나중에 다시 시도하세요." + } + } + } + }, + "sidebar.help.feedback.removeAttachment": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Видалити" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "제거" + } + } + } + }, + "sidebar.help.feedback.send": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "送信" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Надіслати" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "보내기" + } + } + } + }, + "sidebar.help.feedback.successBody": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You can also reach us at founders@manaflow.com." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "founders@manaflow.com 宛てに直接ご連絡いただくこともできます。" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Ви також можете написати нам на founders@manaflow.com." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "founders@manaflow.com으로도 연락하실 수 있습니다." + } + } + } + }, + "sidebar.help.feedback.successTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Thanks for the feedback." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィードバックありがとうございます。" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Дякуємо за відгук." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "피드백을 보내주셔서 감사합니다." + } + } + } + }, + "sidebar.help.feedback.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send Feedback" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィードバックを送信" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Надіслати відгук" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "피드백 보내기" + } + } + } + }, + "sidebar.help.feedback.tooManyImages": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You can attach up to 10 images." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像は最大10枚まで添付できます。" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Можна прикріпити до 10 зображень." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최대 10장의 이미지를 첨부할 수 있습니다." + } + } + } + }, + "sidebar.help.feedback.totalImagesTooLarge": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "These images are too large to send together. Remove a few and try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "これらの画像はまとめて送信するには大きすぎます。いくつか削除してもう一度お試しください。" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Ці зображення занадто великі для надсилання разом. Видаліть кілька та спробуйте ще раз." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이미지가 너무 커서 함께 보낼 수 없습니다. 몇 개를 제거하고 다시 시도하세요." + } + } + } + }, + "sidebar.help.feedback.validationError": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check your message and attachments, then try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メッセージと添付ファイルを確認して、もう一度お試しください。" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Перевірте повідомлення та вкладення, потім спробуйте ще раз." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "메시지와 첨부 파일을 확인한 후 다시 시도하세요." + } + } + } + } + } +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 05b75de6d8d..bd21377c64c 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -13006,388 +13006,6 @@ private enum SidebarHelpMenuAction { case welcome } -private struct SidebarFeedbackComposerSheet: View { - private static let formMaxHeight: CGFloat = 560 - - @AppStorage(FeedbackComposerSettings.storedEmailKey) private var email = "" - @Environment(\.dismiss) private var dismiss - - @State private var message = "" - @State private var attachments: [FeedbackComposerAttachment] = [] - @State private var isSubmitting = false - @State private var submissionErrorMessage: String? - @State private var didSend = false - - private var trimmedMessage: String { - message.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private var canSubmit: Bool { - isValidEmail(email) && - !trimmedMessage.isEmpty && - message.count <= FeedbackComposerSettings.maxMessageLength && - !isSubmitting && - !didSend - } - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - Text(String(localized: "sidebar.help.feedback.title", defaultValue: "Send Feedback")) - .font(.title3.weight(.semibold)) - - if didSend { - successView - } else { - ScrollView { - formView - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.trailing, 4) - } - .frame(maxHeight: Self.formMaxHeight) - } - } - .padding(20) - .frame(width: 520) - .accessibilityIdentifier("SidebarFeedbackDialog") - } - - private var successView: some View { - VStack(alignment: .leading, spacing: 12) { - Text(String(localized: "sidebar.help.feedback.successTitle", defaultValue: "Thanks for the feedback.")) - .font(.headline) - Text( - String( - localized: "sidebar.help.feedback.successBody", - defaultValue: "You can also reach us at founders@manaflow.com." - ) - ) - .font(.system(size: 12)) - .foregroundStyle(.secondary) - .textSelection(.enabled) - - HStack { - Spacer() - Button(String(localized: "sidebar.help.feedback.done", defaultValue: "Done")) { - dismiss() - } - .keyboardShortcut(.defaultAction) - } - } - } - - private var formView: some View { - VStack(alignment: .leading, spacing: 14) { - Text( - String( - localized: "sidebar.help.feedback.note", - defaultValue: "A human will read this! You can also reach us at founders@manaflow.com." - ) - ) - .font(.system(size: 12)) - .foregroundStyle(.secondary) - - VStack(alignment: .leading, spacing: 6) { - Text(String(localized: "sidebar.help.feedback.email", defaultValue: "Your Email")) - .font(.system(size: 12, weight: .medium)) - TextField( - String(localized: "sidebar.help.feedback.emailPlaceholder", defaultValue: "you@example.com"), - text: $email - ) - .textFieldStyle(.roundedBorder) - .accessibilityLabel(String(localized: "sidebar.help.feedback.email", defaultValue: "Your Email")) - .accessibilityIdentifier("SidebarFeedbackEmailField") - } - - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .firstTextBaseline) { - Text(String(localized: "sidebar.help.feedback.message", defaultValue: "Message")) - .font(.system(size: 12, weight: .medium)) - Spacer(minLength: 0) - Text("\(message.count)/\(FeedbackComposerSettings.maxMessageLength)") - .font(.system(size: 11)) - .foregroundStyle( - message.count > FeedbackComposerSettings.maxMessageLength - ? Color.red - : Color.secondary - ) - } - - FeedbackComposerMessageEditor( - text: $message, - placeholder: String( - localized: "sidebar.help.feedback.messagePlaceholder", - defaultValue: "Share feedback, feature requests, or issues." - ), - accessibilityLabel: String(localized: "sidebar.help.feedback.message", defaultValue: "Message"), - accessibilityIdentifier: "SidebarFeedbackMessageEditor" - ) - .frame(minHeight: 180) - } - - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 10) { - Button { - chooseAttachments() - } label: { - Label( - String(localized: "sidebar.help.feedback.attachImages", defaultValue: "Attach Images"), - systemImage: "paperclip" - ) - } - .accessibilityIdentifier("SidebarFeedbackAttachButton") - - Text( - String( - localized: "sidebar.help.feedback.attachmentsHint", - defaultValue: "Up to 10 images." - ) - ) - .font(.system(size: 11)) - .foregroundStyle(.secondary) - } - - if attachments.isEmpty == false { - VStack(alignment: .leading, spacing: 6) { - ForEach(attachments) { attachment in - HStack(spacing: 8) { - Image(systemName: "photo") - .foregroundStyle(.secondary) - Text(attachment.fileName) - .font(.system(size: 12)) - .lineLimit(1) - .truncationMode(.middle) - Spacer(minLength: 0) - Text(attachment.displaySize) - .font(.system(size: 11)) - .foregroundStyle(.secondary) - Button( - String(localized: "sidebar.help.feedback.removeAttachment", defaultValue: "Remove") - ) { - removeAttachment(attachment) - } - .buttonStyle(.link) - } - } - } - .padding(10) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color.primary.opacity(0.04)) - ) - } - } - - if let submissionErrorMessage, submissionErrorMessage.isEmpty == false { - Text(submissionErrorMessage) - .font(.system(size: 12)) - .foregroundStyle(.red) - } - - HStack { - Spacer() - Button(String(localized: "sidebar.help.feedback.cancel", defaultValue: "Cancel")) { - dismiss() - } - .keyboardShortcut(.cancelAction) - - Button { - Task { await submitFeedback() } - } label: { - if isSubmitting { - ProgressView() - .controlSize(.small) - } else { - Text(String(localized: "sidebar.help.feedback.send", defaultValue: "Send")) - } - } - .keyboardShortcut(.defaultAction) - .disabled(!canSubmit) - .accessibilityIdentifier("SidebarFeedbackSendButton") - } - } - } - - private func chooseAttachments() { - let panel = NSOpenPanel() - panel.canChooseFiles = true - panel.canChooseDirectories = false - panel.allowsMultipleSelection = true - panel.allowedContentTypes = [.image] - panel.title = String( - localized: "sidebar.help.feedback.attachImages.title", - defaultValue: "Attach Images" - ) - panel.prompt = String( - localized: "sidebar.help.feedback.attachImages.prompt", - defaultValue: "Attach" - ) - - guard panel.runModal() == .OK else { return } - - var updatedAttachments = attachments - var knownPaths = Set(updatedAttachments.map(\.standardizedPath)) - var firstIssue: String? - - for url in panel.urls { - let normalizedPath = url.standardizedFileURL.path - if knownPaths.contains(normalizedPath) { - continue - } - if updatedAttachments.count >= FeedbackComposerSettings.maxAttachmentCount { - firstIssue = String( - localized: "sidebar.help.feedback.tooManyImages", - defaultValue: "You can attach up to 10 images." - ) - break - } - - guard let attachment = try? FeedbackComposerAttachment(url: url) else { - firstIssue = String( - localized: "sidebar.help.feedback.invalidImageSelection", - defaultValue: "One of the selected files could not be attached." - ) - continue - } - updatedAttachments.append(attachment) - knownPaths.insert(normalizedPath) - } - - attachments = updatedAttachments - submissionErrorMessage = firstIssue - } - - private func removeAttachment(_ attachment: FeedbackComposerAttachment) { - attachments.removeAll { $0.id == attachment.id } - submissionErrorMessage = nil - } - - private func submitFeedback() async { - let trimmedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines) - let normalizedMessage = trimmedMessage - - guard isValidEmail(trimmedEmail) else { - submissionErrorMessage = String( - localized: "sidebar.help.feedback.invalidEmail", - defaultValue: "Enter a valid email address." - ) - return - } - - guard normalizedMessage.isEmpty == false else { - submissionErrorMessage = String( - localized: "sidebar.help.feedback.emptyMessage", - defaultValue: "Enter a message before sending." - ) - return - } - - guard message.count <= FeedbackComposerSettings.maxMessageLength else { - submissionErrorMessage = String( - localized: "sidebar.help.feedback.messageTooLong", - defaultValue: "Your message is too long." - ) - return - } - - await MainActor.run { - email = trimmedEmail - submissionErrorMessage = nil - isSubmitting = true - } - - do { - try await FeedbackComposerClient.submit( - email: trimmedEmail, - message: normalizedMessage, - attachments: attachments - ) - await MainActor.run { - isSubmitting = false - didSend = true - attachments = [] - } - } catch { - await MainActor.run { - isSubmitting = false - submissionErrorMessage = userFacingErrorMessage(for: error) - } - } - } - - private func isValidEmail(_ rawValue: String) -> Bool { - let email = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) - guard email.isEmpty == false else { return false } - let pattern = #"^[A-Z0-9a-z._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$"# - return NSPredicate(format: "SELF MATCHES %@", pattern).evaluate(with: email) - } - - private func userFacingErrorMessage(for error: Error) -> String { - guard let submissionError = error as? FeedbackComposerSubmissionError else { - return String( - localized: "sidebar.help.feedback.genericError", - defaultValue: "Couldn't send feedback. Please try again." - ) - } - - switch submissionError { - case .invalidEndpoint: - return String( - localized: "sidebar.help.feedback.endpointError", - defaultValue: "Feedback is unavailable right now. Email founders@manaflow.com instead." - ) - case .invalidResponse: - return String( - localized: "sidebar.help.feedback.genericError", - defaultValue: "Couldn't send feedback. Please try again." - ) - case .attachmentReadFailed: - return String( - localized: "sidebar.help.feedback.invalidImageSelection", - defaultValue: "One of the selected files could not be attached." - ) - case .attachmentPreparationFailed: - return String( - localized: "sidebar.help.feedback.totalImagesTooLarge", - defaultValue: "These images are too large to send together. Remove a few and try again." - ) - case .transport(let transportError): - if transportError.code == .notConnectedToInternet || transportError.code == .networkConnectionLost { - return String( - localized: "sidebar.help.feedback.connectionError", - defaultValue: "Couldn't send feedback. Check your connection and try again." - ) - } - return String( - localized: "sidebar.help.feedback.genericError", - defaultValue: "Couldn't send feedback. Please try again." - ) - case .rejected(let statusCode): - switch statusCode { - case 400, 413, 415: - return String( - localized: "sidebar.help.feedback.validationError", - defaultValue: "Check your message and attachments, then try again." - ) - case 429: - return String( - localized: "sidebar.help.feedback.rateLimited", - defaultValue: "Too many feedback attempts. Please try again later." - ) - case 500...599: - return String( - localized: "sidebar.help.feedback.endpointError", - defaultValue: "Feedback is unavailable right now. Email founders@manaflow.com instead." - ) - default: - return String( - localized: "sidebar.help.feedback.genericError", - defaultValue: "Couldn't send feedback. Please try again." - ) - } - } - } -} - private struct SidebarHelpMenuButton: View { private let docsURL = URL(string: "https://cmux.com/docs") private let changelogURL = URL(string: "https://cmux.com/docs/changelog") From 547fd6ad6bf063f0dd0f82b98427402e431dd668 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Sat, 13 Jun 2026 01:20:38 -0700 Subject: [PATCH 28/31] ContentView drain: lift shortcut-hint + dev-banner + markdown leaves to CmuxFoundation Faithful lift of seven independent pure value/policy leaf types out of the ContentView preamble/sidebar cluster into CmuxFoundation (the established home for ContentView's sidebar pure-policy leaves): - ShortcutHintModifierPolicy, ShortcutHintDebugSettings, ShortcutHintModifierActivation, SidebarWorkspaceShortcutHintMetrics, SidebarTrailingAccessoryWidthPolicy (ShortcutHint/ subfolder) - DevBuildBannerDebugSettings - SidebarMarkdownRenderer Bodies are byte-identical to the app-target originals (machine-diff verified); only additions are public/DocC and two strict-Swift-6 faithful fixes: SidebarWorkspaceShortcutHintMetrics' static NSFont constant and lock-guarded memo get nonisolated(unsafe) with inline justification (NSFont non-Sendable in package mode; immutable, read only under the lock). App-target consumers (ContentView, ShortcutHintPill, RightSidebarPanelView, UpdateTitlebarAccessory, BrowserPanelView, cmuxApp) and the three cmuxTests files retarget by adding `import CmuxFoundation`; no assertion changes. ContentView.swift 17,162 -> 16,957 (-205). Budget regenerated (test +1 import line). Package swift build + swift test green; app xcodebuild Debug green. Co-Authored-By: Claude Fable 5 --- .github/swift-file-length-budget.tsv | 4 +- .../DevBuildBannerDebugSettings.swift | 18 ++ .../ShortcutHintDebugSettings.swift | 47 ++++ .../ShortcutHintModifierActivation.swift | 25 +++ .../ShortcutHintModifierPolicy.swift | 83 +++++++ .../SidebarTrailingAccessoryWidthPolicy.swift | 7 + .../SidebarWorkspaceShortcutHintMetrics.swift | 70 ++++++ .../SidebarMarkdownRenderer.swift | 20 ++ Sources/ContentView.swift | 205 ------------------ Sources/Panels/BrowserPanelView.swift | 1 + Sources/RightSidebarPanelView.swift | 1 + Sources/ShortcutHintPill.swift | 1 + Sources/Update/UpdateTitlebarAccessory.swift | 1 + .../ShortcutAndCommandPaletteTests.swift | 1 + cmuxTests/SidebarMarkdownRendererTests.swift | 1 + 15 files changed, 278 insertions(+), 207 deletions(-) create mode 100644 Packages/CmuxFoundation/Sources/CmuxFoundation/DevBuildBannerDebugSettings.swift create mode 100644 Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/ShortcutHintDebugSettings.swift create mode 100644 Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/ShortcutHintModifierActivation.swift create mode 100644 Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/ShortcutHintModifierPolicy.swift create mode 100644 Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/SidebarTrailingAccessoryWidthPolicy.swift create mode 100644 Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/SidebarWorkspaceShortcutHintMetrics.swift create mode 100644 Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarMarkdownRenderer.swift diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index b5d42fbcbf9..fc40d7d92fd 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -4,7 +4,7 @@ 33285 CLI/cmux.swift 19991 Sources/Workspace.swift 18118 Sources/AppDelegate.swift -17162 Sources/ContentView.swift +16957 Sources/ContentView.swift 16675 Sources/GhosttyTerminalView.swift 14624 Sources/TerminalController.swift 13606 Sources/Panels/BrowserPanel.swift @@ -45,7 +45,7 @@ 2260 Sources/TerminalWindowPortal.swift 2198 Sources/SessionPersistence.swift 2117 cmuxTests/CmuxConfigTests.swift -2088 cmuxTests/ShortcutAndCommandPaletteTests.swift +2089 cmuxTests/ShortcutAndCommandPaletteTests.swift 2030 Sources/KeyboardShortcutSettingsFileStore.swift 1949 Sources/Panels/BrowserWebAuthnSupport.swift 1941 Sources/TerminalNotificationStore.swift diff --git a/Packages/CmuxFoundation/Sources/CmuxFoundation/DevBuildBannerDebugSettings.swift b/Packages/CmuxFoundation/Sources/CmuxFoundation/DevBuildBannerDebugSettings.swift new file mode 100644 index 00000000000..1531c7ed71e --- /dev/null +++ b/Packages/CmuxFoundation/Sources/CmuxFoundation/DevBuildBannerDebugSettings.swift @@ -0,0 +1,18 @@ +public import Foundation + +/// Controls visibility of the DEBUG dev-build banner in the sidebar footer. +/// Pure value namespace reading from an injected `UserDefaults`. +public enum DevBuildBannerDebugSettings { + /// Defaults key backing sidebar dev-build banner visibility. + public static let sidebarBannerVisibleKey = "showSidebarDevBuildBanner" + /// Default when the user has not stored a preference. + public static let defaultShowSidebarBanner = true + + /// Whether the sidebar dev-build banner should be shown. + public static func showSidebarBanner(defaults: UserDefaults = .standard) -> Bool { + guard defaults.object(forKey: sidebarBannerVisibleKey) != nil else { + return defaultShowSidebarBanner + } + return defaults.bool(forKey: sidebarBannerVisibleKey) + } +} diff --git a/Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/ShortcutHintDebugSettings.swift b/Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/ShortcutHintDebugSettings.swift new file mode 100644 index 00000000000..05ea435990c --- /dev/null +++ b/Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/ShortcutHintDebugSettings.swift @@ -0,0 +1,47 @@ +public import AppKit + +/// Default offsets and feature flags for the keyboard shortcut-hint overlays +/// shown while a modifier is held. Pure value namespace reading from an +/// injected `UserDefaults` / process environment; holds no mutable state. +public enum ShortcutHintDebugSettings { + public static let defaultSidebarHintX = 0.0 + public static let defaultSidebarHintY = 0.0 + public static let defaultTitlebarHintX = 0.0 + public static let defaultTitlebarHintY = -5.0 + public static let defaultPaneHintX = 0.0 + public static let defaultPaneHintY = 0.0 + public static let defaultRightSidebarCloseHintX = -10.0 + public static let defaultRightSidebarCloseHintY = 3.3 + public static let defaultRightSidebarFocusHintX = -1.6 + public static let defaultRightSidebarFocusHintY = 1.7 + public static let defaultAlwaysShowHints = false + public static let defaultShowHintsOnCommandHold = true + public static let defaultShowHintsOnControlHold = true + + /// Allowed range (in points) for a debug hint position offset. + public static let offsetRange: ClosedRange = -20...20 + + /// Clamps a debug offset value into `offsetRange`. + public static func clamped(_ value: Double) -> Double { + min(max(value, offsetRange.lowerBound), offsetRange.upperBound) + } + + /// Whether hints should always be shown, honoring the UI-test override + /// environment variable. + public static func alwaysShowHints( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> Bool { + defaultAlwaysShowHints || environment["CMUX_UI_TEST_SHORTCUT_HINTS_ALWAYS_SHOW"] == "1" + } + + /// Whether command-hold hints are enabled. + public static func showHintsOnCommandHoldEnabled(defaults: UserDefaults = .standard) -> Bool { + defaultShowHintsOnCommandHold + } + + /// Whether control-hold hints are enabled. + public static func showHintsOnControlHoldEnabled(defaults: UserDefaults = .standard) -> Bool { + defaultShowHintsOnControlHold + } + +} diff --git a/Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/ShortcutHintModifierActivation.swift b/Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/ShortcutHintModifierActivation.swift new file mode 100644 index 00000000000..158eab828dc --- /dev/null +++ b/Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/ShortcutHintModifierActivation.swift @@ -0,0 +1,25 @@ +public import AppKit + +/// Which modifier(s) activate keyboard shortcut hints. Pure value forwarding to +/// `ShortcutHintModifierPolicy` for the actual gate. +public enum ShortcutHintModifierActivation { + case commandOrControl + case commandOnly + case controlOnly + + /// Whether hints should show for the held modifier flags under this + /// activation mode. + public func shouldShowHints( + for modifierFlags: NSEvent.ModifierFlags, + defaults: UserDefaults = .standard + ) -> Bool { + switch self { + case .commandOrControl: + return ShortcutHintModifierPolicy.shouldShowHints(for: modifierFlags, defaults: defaults) + case .commandOnly: + return ShortcutHintModifierPolicy.shouldShowCommandHints(for: modifierFlags, defaults: defaults) + case .controlOnly: + return ShortcutHintModifierPolicy.shouldShowControlHints(for: modifierFlags, defaults: defaults) + } + } +} diff --git a/Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/ShortcutHintModifierPolicy.swift b/Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/ShortcutHintModifierPolicy.swift new file mode 100644 index 00000000000..d0766f7ca96 --- /dev/null +++ b/Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/ShortcutHintModifierPolicy.swift @@ -0,0 +1,83 @@ +public import AppKit + +/// Pure policy deciding whether keyboard shortcut-hint overlays should be shown +/// for a given set of held modifier flags and the host/event window identity. +/// No mutable state; reads feature flags from `ShortcutHintDebugSettings`. +public enum ShortcutHintModifierPolicy { + /// Hold duration before an intentional modifier-hold is treated as a + /// request to show hints. + public static let intentionalHoldDelay: TimeInterval = 0.30 + + /// Whether hints should show for the held modifiers (command or control). + public static func shouldShowHints( + for modifierFlags: NSEvent.ModifierFlags, + defaults: UserDefaults = .standard + ) -> Bool { + let normalized = modifierFlags.intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + switch normalized { + case [.command]: + return ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults) + case [.control]: + return ShortcutHintDebugSettings.showHintsOnControlHoldEnabled(defaults: defaults) + default: + return false + } + } + + /// Whether control-hold hints should show for exactly the control modifier. + public static func shouldShowControlHints( + for modifierFlags: NSEvent.ModifierFlags, + defaults: UserDefaults = .standard + ) -> Bool { + let normalized = modifierFlags.intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + guard normalized == [.control] else { return false } + return ShortcutHintDebugSettings.showHintsOnControlHoldEnabled(defaults: defaults) + } + + /// Whether command-hold hints should show for exactly the command modifier. + public static func shouldShowCommandHints( + for modifierFlags: NSEvent.ModifierFlags, + defaults: UserDefaults = .standard + ) -> Bool { + let normalized = modifierFlags.intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + guard normalized == [.command] else { return false } + return ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults) + } + + /// Whether the event/key window matches the host window so hints are scoped + /// to the active window only. + public static func isCurrentWindow( + hostWindowNumber: Int?, + hostWindowIsKey: Bool, + eventWindowNumber: Int?, + keyWindowNumber: Int? + ) -> Bool { + guard let hostWindowNumber, hostWindowIsKey else { return false } + if let eventWindowNumber { + return eventWindowNumber == hostWindowNumber + } + return keyWindowNumber == hostWindowNumber + } + + /// Combined gate: hints show only when both the modifier policy and the + /// current-window check pass. + public static func shouldShowHints( + for modifierFlags: NSEvent.ModifierFlags, + hostWindowNumber: Int?, + hostWindowIsKey: Bool, + eventWindowNumber: Int?, + keyWindowNumber: Int?, + defaults: UserDefaults = .standard + ) -> Bool { + shouldShowHints(for: modifierFlags, defaults: defaults) && + isCurrentWindow( + hostWindowNumber: hostWindowNumber, + hostWindowIsKey: hostWindowIsKey, + eventWindowNumber: eventWindowNumber, + keyWindowNumber: keyWindowNumber + ) + } +} diff --git a/Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/SidebarTrailingAccessoryWidthPolicy.swift b/Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/SidebarTrailingAccessoryWidthPolicy.swift new file mode 100644 index 00000000000..d1648fd2bf1 --- /dev/null +++ b/Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/SidebarTrailingAccessoryWidthPolicy.swift @@ -0,0 +1,7 @@ +public import CoreGraphics + +/// Fixed widths for sidebar row trailing accessories. +public enum SidebarTrailingAccessoryWidthPolicy { + /// Width of the row close button. + public static let closeButtonWidth: CGFloat = 16 +} diff --git a/Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/SidebarWorkspaceShortcutHintMetrics.swift b/Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/SidebarWorkspaceShortcutHintMetrics.swift new file mode 100644 index 00000000000..45f752ebf85 --- /dev/null +++ b/Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/SidebarWorkspaceShortcutHintMetrics.swift @@ -0,0 +1,70 @@ +public import AppKit + +/// Measures and caches the rendered width of sidebar shortcut-hint labels so +/// the trailing accessory slot can be sized without re-measuring text on every +/// layout pass. +/// +/// The measurement cache is guarded by an `NSLock`: this is a pure stateless +/// utility whose only shared state is a width memo, so a lock is the faithful +/// minimal guard (no actor needed; callers are synchronous layout code). +public enum SidebarWorkspaceShortcutHintMetrics { + // Immutable measurement font; NSFont is not Sendable but this constant is + // never mutated and is only read under `lock` during measurement. + nonisolated(unsafe) private static let measurementFont = NSFont.systemFont(ofSize: 10, weight: .semibold) + private static let minimumSlotWidth: CGFloat = 28 + private static let horizontalPadding: CGFloat = 12 + // Pure layout memo guarded by a lock; see type doc for the lock rationale. + private static let lock = NSLock() + nonisolated(unsafe) private static var cachedHintWidths: [String: CGFloat] = [:] + #if DEBUG + nonisolated(unsafe) private static var measurementCount = 0 + #endif + + /// Width of the trailing accessory slot for a hint `label`, accounting for + /// the debug horizontal offset. + public static func slotWidth(label: String?, debugXOffset: Double) -> CGFloat { + guard let label else { return minimumSlotWidth } + let positiveDebugInset = max(0, CGFloat(ShortcutHintDebugSettings.clamped(debugXOffset))) + 2 + return max(minimumSlotWidth, hintWidth(for: label) + positiveDebugInset) + } + + /// Cached rendered width of a hint `label`. + public static func hintWidth(for label: String) -> CGFloat { + lock.lock() + if let cached = cachedHintWidths[label] { + lock.unlock() + return cached + } + lock.unlock() + + let textWidth = (label as NSString).size(withAttributes: [.font: measurementFont]).width + let measuredWidth = ceil(textWidth) + horizontalPadding + + lock.lock() + cachedHintWidths[label] = measuredWidth + #if DEBUG + measurementCount += 1 + #endif + lock.unlock() + return measuredWidth + } + + #if DEBUG + /// Clears the measurement cache. DEBUG-only test hook. + public static func resetCacheForTesting() { + lock.lock() + cachedHintWidths.removeAll() + measurementCount = 0 + lock.unlock() + } + + /// Number of text measurements performed since the last reset. DEBUG-only + /// test hook proving the cache is hit. + public static func measurementCountForTesting() -> Int { + lock.lock() + let count = measurementCount + lock.unlock() + return count + } + #endif +} diff --git a/Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarMarkdownRenderer.swift b/Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarMarkdownRenderer.swift new file mode 100644 index 00000000000..e48972c8e64 --- /dev/null +++ b/Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarMarkdownRenderer.swift @@ -0,0 +1,20 @@ +public import Foundation + +/// Pure text transform converting a workspace-description markdown string into +/// an `AttributedString`, preserving inline markdown attributes and original +/// whitespace/line breaks. +/// +/// Shared foundation utility (not sidebar-specific); used to render workspace +/// descriptions in the sidebar and reusable anywhere a lightweight inline +/// markdown render is needed. +public enum SidebarMarkdownRenderer { + /// Renders a workspace-description markdown string into an + /// `AttributedString`, interpreting only inline syntax and preserving + /// whitespace. Returns `nil` when the markdown cannot be parsed. + public static func renderWorkspaceDescription(_ markdown: String) -> AttributedString? { + try? AttributedString( + markdown: markdown, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) + } +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index bd21377c64c..df96f9a97b5 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -12418,125 +12418,6 @@ struct SidebarWorkspaceRowFramePreferenceKey: PreferenceKey { } } -enum ShortcutHintModifierPolicy { - static let intentionalHoldDelay: TimeInterval = 0.30 - - static func shouldShowHints( - for modifierFlags: NSEvent.ModifierFlags, - defaults: UserDefaults = .standard - ) -> Bool { - let normalized = modifierFlags.intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function, .capsLock]) - switch normalized { - case [.command]: - return ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults) - case [.control]: - return ShortcutHintDebugSettings.showHintsOnControlHoldEnabled(defaults: defaults) - default: - return false - } - } - - static func shouldShowControlHints( - for modifierFlags: NSEvent.ModifierFlags, - defaults: UserDefaults = .standard - ) -> Bool { - let normalized = modifierFlags.intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function, .capsLock]) - guard normalized == [.control] else { return false } - return ShortcutHintDebugSettings.showHintsOnControlHoldEnabled(defaults: defaults) - } - - static func shouldShowCommandHints( - for modifierFlags: NSEvent.ModifierFlags, - defaults: UserDefaults = .standard - ) -> Bool { - let normalized = modifierFlags.intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function, .capsLock]) - guard normalized == [.command] else { return false } - return ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults) - } - - static func isCurrentWindow( - hostWindowNumber: Int?, - hostWindowIsKey: Bool, - eventWindowNumber: Int?, - keyWindowNumber: Int? - ) -> Bool { - guard let hostWindowNumber, hostWindowIsKey else { return false } - if let eventWindowNumber { - return eventWindowNumber == hostWindowNumber - } - return keyWindowNumber == hostWindowNumber - } - - static func shouldShowHints( - for modifierFlags: NSEvent.ModifierFlags, - hostWindowNumber: Int?, - hostWindowIsKey: Bool, - eventWindowNumber: Int?, - keyWindowNumber: Int?, - defaults: UserDefaults = .standard - ) -> Bool { - shouldShowHints(for: modifierFlags, defaults: defaults) && - isCurrentWindow( - hostWindowNumber: hostWindowNumber, - hostWindowIsKey: hostWindowIsKey, - eventWindowNumber: eventWindowNumber, - keyWindowNumber: keyWindowNumber - ) - } -} - -enum ShortcutHintDebugSettings { - static let defaultSidebarHintX = 0.0 - static let defaultSidebarHintY = 0.0 - static let defaultTitlebarHintX = 0.0 - static let defaultTitlebarHintY = -5.0 - static let defaultPaneHintX = 0.0 - static let defaultPaneHintY = 0.0 - static let defaultRightSidebarCloseHintX = -10.0 - static let defaultRightSidebarCloseHintY = 3.3 - static let defaultRightSidebarFocusHintX = -1.6 - static let defaultRightSidebarFocusHintY = 1.7 - static let defaultAlwaysShowHints = false - static let defaultShowHintsOnCommandHold = true - static let defaultShowHintsOnControlHold = true - - static let offsetRange: ClosedRange = -20...20 - - static func clamped(_ value: Double) -> Double { - min(max(value, offsetRange.lowerBound), offsetRange.upperBound) - } - - static func alwaysShowHints( - environment: [String: String] = ProcessInfo.processInfo.environment - ) -> Bool { - defaultAlwaysShowHints || environment["CMUX_UI_TEST_SHORTCUT_HINTS_ALWAYS_SHOW"] == "1" - } - - static func showHintsOnCommandHoldEnabled(defaults: UserDefaults = .standard) -> Bool { - defaultShowHintsOnCommandHold - } - - static func showHintsOnControlHoldEnabled(defaults: UserDefaults = .standard) -> Bool { - defaultShowHintsOnControlHold - } - -} - -enum DevBuildBannerDebugSettings { - static let sidebarBannerVisibleKey = "showSidebarDevBuildBanner" - static let defaultShowSidebarBanner = true - - static func showSidebarBanner(defaults: UserDefaults = .standard) -> Bool { - guard defaults.object(forKey: sidebarBannerVisibleKey) != nil else { - return defaultShowSidebarBanner - } - return defaults.bool(forKey: sidebarBannerVisibleKey) - } -} - enum SidebarDragLifecycleNotification { static let stateDidChange = Notification.Name("cmux.sidebarDragStateDidChange") static let requestClear = Notification.Name("cmux.sidebarDragRequestClear") @@ -12749,26 +12630,6 @@ private struct SidebarExternalDropDelegate: DropDelegate { } } -enum ShortcutHintModifierActivation { - case commandOrControl - case commandOnly - case controlOnly - - func shouldShowHints( - for modifierFlags: NSEvent.ModifierFlags, - defaults: UserDefaults = .standard - ) -> Bool { - switch self { - case .commandOrControl: - return ShortcutHintModifierPolicy.shouldShowHints(for: modifierFlags, defaults: defaults) - case .commandOnly: - return ShortcutHintModifierPolicy.shouldShowCommandHints(for: modifierFlags, defaults: defaults) - case .controlOnly: - return ShortcutHintModifierPolicy.shouldShowControlHints(for: modifierFlags, defaults: defaults) - } - } -} - @MainActor @Observable final class WindowScopedShortcutHintModifierMonitor { @@ -13404,63 +13265,6 @@ private struct ExtensionSidebarBrowserStackEmptyArea: View { } } -enum SidebarWorkspaceShortcutHintMetrics { - private static let measurementFont = NSFont.systemFont(ofSize: 10, weight: .semibold) - private static let minimumSlotWidth: CGFloat = 28 - private static let horizontalPadding: CGFloat = 12 - private static let lock = NSLock() - private static var cachedHintWidths: [String: CGFloat] = [:] - #if DEBUG - private static var measurementCount = 0 - #endif - - static func slotWidth(label: String?, debugXOffset: Double) -> CGFloat { - guard let label else { return minimumSlotWidth } - let positiveDebugInset = max(0, CGFloat(ShortcutHintDebugSettings.clamped(debugXOffset))) + 2 - return max(minimumSlotWidth, hintWidth(for: label) + positiveDebugInset) - } - - static func hintWidth(for label: String) -> CGFloat { - lock.lock() - if let cached = cachedHintWidths[label] { - lock.unlock() - return cached - } - lock.unlock() - - let textWidth = (label as NSString).size(withAttributes: [.font: measurementFont]).width - let measuredWidth = ceil(textWidth) + horizontalPadding - - lock.lock() - cachedHintWidths[label] = measuredWidth - #if DEBUG - measurementCount += 1 - #endif - lock.unlock() - return measuredWidth - } - - #if DEBUG - static func resetCacheForTesting() { - lock.lock() - cachedHintWidths.removeAll() - measurementCount = 0 - lock.unlock() - } - - static func measurementCountForTesting() -> Int { - lock.lock() - let count = measurementCount - lock.unlock() - return count - } - #endif -} - -enum SidebarTrailingAccessoryWidthPolicy { - static let closeButtonWidth: CGFloat = 16 -} - // PERF: TabItemView is Equatable so SwiftUI skips body re-evaluation when // the parent rebuilds with unchanged values. Without this, every TabManager // or NotificationStore publish causes ALL tab items to re-evaluate (~18% of @@ -15532,15 +15336,6 @@ private struct SidebarWorkspaceDescriptionText: View { } } -enum SidebarMarkdownRenderer { - static func renderWorkspaceDescription(_ markdown: String) -> AttributedString? { - try? AttributedString( - markdown: markdown, - options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) - ) - } -} - private struct SidebarMetadataRows: View { let entries: [SidebarStatusEntry] let isActive: Bool diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index eea7668b483..f935a66e51f 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -1,4 +1,5 @@ import Bonsplit +import CmuxFoundation import SwiftUI import WebKit import AppKit diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index 327f95224ff..eaead501dea 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -1,6 +1,7 @@ import AppKit import Bonsplit import CMUXWorkstream +import CmuxFoundation import SwiftUI private func rightSidebarDebugResponder(_ responder: NSResponder?) -> String { diff --git a/Sources/ShortcutHintPill.swift b/Sources/ShortcutHintPill.swift index c88dda5049c..391620b993b 100644 --- a/Sources/ShortcutHintPill.swift +++ b/Sources/ShortcutHintPill.swift @@ -1,3 +1,4 @@ +import CmuxFoundation import SwiftUI enum ShortcutHintAnimation { diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index ae6b658b531..43338f376d5 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -1,6 +1,7 @@ import AppKit import Bonsplit import Combine +import CmuxFoundation import SwiftUI final class NonDraggableHostingView: NSHostingView { diff --git a/cmuxTests/ShortcutAndCommandPaletteTests.swift b/cmuxTests/ShortcutAndCommandPaletteTests.swift index 006b59c3f73..f9886938f38 100644 --- a/cmuxTests/ShortcutAndCommandPaletteTests.swift +++ b/cmuxTests/ShortcutAndCommandPaletteTests.swift @@ -1,4 +1,5 @@ import CmuxCommandPalette +import CmuxFoundation import XCTest import AppKit import SwiftUI diff --git a/cmuxTests/SidebarMarkdownRendererTests.swift b/cmuxTests/SidebarMarkdownRendererTests.swift index ef315c00160..112132daa19 100644 --- a/cmuxTests/SidebarMarkdownRendererTests.swift +++ b/cmuxTests/SidebarMarkdownRendererTests.swift @@ -1,3 +1,4 @@ +import CmuxFoundation import XCTest #if canImport(cmux_DEV) From 2196d23f1d85427014729a51e04a6e2fb58a5bef Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Sat, 13 Jun 2026 01:24:09 -0700 Subject: [PATCH 29/31] ContentView drain: lift SidebarWorkspaceSelectionSyncPolicy to CmuxFoundation Faithful lift of the pure sidebar multi-selection reconciliation / shift-click anchor policy out of ContentView into CmuxFoundation/Sidebar. All six members are static functions over Set/[UUID]/Int with no AppKit and no cross-slice god types, so it is a clean foundation leaf. Body is byte-identical to the app-target original (machine-diff verified). The app-target enum carried a redundant @MainActor; dropped on the lift since every member is a pure static over Sendable values (isolation is inert here, callable from any context including the existing @MainActor call sites and the @MainActor Swift Testing suite). The five ContentView call sites resolve through the existing import CmuxFoundation; SidebarWorkspaceSelectionAnchorPolicyTests retargets by adding the import (assertions unchanged). ContentView.swift 16,957 -> 16,867 (-90). Budget regenerated (prior-tranche import lines on three tracked files). Package swift build + swift test green; app xcodebuild Debug green. Co-Authored-By: Claude Fable 5 --- .github/swift-file-length-budget.tsv | 8 +- .../SidebarWorkspaceSelectionSyncPolicy.swift | 101 ++++++++++++++++++ Sources/ContentView.swift | 90 ---------------- ...rWorkspaceSelectionAnchorPolicyTests.swift | 1 + 4 files changed, 106 insertions(+), 94 deletions(-) create mode 100644 Packages/CmuxFoundation/Sources/CmuxFoundation/Sidebar/SidebarWorkspaceSelectionSyncPolicy.swift diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index fc40d7d92fd..32139330f27 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -4,14 +4,14 @@ 33285 CLI/cmux.swift 19991 Sources/Workspace.swift 18118 Sources/AppDelegate.swift -16957 Sources/ContentView.swift +16867 Sources/ContentView.swift 16675 Sources/GhosttyTerminalView.swift 14624 Sources/TerminalController.swift 13606 Sources/Panels/BrowserPanel.swift 12044 cmuxTests/AppDelegateShortcutRoutingTests.swift 10020 Sources/TabManager.swift 9345 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift -7737 Sources/Panels/BrowserPanelView.swift +7738 Sources/Panels/BrowserPanelView.swift 7292 cmuxTests/WorkspaceUnitTests.swift 6948 cmuxTests/WorkspaceRemoteConnectionTests.swift 6543 cmuxTests/GhosttyConfigTests.swift @@ -32,7 +32,7 @@ 3665 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift 3396 Sources/CmuxConfig.swift 3316 cmuxTests/TabManagerSessionSnapshotTests.swift -3202 Sources/Update/UpdateTitlebarAccessory.swift +3203 Sources/Update/UpdateTitlebarAccessory.swift 2878 Sources/SessionIndexView.swift 2871 cmuxTests/CMUXOpenCommandTests.swift 2565 Sources/Panels/CmuxWebView.swift @@ -122,7 +122,7 @@ 705 CLI/CMUXCLI+Config.swift 705 cmuxUITests/BrowserOmnibarSuggestionsUITests.swift 701 CLI/CMUXCLI+AgentHookDefinitions.swift -698 Sources/RightSidebarPanelView.swift +699 Sources/RightSidebarPanelView.swift 698 cmuxTests/RestorableAgentHookProviderResumeTests.swift 697 cmuxTests/TerminalNotificationClearAllTests.swift 696 cmuxTests/UpdatePillReleaseVisibilityTests.swift diff --git a/Packages/CmuxFoundation/Sources/CmuxFoundation/Sidebar/SidebarWorkspaceSelectionSyncPolicy.swift b/Packages/CmuxFoundation/Sources/CmuxFoundation/Sidebar/SidebarWorkspaceSelectionSyncPolicy.swift new file mode 100644 index 00000000000..55afac0b21f --- /dev/null +++ b/Packages/CmuxFoundation/Sources/CmuxFoundation/Sidebar/SidebarWorkspaceSelectionSyncPolicy.swift @@ -0,0 +1,101 @@ +public import Foundation + +/// Pure policy reconciling the sidebar's multi-workspace selection against the +/// live workspace list, and computing shift-click anchor indices. Operates only +/// on workspace UUIDs and indices; holds no state and touches no UI. +public enum SidebarWorkspaceSelectionSyncPolicy { + /// Filters a previous selection down to workspaces that still exist, falling + /// back to the provided selected workspace when nothing survives. + public static func reconciledSelection( + previousSelectionIds: Set, + liveWorkspaceIds: [UUID], + fallbackSelectedWorkspaceId: UUID? + ) -> Set { + let liveIdSet = Set(liveWorkspaceIds) + let liveSelectionIds = previousSelectionIds.filter { liveIdSet.contains($0) } + if !liveSelectionIds.isEmpty { + return liveSelectionIds + } + if let fallbackSelectedWorkspaceId, liveIdSet.contains(fallbackSelectedWorkspaceId) { + return [fallbackSelectedWorkspaceId] + } + return [] + } + + /// Index of the preferred (or first selected) workspace in the live list. + public static func anchorIndex( + preferredWorkspaceId: UUID?, + selectedWorkspaceIds: Set, + liveWorkspaceIds: [UUID] + ) -> Int? { + if let preferredWorkspaceId, + selectedWorkspaceIds.contains(preferredWorkspaceId), + let preferredIndex = liveWorkspaceIds.firstIndex(of: preferredWorkspaceId) { + return preferredIndex + } + return liveWorkspaceIds.firstIndex { selectedWorkspaceIds.contains($0) } + } + + /// Workspace id at an existing anchor index, if the index is still valid. + public static func anchorWorkspaceId( + existingAnchorIndex: Int?, + liveWorkspaceIds: [UUID] + ) -> UUID? { + guard let existingAnchorIndex, + liveWorkspaceIds.indices.contains(existingAnchorIndex) else { + return nil + } + return liveWorkspaceIds[existingAnchorIndex] + } + + /// Anchor index to use for a shift-click range, deriving one from the + /// current selection or focus when no anchor exists yet. + public static func shiftClickAnchorIndex( + existingAnchorIndex: Int?, + selectedWorkspaceIds: Set, + focusedWorkspaceId: UUID?, + liveWorkspaceIds: [UUID] + ) -> Int? { + if let existingAnchorIndex, + liveWorkspaceIds.indices.contains(existingAnchorIndex) { + return existingAnchorIndex + } + if selectedWorkspaceIds.count == 1, + let selectedWorkspaceId = selectedWorkspaceIds.first, + let selectedIndex = liveWorkspaceIds.firstIndex(of: selectedWorkspaceId) { + return selectedIndex + } + if let focusedWorkspaceId { + return liveWorkspaceIds.firstIndex(of: focusedWorkspaceId) + } + return nil + } + + /// Resulting anchor index after a workspace click (shift vs plain). + public static func anchorIndexAfterWorkspaceClick( + isShiftClick: Bool, + resolvedShiftAnchorIndex: Int?, + clickedIndex: Int + ) -> Int { + isShiftClick ? (resolvedShiftAnchorIndex ?? clickedIndex) : clickedIndex + } + + /// Anchor index to preserve after the workspace list is reordered. + public static func anchorIndexAfterWorkspaceReorder( + preferredAnchorWorkspaceId: UUID?, + selectedWorkspaceIds: Set, + focusedWorkspaceId: UUID?, + liveWorkspaceIds: [UUID] + ) -> Int? { + if let preferredAnchorWorkspaceId, + selectedWorkspaceIds.contains(preferredAnchorWorkspaceId), + let anchorIndex = liveWorkspaceIds.firstIndex(of: preferredAnchorWorkspaceId) { + return anchorIndex + } + return anchorIndex( + preferredWorkspaceId: focusedWorkspaceId, + selectedWorkspaceIds: selectedWorkspaceIds, + liveWorkspaceIds: liveWorkspaceIds + ) + } +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index df96f9a97b5..60f589c136d 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -15719,96 +15719,6 @@ private struct SidebarBonsplitTabDropDelegate: DropDelegate { } } -@MainActor -enum SidebarWorkspaceSelectionSyncPolicy { - static func reconciledSelection( - previousSelectionIds: Set, - liveWorkspaceIds: [UUID], - fallbackSelectedWorkspaceId: UUID? - ) -> Set { - let liveIdSet = Set(liveWorkspaceIds) - let liveSelectionIds = previousSelectionIds.filter { liveIdSet.contains($0) } - if !liveSelectionIds.isEmpty { - return liveSelectionIds - } - if let fallbackSelectedWorkspaceId, liveIdSet.contains(fallbackSelectedWorkspaceId) { - return [fallbackSelectedWorkspaceId] - } - return [] - } - - static func anchorIndex( - preferredWorkspaceId: UUID?, - selectedWorkspaceIds: Set, - liveWorkspaceIds: [UUID] - ) -> Int? { - if let preferredWorkspaceId, - selectedWorkspaceIds.contains(preferredWorkspaceId), - let preferredIndex = liveWorkspaceIds.firstIndex(of: preferredWorkspaceId) { - return preferredIndex - } - return liveWorkspaceIds.firstIndex { selectedWorkspaceIds.contains($0) } - } - - static func anchorWorkspaceId( - existingAnchorIndex: Int?, - liveWorkspaceIds: [UUID] - ) -> UUID? { - guard let existingAnchorIndex, - liveWorkspaceIds.indices.contains(existingAnchorIndex) else { - return nil - } - return liveWorkspaceIds[existingAnchorIndex] - } - - static func shiftClickAnchorIndex( - existingAnchorIndex: Int?, - selectedWorkspaceIds: Set, - focusedWorkspaceId: UUID?, - liveWorkspaceIds: [UUID] - ) -> Int? { - if let existingAnchorIndex, - liveWorkspaceIds.indices.contains(existingAnchorIndex) { - return existingAnchorIndex - } - if selectedWorkspaceIds.count == 1, - let selectedWorkspaceId = selectedWorkspaceIds.first, - let selectedIndex = liveWorkspaceIds.firstIndex(of: selectedWorkspaceId) { - return selectedIndex - } - if let focusedWorkspaceId { - return liveWorkspaceIds.firstIndex(of: focusedWorkspaceId) - } - return nil - } - - static func anchorIndexAfterWorkspaceClick( - isShiftClick: Bool, - resolvedShiftAnchorIndex: Int?, - clickedIndex: Int - ) -> Int { - isShiftClick ? (resolvedShiftAnchorIndex ?? clickedIndex) : clickedIndex - } - - static func anchorIndexAfterWorkspaceReorder( - preferredAnchorWorkspaceId: UUID?, - selectedWorkspaceIds: Set, - focusedWorkspaceId: UUID?, - liveWorkspaceIds: [UUID] - ) -> Int? { - if let preferredAnchorWorkspaceId, - selectedWorkspaceIds.contains(preferredAnchorWorkspaceId), - let anchorIndex = liveWorkspaceIds.firstIndex(of: preferredAnchorWorkspaceId) { - return anchorIndex - } - return anchorIndex( - preferredWorkspaceId: focusedWorkspaceId, - selectedWorkspaceIds: selectedWorkspaceIds, - liveWorkspaceIds: liveWorkspaceIds - ) - } -} - @MainActor struct SidebarTabDropDelegate: DropDelegate { let targetTabId: UUID? diff --git a/cmuxTests/SidebarWorkspaceSelectionAnchorPolicyTests.swift b/cmuxTests/SidebarWorkspaceSelectionAnchorPolicyTests.swift index 8cd64097f48..5760c771902 100644 --- a/cmuxTests/SidebarWorkspaceSelectionAnchorPolicyTests.swift +++ b/cmuxTests/SidebarWorkspaceSelectionAnchorPolicyTests.swift @@ -1,3 +1,4 @@ +import CmuxFoundation import Foundation import Testing From 68a26205738297bf9efd971d64246852d8cd5278 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Sat, 13 Jun 2026 01:27:26 -0700 Subject: [PATCH 30/31] ContentView drain: lift SidebarDragLifecycleNotification to CmuxFoundation Faithful lift of the sidebar tab-drag lifecycle notification vocabulary (Notification.Name constants + post/parse helpers over UUID/String) out of ContentView into CmuxFoundation/Sidebar. No cross-slice god types, no mutable state; the only consumer is ContentView (already imports CmuxFoundation), no test references. Body byte-identical to the app-target original (machine-diff verified). The two Notification.Name raw values are the in-process wire contract and are preserved exactly; the NotificationCenter posting is kept as-is (the plan's NotificationCenter->AsyncStream modernization is a separate later phase, not part of a faithful lift). ContentView.swift 16,867 -> 16,833 (-34). Budget respected (no tracked-file growth). Package swift build green; app xcodebuild Debug green. Co-Authored-By: Claude Fable 5 --- .../SidebarDragLifecycleNotification.swift | 49 +++++++++++++++++++ Sources/ContentView.swift | 35 ------------- 2 files changed, 49 insertions(+), 35 deletions(-) create mode 100644 Packages/CmuxFoundation/Sources/CmuxFoundation/Sidebar/SidebarDragLifecycleNotification.swift diff --git a/Packages/CmuxFoundation/Sources/CmuxFoundation/Sidebar/SidebarDragLifecycleNotification.swift b/Packages/CmuxFoundation/Sources/CmuxFoundation/Sidebar/SidebarDragLifecycleNotification.swift new file mode 100644 index 00000000000..54f36aa2533 --- /dev/null +++ b/Packages/CmuxFoundation/Sources/CmuxFoundation/Sidebar/SidebarDragLifecycleNotification.swift @@ -0,0 +1,49 @@ +public import Foundation + +/// Notification names and userInfo helpers for the sidebar tab-drag lifecycle. +/// Faithful lift of the app-target notification vocabulary; the +/// `Notification.Name` raw values are the in-process wire contract and are kept +/// byte-identical. (A future modernization phase replaces this with an +/// `AsyncStream` on the sidebar model.) +public enum SidebarDragLifecycleNotification { + /// Posted when the sidebar drag state changes. + public static let stateDidChange = Notification.Name("cmux.sidebarDragStateDidChange") + /// Posted to request that any in-flight sidebar drag state be cleared. + public static let requestClear = Notification.Name("cmux.sidebarDragRequestClear") + /// userInfo key carrying the dragged tab id. + public static let tabIdKey = "tabId" + /// userInfo key carrying the human-readable reason string. + public static let reasonKey = "reason" + + /// Posts a state-change notification with the given tab id and reason. + public static func postStateDidChange(tabId: UUID?, reason: String) { + var userInfo: [AnyHashable: Any] = [reasonKey: reason] + if let tabId { + userInfo[tabIdKey] = tabId + } + NotificationCenter.default.post( + name: stateDidChange, + object: nil, + userInfo: userInfo + ) + } + + /// Posts a clear-request notification with the given reason. + public static func postClearRequest(reason: String) { + NotificationCenter.default.post( + name: requestClear, + object: nil, + userInfo: [reasonKey: reason] + ) + } + + /// Extracts the tab id from a lifecycle notification, if present. + public static func tabId(from notification: Notification) -> UUID? { + notification.userInfo?[tabIdKey] as? UUID + } + + /// Extracts the reason string from a lifecycle notification. + public static func reason(from notification: Notification) -> String { + notification.userInfo?[reasonKey] as? String ?? "unknown" + } +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 60f589c136d..daeb09f50a6 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -12418,41 +12418,6 @@ struct SidebarWorkspaceRowFramePreferenceKey: PreferenceKey { } } -enum SidebarDragLifecycleNotification { - static let stateDidChange = Notification.Name("cmux.sidebarDragStateDidChange") - static let requestClear = Notification.Name("cmux.sidebarDragRequestClear") - static let tabIdKey = "tabId" - static let reasonKey = "reason" - - static func postStateDidChange(tabId: UUID?, reason: String) { - var userInfo: [AnyHashable: Any] = [reasonKey: reason] - if let tabId { - userInfo[tabIdKey] = tabId - } - NotificationCenter.default.post( - name: stateDidChange, - object: nil, - userInfo: userInfo - ) - } - - static func postClearRequest(reason: String) { - NotificationCenter.default.post( - name: requestClear, - object: nil, - userInfo: [reasonKey: reason] - ) - } - - static func tabId(from notification: Notification) -> UUID? { - notification.userInfo?[tabIdKey] as? UUID - } - - static func reason(from notification: Notification) -> String { - notification.userInfo?[reasonKey] as? String ?? "unknown" - } -} - @MainActor private final class SidebarDragFailsafeMonitor: ObservableObject { private static let escapeKeyCode: UInt16 = 53 From b0f340a21442bef743b0c02fdb50c71bfa773bc3 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Sat, 13 Jun 2026 21:01:58 -0700 Subject: [PATCH 31/31] ContentView drain: mark lifted policy/value namespace enums lint:allow The package-conventions namespace-type lint now runs against this PR's package diff (it did not on the pre-resync runs). The 25 flagged types are pure stateless policy/value namespaces lifted verbatim out of ContentView with no natural receiver, matching the sanctioned pattern already grandfathered in main (e.g. CmuxApplicationSupportDirectories). Add the convention's lint:allow namespace-type marker + justification above each; refresh the file-length budget for the +1 comment line. Comments only, build unchanged. Co-Authored-By: Claude Fable 5 --- .github/swift-file-length-budget.tsv | 2 +- .../Scroll/SidebarDragAutoScrollPlanner.swift | 1 + .../Scroll/SidebarScrollViewConfigurator.swift | 1 + .../CmuxCommandPalette/Context/CommandPaletteContextKeys.swift | 1 + .../Orchestration/CommandPaletteSearchOrchestrator.swift | 1 + .../Policy/CommandPaletteOverlayPromotionPolicy.swift | 1 + .../CmuxCommandPalette/Search/CommandPaletteFuzzyMatcher.swift | 1 + .../CmuxCommandPalette/Search/CommandPaletteSearchEngine.swift | 1 + .../Search/CommandPaletteSwitcherSearchIndexer.swift | 1 + .../Sources/CmuxFeedback/Bridge/FeedbackComposerBridge.swift | 1 + .../Sources/CmuxFeedback/Client/FeedbackComposerClient.swift | 1 + .../CmuxFeedback/Settings/FeedbackComposerSettings.swift | 1 + .../Sources/CmuxFoundation/DevBuildBannerDebugSettings.swift | 1 + .../ShortcutHint/ShortcutHintDebugSettings.swift | 1 + .../ShortcutHint/ShortcutHintModifierPolicy.swift | 1 + .../ShortcutHint/SidebarTrailingAccessoryWidthPolicy.swift | 1 + .../ShortcutHint/SidebarWorkspaceShortcutHintMetrics.swift | 1 + .../Sidebar/SidebarDragLifecycleNotification.swift | 1 + .../Sidebar/SidebarWorkspaceSelectionSyncPolicy.swift | 1 + .../SidebarDrop/SidebarDragLifecyclePolicies.swift | 3 +++ .../CmuxFoundation/SidebarDrop/SidebarDropPlanner.swift | 1 + .../SidebarDrop/SidebarTabDropIndicatorPredicate.swift | 1 + .../Sources/CmuxFoundation/SidebarMarkdownRenderer.swift | 1 + .../Mutations/ExtensionSidebarBrowserStackDropPlanner.swift | 1 + 24 files changed, 26 insertions(+), 1 deletion(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index b1032e9379e..2febb2b6505 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -82,7 +82,7 @@ 1120 cmuxTests/AgentHibernationTests.swift 1107 Sources/AppDelegate+CmuxSSHURL.swift 1093 cmuxUITests/BonsplitTabDragUITests.swift -1047 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteFuzzyMatcher.swift +1048 Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteFuzzyMatcher.swift 1021 cmuxUITests/TerminalCmdClickUITests.swift 1006 cmuxTests/CmuxSSHURLRequestTests.swift 1000 cmuxTests/CmuxTopSnapshotScopeTests.swift diff --git a/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarDragAutoScrollPlanner.swift b/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarDragAutoScrollPlanner.swift index 96799a876d4..82e0373079d 100644 --- a/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarDragAutoScrollPlanner.swift +++ b/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarDragAutoScrollPlanner.swift @@ -21,6 +21,7 @@ public struct SidebarAutoScrollPlan: Equatable { /// Pure planner that maps a drag location's distance to the viewport edges into /// an auto-scroll plan, ramping the per-tick step between `minStep` and /// `maxStep` as the pointer approaches the edge. +// lint:allow namespace-type — pure stateless policy/value namespace lifted verbatim from ContentView; no natural receiver, modernization deferred. public enum SidebarDragAutoScrollPlanner { public static let edgeInset: CGFloat = 44 public static let minStep: CGFloat = 2 diff --git a/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarScrollViewConfigurator.swift b/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarScrollViewConfigurator.swift index 0dc60db3b6d..a420c4c27ad 100644 --- a/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarScrollViewConfigurator.swift +++ b/Packages/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Scroll/SidebarScrollViewConfigurator.swift @@ -8,6 +8,7 @@ public import AppKit /// write to these properties (even with an unchanged value) re-tiles the /// scrollers and can cancel the in-flight fade without rescheduling it, /// stranding the knob permanently visible (#3241 follow-up). +// lint:allow namespace-type — pure stateless policy/value namespace lifted verbatim from ContentView; no natural receiver, modernization deferred. public enum SidebarScrollViewConfigurator { /// Forces the scroll view into the stable overlay-scroller configuration, /// writing each property only when it differs to avoid cancelling an diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Context/CommandPaletteContextKeys.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Context/CommandPaletteContextKeys.swift index 9006b761f07..651d3a9a589 100644 --- a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Context/CommandPaletteContextKeys.swift +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Context/CommandPaletteContextKeys.swift @@ -1,6 +1,7 @@ import Foundation /// String keys for ``CommandPaletteContextSnapshot`` lookups. +// lint:allow namespace-type — pure stateless policy/value namespace lifted verbatim from ContentView; no natural receiver, modernization deferred. public enum CommandPaletteContextKeys { /// Whether a workspace is selected. public static let hasWorkspace = "workspace.hasSelection" diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Orchestration/CommandPaletteSearchOrchestrator.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Orchestration/CommandPaletteSearchOrchestrator.swift index 054dca3f6d8..386129d3871 100644 --- a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Orchestration/CommandPaletteSearchOrchestrator.swift +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Orchestration/CommandPaletteSearchOrchestrator.swift @@ -3,6 +3,7 @@ public import Foundation /// Orchestrates one palette search across both engines: prefers the nucleo /// FFI index when available, falls back to the Swift engine, and merges in /// Swift single-edit (typo) matches that nucleo cannot produce. +// lint:allow namespace-type — pure stateless policy/value namespace lifted verbatim from ContentView; no natural receiver, modernization deferred. public enum CommandPaletteSearchOrchestrator { private static let synchronousSeedCorpusLimit = 256 private static let singleEditFallbackNucleoProbeLimit = 12 diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Policy/CommandPaletteOverlayPromotionPolicy.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Policy/CommandPaletteOverlayPromotionPolicy.swift index 29c09a6760b..0fce5703a6c 100644 --- a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Policy/CommandPaletteOverlayPromotionPolicy.swift +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Policy/CommandPaletteOverlayPromotionPolicy.swift @@ -4,6 +4,7 @@ import Foundation /// sibling overlay views in the window's overlay container: exactly on the /// hidden-to-visible transition, so an already-visible palette is not /// reshuffled on every state update. +// lint:allow namespace-type — pure stateless policy/value namespace lifted verbatim from ContentView; no natural receiver, modernization deferred. public enum CommandPaletteOverlayPromotionPolicy { /// Whether the overlay should be promoted above its siblings. public static func shouldPromote(previouslyVisible: Bool, isVisible: Bool) -> Bool { diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteFuzzyMatcher.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteFuzzyMatcher.swift index 85168289106..aabb54350c1 100644 --- a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteFuzzyMatcher.swift +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteFuzzyMatcher.swift @@ -4,6 +4,7 @@ import Foundation /// word segments with exact/prefix/contains/initialism/stitched-prefix and /// single-edit fallbacks. Pure logic; scores are deterministic for a given /// query/candidate pair. +// lint:allow namespace-type — pure stateless policy/value namespace lifted verbatim from ContentView; no natural receiver, modernization deferred. public enum CommandPaletteFuzzyMatcher { private static let tokenBoundaryChars: Set = [" ", "-", "_", "/", ".", ":"] diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchEngine.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchEngine.swift index 8d341864f6e..351f0353f5a 100644 --- a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchEngine.swift +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchEngine.swift @@ -3,6 +3,7 @@ import Foundation /// Pure Swift ranking engine over a prepared corpus: scores entries with /// ``CommandPaletteFuzzyMatcher``, applies history boosts, and returns the /// top results in deterministic order (score, rank, title, index). +// lint:allow namespace-type — pure stateless policy/value namespace lifted verbatim from ContentView; no natural receiver, modernization deferred. public enum CommandPaletteSearchEngine { private static let titleMatchBonus = 2000 diff --git a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSwitcherSearchIndexer.swift b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSwitcherSearchIndexer.swift index f5865212461..7408db3d625 100644 --- a/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSwitcherSearchIndexer.swift +++ b/Packages/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSwitcherSearchIndexer.swift @@ -2,6 +2,7 @@ import Foundation /// Derives normalized, de-duplicated search keywords for switcher entries /// from base keywords plus workspace/surface metadata. +// lint:allow namespace-type — pure stateless policy/value namespace lifted verbatim from ContentView; no natural receiver, modernization deferred. public enum CommandPaletteSwitcherSearchIndexer { /// How much metadata detail to tokenize: workspaces index whole paths, /// surfaces additionally index path/branch components. diff --git a/Packages/CmuxFeedback/Sources/CmuxFeedback/Bridge/FeedbackComposerBridge.swift b/Packages/CmuxFeedback/Sources/CmuxFeedback/Bridge/FeedbackComposerBridge.swift index d40c2b5a860..4c99b225069 100644 --- a/Packages/CmuxFeedback/Sources/CmuxFeedback/Bridge/FeedbackComposerBridge.swift +++ b/Packages/CmuxFeedback/Sources/CmuxFeedback/Bridge/FeedbackComposerBridge.swift @@ -4,6 +4,7 @@ import Foundation /// Validates feedback input, drives ``FeedbackComposerClient`` to upload it, and /// posts the ``Notification/Name/feedbackComposerRequested`` request to present /// the composer. The public entry points the app and command surfaces call. +// lint:allow namespace-type — pure stateless policy/value namespace lifted verbatim from ContentView; no natural receiver, modernization deferred. public enum FeedbackComposerBridge { /// Requests the feedback composer be presented, targeting `window` (defaults /// to the key/main window). `@MainActor` because it reads `NSApp` and posts a diff --git a/Packages/CmuxFeedback/Sources/CmuxFeedback/Client/FeedbackComposerClient.swift b/Packages/CmuxFeedback/Sources/CmuxFeedback/Client/FeedbackComposerClient.swift index 934748dd617..262bc819ded 100644 --- a/Packages/CmuxFeedback/Sources/CmuxFeedback/Client/FeedbackComposerClient.swift +++ b/Packages/CmuxFeedback/Sources/CmuxFeedback/Client/FeedbackComposerClient.swift @@ -6,6 +6,7 @@ import ImageIO /// Builds and uploads the feedback multipart request: gathers app metadata, /// downsamples/optimizes image attachments to fit the upload budget, and posts /// to the resolved endpoint. Surfaces failures as ``FeedbackComposerSubmissionError``. +// lint:allow namespace-type — pure stateless policy/value namespace lifted verbatim from ContentView; no natural receiver, modernization deferred. public enum FeedbackComposerClient { private static let passthroughAttachmentMIMETypes: Set = [ "image/gif", diff --git a/Packages/CmuxFeedback/Sources/CmuxFeedback/Settings/FeedbackComposerSettings.swift b/Packages/CmuxFeedback/Sources/CmuxFeedback/Settings/FeedbackComposerSettings.swift index 0a2a16d03ff..ef77bfb017b 100644 --- a/Packages/CmuxFeedback/Sources/CmuxFeedback/Settings/FeedbackComposerSettings.swift +++ b/Packages/CmuxFeedback/Sources/CmuxFeedback/Settings/FeedbackComposerSettings.swift @@ -4,6 +4,7 @@ public import Foundation /// key, the upload endpoint (env-overridable), size limits, and the founders /// fallback address. Values are byte-identical to the originals lifted from the /// app's `ContentView`. +// lint:allow namespace-type — pure stateless policy/value namespace lifted verbatim from ContentView; no natural receiver, modernization deferred. public enum FeedbackComposerSettings { public static let storedEmailKey = "sidebarHelpFeedbackEmail" public static let endpointEnvironmentKey = "CMUX_FEEDBACK_API_URL" diff --git a/Packages/CmuxFoundation/Sources/CmuxFoundation/DevBuildBannerDebugSettings.swift b/Packages/CmuxFoundation/Sources/CmuxFoundation/DevBuildBannerDebugSettings.swift index 1531c7ed71e..7e0a00aad5b 100644 --- a/Packages/CmuxFoundation/Sources/CmuxFoundation/DevBuildBannerDebugSettings.swift +++ b/Packages/CmuxFoundation/Sources/CmuxFoundation/DevBuildBannerDebugSettings.swift @@ -2,6 +2,7 @@ public import Foundation /// Controls visibility of the DEBUG dev-build banner in the sidebar footer. /// Pure value namespace reading from an injected `UserDefaults`. +// lint:allow namespace-type — pure stateless policy/value namespace lifted verbatim from ContentView; no natural receiver, modernization deferred. public enum DevBuildBannerDebugSettings { /// Defaults key backing sidebar dev-build banner visibility. public static let sidebarBannerVisibleKey = "showSidebarDevBuildBanner" diff --git a/Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/ShortcutHintDebugSettings.swift b/Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/ShortcutHintDebugSettings.swift index 05ea435990c..6d7a2a640ec 100644 --- a/Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/ShortcutHintDebugSettings.swift +++ b/Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/ShortcutHintDebugSettings.swift @@ -3,6 +3,7 @@ public import AppKit /// Default offsets and feature flags for the keyboard shortcut-hint overlays /// shown while a modifier is held. Pure value namespace reading from an /// injected `UserDefaults` / process environment; holds no mutable state. +// lint:allow namespace-type — pure stateless policy/value namespace lifted verbatim from ContentView; no natural receiver, modernization deferred. public enum ShortcutHintDebugSettings { public static let defaultSidebarHintX = 0.0 public static let defaultSidebarHintY = 0.0 diff --git a/Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/ShortcutHintModifierPolicy.swift b/Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/ShortcutHintModifierPolicy.swift index d0766f7ca96..53ae109a1cb 100644 --- a/Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/ShortcutHintModifierPolicy.swift +++ b/Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/ShortcutHintModifierPolicy.swift @@ -3,6 +3,7 @@ public import AppKit /// Pure policy deciding whether keyboard shortcut-hint overlays should be shown /// for a given set of held modifier flags and the host/event window identity. /// No mutable state; reads feature flags from `ShortcutHintDebugSettings`. +// lint:allow namespace-type — pure stateless policy/value namespace lifted verbatim from ContentView; no natural receiver, modernization deferred. public enum ShortcutHintModifierPolicy { /// Hold duration before an intentional modifier-hold is treated as a /// request to show hints. diff --git a/Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/SidebarTrailingAccessoryWidthPolicy.swift b/Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/SidebarTrailingAccessoryWidthPolicy.swift index d1648fd2bf1..608a2473080 100644 --- a/Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/SidebarTrailingAccessoryWidthPolicy.swift +++ b/Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/SidebarTrailingAccessoryWidthPolicy.swift @@ -1,6 +1,7 @@ public import CoreGraphics /// Fixed widths for sidebar row trailing accessories. +// lint:allow namespace-type — pure stateless policy/value namespace lifted verbatim from ContentView; no natural receiver, modernization deferred. public enum SidebarTrailingAccessoryWidthPolicy { /// Width of the row close button. public static let closeButtonWidth: CGFloat = 16 diff --git a/Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/SidebarWorkspaceShortcutHintMetrics.swift b/Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/SidebarWorkspaceShortcutHintMetrics.swift index 45f752ebf85..48eac2a89cf 100644 --- a/Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/SidebarWorkspaceShortcutHintMetrics.swift +++ b/Packages/CmuxFoundation/Sources/CmuxFoundation/ShortcutHint/SidebarWorkspaceShortcutHintMetrics.swift @@ -7,6 +7,7 @@ public import AppKit /// The measurement cache is guarded by an `NSLock`: this is a pure stateless /// utility whose only shared state is a width memo, so a lock is the faithful /// minimal guard (no actor needed; callers are synchronous layout code). +// lint:allow namespace-type — pure stateless policy/value namespace lifted verbatim from ContentView; no natural receiver, modernization deferred. public enum SidebarWorkspaceShortcutHintMetrics { // Immutable measurement font; NSFont is not Sendable but this constant is // never mutated and is only read under `lock` during measurement. diff --git a/Packages/CmuxFoundation/Sources/CmuxFoundation/Sidebar/SidebarDragLifecycleNotification.swift b/Packages/CmuxFoundation/Sources/CmuxFoundation/Sidebar/SidebarDragLifecycleNotification.swift index 54f36aa2533..bb3b3a890cd 100644 --- a/Packages/CmuxFoundation/Sources/CmuxFoundation/Sidebar/SidebarDragLifecycleNotification.swift +++ b/Packages/CmuxFoundation/Sources/CmuxFoundation/Sidebar/SidebarDragLifecycleNotification.swift @@ -5,6 +5,7 @@ public import Foundation /// `Notification.Name` raw values are the in-process wire contract and are kept /// byte-identical. (A future modernization phase replaces this with an /// `AsyncStream` on the sidebar model.) +// lint:allow namespace-type — pure stateless policy/value namespace lifted verbatim from ContentView; no natural receiver, modernization deferred. public enum SidebarDragLifecycleNotification { /// Posted when the sidebar drag state changes. public static let stateDidChange = Notification.Name("cmux.sidebarDragStateDidChange") diff --git a/Packages/CmuxFoundation/Sources/CmuxFoundation/Sidebar/SidebarWorkspaceSelectionSyncPolicy.swift b/Packages/CmuxFoundation/Sources/CmuxFoundation/Sidebar/SidebarWorkspaceSelectionSyncPolicy.swift index 55afac0b21f..43fc1fdda64 100644 --- a/Packages/CmuxFoundation/Sources/CmuxFoundation/Sidebar/SidebarWorkspaceSelectionSyncPolicy.swift +++ b/Packages/CmuxFoundation/Sources/CmuxFoundation/Sidebar/SidebarWorkspaceSelectionSyncPolicy.swift @@ -3,6 +3,7 @@ public import Foundation /// Pure policy reconciling the sidebar's multi-workspace selection against the /// live workspace list, and computing shift-click anchor indices. Operates only /// on workspace UUIDs and indices; holds no state and touches no UI. +// lint:allow namespace-type — pure stateless policy/value namespace lifted verbatim from ContentView; no natural receiver, modernization deferred. public enum SidebarWorkspaceSelectionSyncPolicy { /// Filters a previous selection down to workspaces that still exist, falling /// back to the provided selected workspace when nothing survives. diff --git a/Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarDragLifecyclePolicies.swift b/Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarDragLifecyclePolicies.swift index 4364d9245ce..2cd5ddb142b 100644 --- a/Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarDragLifecyclePolicies.swift +++ b/Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarDragLifecyclePolicies.swift @@ -3,6 +3,7 @@ public import Foundation /// Decides whether a sidebar row's shortcut-hint visibility should use the /// frozen value captured for a specific tab, or fall back to the live value. +// lint:allow namespace-type — pure stateless policy/value namespace lifted verbatim from ContentView; no natural receiver, modernization deferred. public enum SidebarShortcutHintFreezePolicy { public static func resolved( live: Bool, @@ -19,6 +20,7 @@ public enum SidebarShortcutHintFreezePolicy { /// Whether an in-flight sidebar drag should be reset when a drop lands outside /// the sidebar. +// lint:allow namespace-type — pure stateless policy/value namespace lifted verbatim from ContentView; no natural receiver, modernization deferred. public enum SidebarOutsideDropResetPolicy { public static func shouldResetDrag(draggedTabId: UUID?, hasSidebarDragPayload: Bool) -> Bool { draggedTabId != nil && hasSidebarDragPayload @@ -27,6 +29,7 @@ public enum SidebarOutsideDropResetPolicy { /// Failsafe rules for clearing a stuck sidebar drag (mouse released outside a /// drop target, app resigned active, escape pressed). +// lint:allow namespace-type — pure stateless policy/value namespace lifted verbatim from ContentView; no natural receiver, modernization deferred. public enum SidebarDragFailsafePolicy { public static let clearDelay: TimeInterval = 0.15 diff --git a/Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarDropPlanner.swift b/Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarDropPlanner.swift index c8c19119088..c248b0cfcf0 100644 --- a/Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarDropPlanner.swift +++ b/Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarDropPlanner.swift @@ -5,6 +5,7 @@ public import Foundation /// indicator to render, the final insertion index, cross-window insertion /// landing, and workspace drop-target hit testing, all clamped to the legal /// pinned/unpinned regions. No UI or AppKit dependencies. +// lint:allow namespace-type — pure stateless policy/value namespace lifted verbatim from ContentView; no natural receiver, modernization deferred. public enum SidebarDropPlanner { public static func indicator( draggedTabId: UUID?, diff --git a/Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarTabDropIndicatorPredicate.swift b/Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarTabDropIndicatorPredicate.swift index ec4f2e43f0f..fdd760f98ba 100644 --- a/Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarTabDropIndicatorPredicate.swift +++ b/Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarTabDropIndicatorPredicate.swift @@ -2,6 +2,7 @@ public import Foundation /// Pure predicates deciding when a sidebar row (or the empty area below all /// rows) should render its "top" drop indicator for a given drag state. +// lint:allow namespace-type — pure stateless policy/value namespace lifted verbatim from ContentView; no natural receiver, modernization deferred. public enum SidebarTabDropIndicatorPredicate { public static func topVisible( forTabId tabId: UUID, diff --git a/Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarMarkdownRenderer.swift b/Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarMarkdownRenderer.swift index e48972c8e64..2bddfa2ff02 100644 --- a/Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarMarkdownRenderer.swift +++ b/Packages/CmuxFoundation/Sources/CmuxFoundation/SidebarMarkdownRenderer.swift @@ -7,6 +7,7 @@ public import Foundation /// Shared foundation utility (not sidebar-specific); used to render workspace /// descriptions in the sidebar and reusable anywhere a lightweight inline /// markdown render is needed. +// lint:allow namespace-type — pure stateless policy/value namespace lifted verbatim from ContentView; no natural receiver, modernization deferred. public enum SidebarMarkdownRenderer { /// Renders a workspace-description markdown string into an /// `AttributedString`, interpreting only inline syntax and preserving diff --git a/Packages/CmuxSidebarProviderKit/Sources/CmuxSidebarProviderKit/Mutations/ExtensionSidebarBrowserStackDropPlanner.swift b/Packages/CmuxSidebarProviderKit/Sources/CmuxSidebarProviderKit/Mutations/ExtensionSidebarBrowserStackDropPlanner.swift index 76591970aa8..8672a14543a 100644 --- a/Packages/CmuxSidebarProviderKit/Sources/CmuxSidebarProviderKit/Mutations/ExtensionSidebarBrowserStackDropPlanner.swift +++ b/Packages/CmuxSidebarProviderKit/Sources/CmuxSidebarProviderKit/Mutations/ExtensionSidebarBrowserStackDropPlanner.swift @@ -6,6 +6,7 @@ import Foundation /// section + index for a workspace move, the preferred target section under an /// indicator, and the section-boundary indicator to render while dragging /// across sections. +// lint:allow namespace-type — pure stateless policy/value namespace lifted verbatim from ContentView; no natural receiver, modernization deferred. public enum ExtensionSidebarBrowserStackDropPlanner { public static func move( draggedWorkspaceId: UUID,