diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 3209ceef34a5..105cf339b2b0 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -4,7 +4,7 @@ 32955 CLI/cmux.swift 22576 Sources/TerminalController.swift 19955 Sources/Workspace.swift -19248 Sources/ContentView.swift +19256 Sources/ContentView.swift 18057 Sources/AppDelegate.swift 16539 Sources/GhosttyTerminalView.swift 13608 Sources/Panels/BrowserPanel.swift @@ -127,7 +127,7 @@ 683 Sources/Panels/CodexAppServerSession.swift 681 Sources/Panels/AgentSessionProcessStore.swift 680 Sources/FileExplorerSearchController.swift -669 Packages/CmuxSwiftRender/Sources/CmuxSwiftRender/SwiftViewInterpreter.swift +683 Packages/CmuxSwiftRender/Sources/CmuxSwiftRender/SwiftViewInterpreter.swift 668 cmuxTests/FeedCoordinatorTests.swift 668 cmuxTests/KeyboardShortcutContextTests.swift 654 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift @@ -146,7 +146,7 @@ 596 cmuxTests/CmuxEventBusTests.swift 594 Sources/SessionIndexModels.swift 594 cmuxTests/PortalTabDragRoutingTests.swift -589 Sources/SettingsNavigation.swift +599 Sources/SettingsNavigation.swift 588 cmuxTests/CommandPaletteShortcutCustomizationTests.swift 586 Sources/JSONCParser.swift 585 Sources/Cloud/VMClient.swift @@ -170,7 +170,7 @@ 528 cmuxTests/CLINotifyProcessTestSupport.swift 528 cmuxUITests/AutomationSocketUITests.swift 527 CLI/CLISocketPathResolver.swift -523 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift +531 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift 522 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRuntime.swift 520 CLI/CMUXCLI+AmpExtension.swift 520 cmuxTests/MainWindowVisibilityControllerTests.swift diff --git a/CLI/CMUXCLI+DocsSettings.swift b/CLI/CMUXCLI+DocsSettings.swift index 8a300088e718..05ce0341b8a5 100644 --- a/CLI/CMUXCLI+DocsSettings.swift +++ b/CLI/CMUXCLI+DocsSettings.swift @@ -369,9 +369,10 @@ extension CMUXCLI { docs Print the same output as `cmux docs settings`. Targets: - account, app, terminal, sidebar-appearance, automation, browser, - browser-import, global-hotkey, keyboard-shortcuts, shortcuts, - workspace-colors, cmux-json, json, reset + account, app, terminal, sidebar-appearance, custom-sidebars, + automation, browser, browser-import, global-hotkey, + keyboard-shortcuts, shortcuts, workspace-colors, cmux-json, + json, reset Config file: \(Self.primarySettingsDisplayPath) @@ -404,6 +405,8 @@ extension CMUXCLI { return "terminal" case "sidebar", "sidebar-appearance", "sidebarappearance": return "sidebarAppearance" + case "custom-sidebars", "customsidebars": + return "customSidebars" case "automation": return "automation" case "browser": diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/BetaFeaturesCatalogSection.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/BetaFeaturesCatalogSection.swift index d680c8c585e9..9d0efbc27de9 100644 --- a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/BetaFeaturesCatalogSection.swift +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/BetaFeaturesCatalogSection.swift @@ -38,12 +38,12 @@ public struct BetaFeaturesCatalogSection: SettingCatalogSection { /// Custom sidebars: user/agent-authored sidebars (interpreted Swift or /// JSON) discovered from `~/.config/cmux/sidebars/` and selectable in the - /// sidebar button's provider picker. Defaults off; while off, no custom + /// sidebar button's provider picker. Defaults on; while off, no custom /// sidebar appears in the picker and a persisted custom selection falls /// back to the default workspaces sidebar. public let customSidebars = DefaultsKey( id: "customSidebars.beta.enabled", - defaultValue: false, + defaultValue: true, userDefaultsKey: "customSidebars.beta.enabled" ) diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/CustomSidebarsCatalogSection.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/CustomSidebarsCatalogSection.swift new file mode 100644 index 000000000000..72015e03c3a3 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/CustomSidebarsCatalogSection.swift @@ -0,0 +1,23 @@ +import Foundation + +/// Settings for custom (user/agent-authored) sidebars, the `customSidebars.*` +/// keys. The beta gate that lists custom sidebars in the picker lives in +/// ``BetaFeaturesCatalogSection/customSidebars``; this section holds how a +/// selected custom sidebar behaves. +public struct CustomSidebarsCatalogSection: SettingCatalogSection { + /// Which renderer a selected custom sidebar uses: `inProcess` (default; + /// native in-host SwiftUI with real hover/focus/keyboard) or `remote` + /// (the crash-isolated out-of-process worker for untrusted sources). + /// + /// JSON-backed so it can be flipped by editing `~/.config/cmux/cmux.json`: + /// + /// ```json + /// { "customSidebars": { "renderer": "remote" } } + /// ``` + public let renderer = JSONKey( + id: "customSidebars.renderer", + defaultValue: .inProcess + ) + + public init() {} +} diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/SettingCatalog.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/SettingCatalog.swift index 852091b48441..1cd604e5c404 100644 --- a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/SettingCatalog.swift +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/SettingCatalog.swift @@ -37,6 +37,8 @@ public struct SettingCatalog: SettingCatalogSection { /// Settings for Mobile pairing and sync. public let mobile = MobileCatalogSection() public let betaFeatures = BetaFeaturesCatalogSection() + /// Settings for custom (user/agent-authored) sidebars (the `customSidebars.*` keys). + public let customSidebars = CustomSidebarsCatalogSection() public let shortcuts = KeyboardShortcutsCatalogSection() public let integrations = IntegrationsCatalogSection() public let account = AccountCatalogSection() diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Values/CustomSidebarRendererMode.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Values/CustomSidebarRendererMode.swift new file mode 100644 index 000000000000..ce92b64ae734 --- /dev/null +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Values/CustomSidebarRendererMode.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Where a custom sidebar's interpreted source is rendered. +/// +/// Stored under the catalog entry ``CustomSidebarsCatalogSection/renderer`` +/// (`customSidebars.renderer` in `~/.config/cmux/cmux.json`). The raw values +/// are the on-disk strings, so they must not be renamed without a migration. +public enum CustomSidebarRendererMode: String, CaseIterable, Sendable, SettingCodable { + /// The containment lane: an out-of-process render worker + /// interprets and renders the file; the host only composites the worker's + /// remote layer, so an interpreter fault cannot crash the host. Input is + /// limited to forwarded clicks (no hover, focus, or keyboard). + case remote + + /// The default lane: the file is interpreted and rendered as real + /// SwiftUI in the host process, gaining native input (hover, focus, + /// keyboard) and same-frame resize. A renderer fault shares the host + /// process, so only use this for sidebars you authored yourself. + case inProcess +} diff --git a/Packages/CmuxSettings/Tests/CmuxSettingsTests/CustomSidebarRendererSettingTests.swift b/Packages/CmuxSettings/Tests/CmuxSettingsTests/CustomSidebarRendererSettingTests.swift new file mode 100644 index 000000000000..c2e9da7c0c3b --- /dev/null +++ b/Packages/CmuxSettings/Tests/CmuxSettingsTests/CustomSidebarRendererSettingTests.swift @@ -0,0 +1,52 @@ +import Foundation +import Testing +@testable import CmuxSettings + +/// Behavior of the `customSidebars.renderer` setting through the real JSON +/// store: the on-disk strings users put in `~/.config/cmux/cmux.json` must +/// decode to the right renderer, and anything else must fall back to the +/// default (native in-process rendering). +@Suite("customSidebars.renderer") +struct CustomSidebarRendererSettingTests { + private func makeStore() -> (JSONConfigStore, URL) { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-renderer-tests-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("cmux.json", isDirectory: false) + return (JSONConfigStore(fileURL: fileURL), fileURL) + } + + @Test func defaultsToInProcessWhenUnset() async { + let (store, _) = makeStore() + let value = await store.value(for: SettingCatalog().customSidebars.renderer) + #expect(value == .inProcess) + } + + @Test func readsRemoteFromHandEditedConfigFile() async throws { + let (store, fileURL) = makeStore() + try #"{ "customSidebars": { "renderer": "remote" } }"# + .write(to: fileURL, atomically: true, encoding: .utf8) + let value = await store.value(for: SettingCatalog().customSidebars.renderer) + #expect(value == .remote) + } + + @Test func unknownRawValueFallsBackToTheDefault() async throws { + let (store, fileURL) = makeStore() + try #"{ "customSidebars": { "renderer": "yolo" } }"# + .write(to: fileURL, atomically: true, encoding: .utf8) + let value = await store.value(for: SettingCatalog().customSidebars.renderer) + #expect(value == .inProcess) + } + + @Test func roundTripsThroughTheStore() async throws { + let (store, fileURL) = makeStore() + try await store.set(.remote, for: SettingCatalog().customSidebars.renderer) + let value = await store.value(for: SettingCatalog().customSidebars.renderer) + #expect(value == .remote) + + // The on-disk representation is the raw string, hand-editable. + let parsed = try JSONSerialization.jsonObject(with: Data(contentsOf: fileURL)) as? [String: Any] + let section = parsed?["customSidebars"] as? [String: Any] + #expect(section?["renderer"] as? String == "remote") + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift index f6b7062a1080..d0d062257d6c 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift @@ -102,6 +102,10 @@ extension Array where Element == CuratedSettingEntry { .init(section: .mobile, id: "iOSPairingPort", title: String(localized: "settings.mobile.port", defaultValue: "Pairing Port"), synonyms: "mobile.iOSPairingHost.port ios iphone mobile pairing port tcp listener firewall conflict"), .init(section: .mobile, id: "iOSPairingDisplayName", title: String(localized: "settings.mobile.displayName", defaultValue: "Display Name"), synonyms: "mobile.iOSPairingHost.displayName ios iphone mobile pairing display name mac hostname device label"), + // Custom Sidebars + .init(section: .customSidebars, id: "enabled", title: String(localized: "settings.customSidebars.enabled", defaultValue: "Show Custom Sidebars"), synonyms: "custom sidebars enable show vibe swift json interpreted picker beta"), + .init(section: .customSidebars, id: "renderer", title: String(localized: "settings.customSidebars.renderer", defaultValue: "Renderer"), synonyms: "customSidebars.renderer renderer in-process in app remote worker isolated process hover focus typing input"), + // Beta .init(section: .betaFeatures, id: "feed", title: "Feed", synonyms: "feed right sidebar agent decisions permissions questions approval beta unstable"), .init(section: .betaFeatures, id: "dock", title: "Dock", synonyms: "dock right sidebar terminal controls tui beta unstable"), diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSectionID.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSectionID.swift index a64b0687caa4..a14b1b490bd4 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSectionID.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSectionID.swift @@ -17,6 +17,8 @@ public enum SettingsSectionID: String, CaseIterable, Identifiable, Sendable, Has /// Mobile pairing and sync settings. case mobile case sidebarAppearance + /// User/agent-authored custom sidebars: enable gate and renderer choice. + case customSidebars case betaFeatures case automation case browser @@ -38,6 +40,7 @@ public enum SettingsSectionID: String, CaseIterable, Identifiable, Sendable, Has case .textBox: return String(localized: "settings.section.textBox", defaultValue: "TextBox (Beta)") case .mobile: return String(localized: "settings.section.mobile", defaultValue: "Mobile") case .sidebarAppearance: return "Sidebar" + case .customSidebars: return String(localized: "settings.section.customSidebars", defaultValue: "Custom Sidebars") case .betaFeatures: return "Beta Features" case .automation: return "Automation" case .browser: return "Browser" @@ -59,6 +62,7 @@ public enum SettingsSectionID: String, CaseIterable, Identifiable, Sendable, Has case .textBox: return "textformat" case .mobile: return "iphone" case .sidebarAppearance: return "sidebar.left" + case .customSidebars: return "sidebar.squares.left" case .betaFeatures: return "exclamationmark.triangle" case .automation: return "wand.and.sparkles" case .browser: return "globe" @@ -82,6 +86,7 @@ public enum SettingsSectionID: String, CaseIterable, Identifiable, Sendable, Has case .textBox: return "textbox text box rich input prompt default new terminal workspace split tab focus show beta" case .mobile: return "ios iphone ipad mobile pairing local network sync" case .sidebarAppearance: return "sidebar details branches material terminal background" + case .customSidebars: return "custom sidebars vibe swift json interpreted renderer in-process remote worker isolated" case .betaFeatures: return "beta experimental unstable feed dock right sidebar" case .automation: return "socket integrations hooks ports claude cursor gemini" case .browser: return "search engine links history theme" diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift index 8b4b55f4a8af..4415ea9c447f 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift @@ -462,6 +462,14 @@ public struct SettingsWindowRoot: View { SidebarSection(defaultsStore: defaultsStore, catalog: catalog, hostActions: hostActions) .id(anchorID(for: .sidebarAppearance)) + CustomSidebarsSection( + defaultsStore: defaultsStore, + jsonStore: jsonStore, + catalog: catalog, + errorLog: runtime.errorLog + ) + .id(anchorID(for: .customSidebars)) + BetaFeaturesSection(defaultsStore: defaultsStore, catalog: catalog) .id(anchorID(for: .betaFeatures)) diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/CustomSidebarsSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/CustomSidebarsSection.swift new file mode 100644 index 000000000000..5f30b213704f --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/CustomSidebarsSection.swift @@ -0,0 +1,77 @@ +import CmuxSettings +import SwiftUI + +/// **Custom Sidebars** section — the user/agent-authored sidebar +/// surface: the enable toggle (shared with the Beta Features gate) and +/// the renderer picker choosing between the crash-isolated helper +/// process and native in-app rendering. +@MainActor +public struct CustomSidebarsSection: View { + @State private var enabled: DefaultsValueModel + @State private var renderer: JSONValueModel + + public init( + defaultsStore: UserDefaultsSettingsStore, + jsonStore: JSONConfigStore, + catalog: SettingCatalog, + errorLog: SettingsErrorLog + ) { + _enabled = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.betaFeatures.customSidebars)) + _renderer = State(initialValue: JSONValueModel( + store: jsonStore, + key: catalog.customSidebars.renderer, + errorLog: errorLog + )) + } + + public var body: some View { + Group { + SettingsSectionHeader(String(localized: "settings.section.customSidebars", defaultValue: "Custom Sidebars"), section: .customSidebars) + SettingsCard { + enabledRow + SettingsCardDivider() + rendererRow + SettingsCardDivider() + SettingsCardNote( + String(localized: "settings.customSidebars.note", defaultValue: "Custom sidebars are SwiftUI-style files in ~/.config/cmux/sidebars. Pick one from the sidebar toggle button's right-click menu; edits hot-reload on save. Use the in-app renderer only for sidebars you trust.") + ) + } + } + } + + @ViewBuilder + private var enabledRow: some View { + SettingsCardRow( + configurationReview: .settingsOnly, + searchAnchorID: "setting:customSidebars:enabled", + String(localized: "settings.customSidebars.enabled", defaultValue: "Show Custom Sidebars"), + subtitle: enabled.current + ? String(localized: "settings.customSidebars.enabled.subtitleOn", defaultValue: "Lists your sidebars from ~/.config/cmux/sidebars in the sidebar picker.") + : String(localized: "settings.customSidebars.enabled.subtitleOff", defaultValue: "Hides custom sidebars from the sidebar picker until you enable them here.") + ) { + Toggle("", isOn: Binding(get: { enabled.current }, set: { enabled.set($0) })) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsCustomSidebarsEnabledToggle") + } + } + + @ViewBuilder + private var rendererRow: some View { + SettingsCardRow( + configurationReview: .json("customSidebars.renderer"), + String(localized: "settings.customSidebars.renderer", defaultValue: "Renderer"), + subtitle: renderer.current.rendererDescription + ) { + Picker("", selection: Binding(get: { renderer.current }, set: { renderer.set($0) })) { + ForEach(CustomSidebarRendererMode.uiCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) + } + } + .labelsHidden() + .pickerStyle(.menu) + .disabled(!enabled.current) + .accessibilityIdentifier("SettingsCustomSidebarsRendererPicker") + } + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Values/CustomSidebarRendererMode+Display.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Values/CustomSidebarRendererMode+Display.swift new file mode 100644 index 000000000000..537e569da261 --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Values/CustomSidebarRendererMode+Display.swift @@ -0,0 +1,31 @@ +import CmuxSettings +import Foundation + +/// UI-facing labels for ``CustomSidebarRendererMode``, shown by the +/// Custom Sidebars settings section's renderer picker. +extension CustomSidebarRendererMode { + /// Canonical UI ordering of the modes in the picker. + static var uiCases: [CustomSidebarRendererMode] { + [.remote, .inProcess] + } + + /// Short label shown in the renderer picker. + var displayName: String { + switch self { + case .remote: + return String(localized: "customSidebarRenderer.remote.name", defaultValue: "Isolated process") + case .inProcess: + return String(localized: "customSidebarRenderer.inProcess.name", defaultValue: "In-app (full input)") + } + } + + /// One-sentence row subtitle explaining the tradeoff. + var rendererDescription: String { + switch self { + case .remote: + return String(localized: "customSidebarRenderer.remote.description", defaultValue: "Renders in a crash-isolated helper process. Clicks only: no hover, focus, or typing.") + case .inProcess: + return String(localized: "customSidebarRenderer.inProcess.description", defaultValue: "Renders as native SwiftUI inside cmux with hover, focus, and typing. A faulty sidebar shares the app process.") + } + } +} diff --git a/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift b/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift index 1452d17526b4..74d1686c8e3d 100644 --- a/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift +++ b/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift @@ -74,6 +74,7 @@ struct SettingsRowAnchorResolutionTests { "browser.showSearchSuggestions", "browser.theme", "browser.urlsToAlwaysOpenExternally", + "customSidebars.renderer", "fileEditor.wordWrap", "notifications.command", "notifications.dockBadge", @@ -132,6 +133,7 @@ struct SettingsRowAnchorResolutionTests { "setting:betaFeatures:feed", "setting:betaFeatures:dock", "setting:betaFeatures:customSidebars", + "setting:customSidebars:enabled", "setting:browser:history", "setting:browser:http-allowlist", "setting:workspaceColors:palette", diff --git a/Packages/CmuxSidebarInterpreterService/Package.swift b/Packages/CmuxSidebarInterpreterService/Package.swift index 76538ac9ae49..e67b278c0243 100644 --- a/Packages/CmuxSidebarInterpreterService/Package.swift +++ b/Packages/CmuxSidebarInterpreterService/Package.swift @@ -73,5 +73,12 @@ let package = Package( .swiftLanguageMode(.v6), ] ), + .testTarget( + name: "CmuxSidebarRemoteRenderTests", + dependencies: ["CmuxSidebarRemoteRender"], + swiftSettings: [ + .swiftLanguageMode(.v6), + ] + ), ] ) diff --git a/Packages/CmuxSidebarInterpreterService/Sources/CmuxSidebarRemoteRender/CustomSidebarSurface.swift b/Packages/CmuxSidebarInterpreterService/Sources/CmuxSidebarRemoteRender/CustomSidebarSurface.swift new file mode 100644 index 000000000000..37adf3cf67f3 --- /dev/null +++ b/Packages/CmuxSidebarInterpreterService/Sources/CmuxSidebarRemoteRender/CustomSidebarSurface.swift @@ -0,0 +1,83 @@ +import CmuxSidebarInterpreterClient +import CmuxSwiftRender +import CmuxSwiftRenderUI +import SwiftUI + +/// The single mount seam for a selected custom sidebar: renders the file +/// through either the remote (out-of-process worker) renderer or the +/// in-process renderer, switching live when the choice changes. +/// +/// `remote` is the containment lane: the worker process interprets and +/// renders the file, the host only composites its layer and forwards clicks, +/// so an interpreter fault cannot crash the host. The cost is input fidelity +/// (no hover, focus, or keyboard) and repaint latency. +/// +/// In-process mounts ``CmuxSwiftRenderUI/CustomSidebarView`` directly: real +/// SwiftUI in the host window, so native hover/focus/keyboard and same-frame +/// resize work, at the price of sharing the host process with the +/// interpreter. The host chooses via the `customSidebars.renderer` setting +/// (see `CustomSidebarsCatalogSection`); this view stays settings-agnostic +/// and just takes the resolved choice, so the package needs no settings +/// dependency and tests can drive both branches directly. +public struct CustomSidebarSurface: View { + private let fileURL: URL + private let dataContext: [String: SwiftValue] + private let dispatch: SidebarActionDispatch + private let contentInsets: CustomSidebarContentInsets + private let rendersInProcess: Bool + @Binding private var client: RenderWorkerClient? + + /// Creates the surface. + /// + /// - Parameters: + /// - fileURL: The `.swift` or `.json` sidebar file to render and watch. + /// - dataContext: Live, read-only values the interpreter binds. + /// - dispatch: Runs button/tap actions against the host command surface. + /// - contentInsets: Top/bottom scroll insets for the host chrome. + /// - rendersInProcess: `true` (the default) mounts the in-process + /// renderer; `false` mounts the out-of-process worker. + /// - client: Window-owned worker client storage for the remote lane, + /// so provider switches reuse the live worker instead of paying a + /// spawn-and-handshake blank frame (see ``RemoteCustomSidebarHost``). + public init( + fileURL: URL, + dataContext: [String: SwiftValue], + dispatch: SidebarActionDispatch, + contentInsets: CustomSidebarContentInsets = .zero, + rendersInProcess: Bool = true, + client: Binding + ) { + self.fileURL = fileURL + self.dataContext = dataContext + self.dispatch = dispatch + self.contentInsets = contentInsets + self.rendersInProcess = rendersInProcess + self._client = client + } + + public var body: some View { + if rendersInProcess { + // `.id(fileURL)` per CustomSidebarView's contract: its @State + // model is keyed to the file it was created with, so switching + // providers must rebuild it against the new file. + CustomSidebarView( + fileURL: fileURL, + dataContext: dataContext, + dispatch: dispatch, + contentInsets: contentInsets + ) + .id(fileURL) + } else { + // No `.id(fileURL)` here: the worker swaps files in place on the + // next scene message, so remounting the surface would only flash + // the previous sidebar's pixels during the switch. + RemoteCustomSidebarHost( + fileURL: fileURL, + dataContext: dataContext, + dispatch: dispatch, + contentInsets: contentInsets, + client: $client + ) + } + } +} diff --git a/Packages/CmuxSidebarInterpreterService/Sources/CmuxSidebarRemoteRender/RemoteWorkerDisplayPump.swift b/Packages/CmuxSidebarInterpreterService/Sources/CmuxSidebarRemoteRender/RemoteWorkerDisplayPump.swift new file mode 100644 index 000000000000..2a35fa7f36bc --- /dev/null +++ b/Packages/CmuxSidebarInterpreterService/Sources/CmuxSidebarRemoteRender/RemoteWorkerDisplayPump.swift @@ -0,0 +1,103 @@ +import AppKit +import QuartzCore + +/// Display-refresh-driven pump for the worker's never-ordered window. +/// +/// Dirtiness signals (see ``RemoteWorkerHostingView`` and +/// ``RemoteWorkerWindow``) arm a `CADisplayLink`; each tick runs the +/// coordinator's pump once, and the first clean tick pauses the link, so an +/// idle worker has no periodic wakeups at all. Decisions live in +/// ``RenderPumpGate``; this class only owns the link plumbing. +/// +/// The link comes from `NSScreen.displayLink(target:selector:)` (macOS 14+, +/// the non-deprecated `CVDisplayLink` replacement). It is deliberately bound +/// to a screen, not to the worker's view or window: the worker's window is +/// never on any display, so a view- or window-bound link would stay paused +/// forever. The screen choice only sets the tick rate; the commit target is +/// the remote context, which the host composites on whatever display it is +/// actually on. +@MainActor +final class RemoteWorkerDisplayPump: NSObject { + private let onPump: @MainActor () -> Void + private var gate = RenderPumpGate() + private var link: CADisplayLink? + private var screenObserver: NSObjectProtocol? + + /// Creates a pump that invokes `onPump` at most once per display refresh + /// while dirty. `onPump` must end by calling ``pumpCompleted()`` (the + /// coordinator's pump does). + init(onPump: @escaping @MainActor () -> Void) { + self.onPump = onPump + super.init() + // Displays can come and go; a link created against an unplugged + // screen stops ticking silently. Drop it and let the next dirtiness + // signal rebuild against the current screen. + screenObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didChangeScreenParametersNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.rebuildLinkIfNeeded() + } + } + } + + /// Records an invalidation; resumes the display link when the gate was + /// clean. + func noteInvalidation() { + guard gate.markDirty() else { return } + resumeLink() + } + + /// Tells the gate a pump's commit landed (explicit message pumps and + /// tick pumps both flush everything marked dirty so far), and parks the + /// link immediately so a commit's own invalidation noise (layout marking + /// the view dirty mid-pump) does not buy a throwaway wakeup. + func pumpCompleted() { + gate.pumpCompleted() + link?.isPaused = true + } + + /// Stops the link and releases its target retain. The worker normally + /// lives for the whole process, but tests and future owners get a clean + /// teardown. + func invalidate() { + link?.invalidate() + link = nil + if let screenObserver { + NotificationCenter.default.removeObserver(screenObserver) + self.screenObserver = nil + } + } + + @objc private func tick(_ link: CADisplayLink) { + switch gate.tickAction() { + case .pump: + onPump() + case .pause: + link.isPaused = true + } + } + + private func resumeLink() { + if link == nil { + // The window is never on a screen, so anchor the link to the + // main screen (first screen as a faceless-process fallback). + guard let screen = NSScreen.main ?? NSScreen.screens.first else { return } + let link = screen.displayLink(target: self, selector: #selector(tick(_:))) + link.add(to: .main, forMode: .common) + self.link = link + } + link?.isPaused = false + } + + private func rebuildLinkIfNeeded() { + guard link != nil else { return } + link?.invalidate() + link = nil + if gate.isDirty { + resumeLink() + } + } +} diff --git a/Packages/CmuxSidebarInterpreterService/Sources/CmuxSidebarRemoteRender/RemoteWorkerHostingView.swift b/Packages/CmuxSidebarInterpreterService/Sources/CmuxSidebarRemoteRender/RemoteWorkerHostingView.swift new file mode 100644 index 000000000000..b225f34fd8aa --- /dev/null +++ b/Packages/CmuxSidebarInterpreterService/Sources/CmuxSidebarRemoteRender/RemoteWorkerHostingView.swift @@ -0,0 +1,34 @@ +import AppKit +import SwiftUI + +/// The worker's hosting view: surfaces AppKit invalidation signals so the +/// coordinator's display pump can commit them. +/// +/// In the never-ordered window, SwiftUI/AppKit schedule layout and display +/// work that no display cycle will ever run. Host messages pump explicitly, +/// but work scheduled *between* messages (SwiftUI re-rendering from its own +/// state, deferred display passes) only shows up as `needsLayout`/ +/// `needsDisplay` flips here (or as the window's `viewsNeedDisplay`, for +/// descendant views). Forwarding those flips lets the pump turn them into +/// real commits instead of letting them ride the next scene tick. +final class RemoteWorkerHostingView: NSHostingView { + /// Fired whenever this view is marked as needing layout or display. + var onInvalidation: (@MainActor () -> Void)? + + override var needsLayout: Bool { + didSet { + if needsLayout { onInvalidation?() } + } + } + + override var needsDisplay: Bool { + didSet { + if needsDisplay { onInvalidation?() } + } + } + + override func setNeedsDisplay(_ invalidRect: NSRect) { + super.setNeedsDisplay(invalidRect) + onInvalidation?() + } +} diff --git a/Packages/CmuxSidebarInterpreterService/Sources/CmuxSidebarRemoteRender/RemoteWorkerWindow.swift b/Packages/CmuxSidebarInterpreterService/Sources/CmuxSidebarRemoteRender/RemoteWorkerWindow.swift index d098deaf7e60..5976db7c4e31 100644 --- a/Packages/CmuxSidebarInterpreterService/Sources/CmuxSidebarRemoteRender/RemoteWorkerWindow.swift +++ b/Packages/CmuxSidebarInterpreterService/Sources/CmuxSidebarRemoteRender/RemoteWorkerWindow.swift @@ -9,8 +9,20 @@ import AppKit /// layout and coordinate spaces; forwarded input is hit-tested geometrically, /// not routed through the window. final class RemoteWorkerWindow: NSWindow { + /// Fired whenever a descendant view marks itself as needing display + /// (AppKit aggregates `setNeedsDisplay` from any view in the window + /// here). With no display cycle to consume the flag, this signal is how + /// between-message invalidations reach the coordinator's display pump. + var onViewsNeedDisplay: (@MainActor () -> Void)? + /// Allow future focus forwarding; nothing orders this window in, so key /// status never affects the user's real windows. override var canBecomeKey: Bool { true } override var canBecomeMain: Bool { true } + + override var viewsNeedDisplay: Bool { + didSet { + if viewsNeedDisplay { onViewsNeedDisplay?() } + } + } } diff --git a/Packages/CmuxSidebarInterpreterService/Sources/CmuxSidebarRemoteRender/RenderPumpGate.swift b/Packages/CmuxSidebarInterpreterService/Sources/CmuxSidebarRemoteRender/RenderPumpGate.swift new file mode 100644 index 000000000000..a14b8fb35543 --- /dev/null +++ b/Packages/CmuxSidebarInterpreterService/Sources/CmuxSidebarRemoteRender/RenderPumpGate.swift @@ -0,0 +1,48 @@ +/// The pure decision core of the render worker's display-refresh pump. +/// +/// The worker's window is never ordered onto the screen, so AppKit/SwiftUI +/// have no display-link driver of their own: every visible change must be +/// pushed through an explicit pump (layout + `CATransaction.flush`). Host +/// messages pump synchronously, but invalidations that arrive *outside* a +/// host message (SwiftUI scheduling a re-render from its own state, AppKit +/// marking views dirty for scroller chrome, deferred display work) would +/// otherwise sit uncommitted until the next 1 s scene tick. +/// +/// The gate turns those invalidations into at-most-one pump per display +/// refresh, and idles at zero cost: dirtiness arms it (resuming the paused +/// display link), a tick pumps while armed, and the first clean tick pauses +/// the link again — no timers, no polling, no per-frame work while idle. +struct RenderPumpGate { + /// What a display-link tick should do. + enum TickAction: Equatable { + /// Something was invalidated since the last pump: pump now (and call + /// ``pumpCompleted()`` once the commit lands). + case pump + /// Nothing dirty since the last frame: pause the display link. + case pause + } + + /// Whether an invalidation is awaiting a pump. + private(set) var isDirty = false + + /// Records an invalidation. Returns `true` when the gate transitioned + /// from clean to dirty, meaning the (paused) display link must be + /// resumed; `false` when a pump is already scheduled. + mutating func markDirty() -> Bool { + let wasClean = !isDirty + isDirty = true + return wasClean + } + + /// Decides one display-link tick. + func tickAction() -> TickAction { + isDirty ? .pump : .pause + } + + /// A pump committed the layer tree (explicitly from a host message, or + /// from a tick). Everything marked dirty before or during that pump was + /// flushed by its commit, so the gate returns to clean. + mutating func pumpCompleted() { + isDirty = false + } +} diff --git a/Packages/CmuxSidebarInterpreterService/Sources/CmuxSidebarRemoteRender/RenderWorkerCoordinator.swift b/Packages/CmuxSidebarInterpreterService/Sources/CmuxSidebarRemoteRender/RenderWorkerCoordinator.swift index 3b90d891e460..9bdebc80eace 100644 --- a/Packages/CmuxSidebarInterpreterService/Sources/CmuxSidebarRemoteRender/RenderWorkerCoordinator.swift +++ b/Packages/CmuxSidebarInterpreterService/Sources/CmuxSidebarRemoteRender/RenderWorkerCoordinator.swift @@ -3,6 +3,7 @@ import CmuxSidebarInterpreterClient import CmuxSwiftRender import CmuxSwiftRenderUI import Observation +import QuartzCore import SwiftUI /// The render worker's main-actor state machine: owns the offscreen surface @@ -26,7 +27,16 @@ final class RenderWorkerCoordinator { private var remoteContext: RemoteRenderContext? private var window: RemoteWorkerWindow? - private var hosting: NSHostingView? + private var hosting: RemoteWorkerHostingView? + + /// Commits invalidations that arrive between host messages (SwiftUI + /// scheduling its own re-render, AppKit display passes) at display + /// refresh, instead of letting them ride the next 1 s scene tick. Idles + /// paused; armed by the window/hosting dirtiness signals wired in + /// `ensureSurface()`. + private lazy var displayPump = RemoteWorkerDisplayPump { [weak self] in + self?.pump(reason: "displaylink") + } /// Tappable regions of the current render, in the root coordinate space /// (top-left origin), refreshed by the root view's preference observer. private var tapTargets: [SidebarTapTarget] = [] @@ -43,15 +53,16 @@ final class RenderWorkerCoordinator { private var geometry = RenderSurfaceGeometry(width: 280, height: 600, scale: 2) private var swiftRender: RenderNode? private var hasRendered = false + /// The state the most recent `refresh()` actually put on screen (which may + /// be `lastGoodState`, not `model.state`, while a broken save is on disk). + /// Geometry republishes reuse it so a drag-resize never flips a last-good + /// sticky render back to an error state. + private var displayedState: CustomSidebarModel.State? /// The most recent file state that produced a working view, kept so a /// broken mid-edit save (or an atomic save's transient delete) does NOT /// replace a working sidebar. Reset when the selected file changes. private var lastGoodState: CustomSidebarModel.State? private var lastGoodRender: RenderNode? - /// State currently published to SwiftUI. This may intentionally differ from - /// `model.state` while a transient missing/failed file keeps last-good - /// content on screen. - private var displayedState: CustomSidebarModel.State? /// Sends interpreted-button actions back to the host for dispatch. private lazy var dispatch = SidebarActionDispatch { [weak self] action in @@ -70,7 +81,8 @@ final class RenderWorkerCoordinator { private func debugLog(_ message: @autoclosure () -> String) { guard debugEnabled else { return } - FileHandle.standardError.write(Data("render-worker: \(message())\n".utf8)) + let timestamp = String(format: "%.3f", CACurrentMediaTime() * 1000) + FileHandle.standardError.write(Data("render-worker: [t=\(timestamp)ms] \(message())\n".utf8)) } /// Applies one host message. Called from a single FIFO consumer, so @@ -100,10 +112,13 @@ final class RenderWorkerCoordinator { remoteContext = context let frame = NSRect(x: 0, y: 0, width: geometry.width, height: geometry.height) - let hosting = NSHostingView(rootView: currentContent()) + let hosting = RemoteWorkerHostingView(rootView: currentContent()) // The host dictates the surface size; don't let SwiftUI fight it. hosting.sizingOptions = [] hosting.frame = frame + hosting.onInvalidation = { [weak self] in + self?.displayPump.noteInvalidation() + } let window = RemoteWorkerWindow( contentRect: frame, @@ -112,6 +127,9 @@ final class RenderWorkerCoordinator { defer: false ) window.isReleasedWhenClosed = false + window.onViewsNeedDisplay = { [weak self] in + self?.displayPump.noteInvalidation() + } window.contentView = hosting hosting.wantsLayer = true @@ -160,13 +178,16 @@ final class RenderWorkerCoordinator { let size = NSSize(width: geometry.width, height: geometry.height) window.setContentSize(size) hosting.frame = NSRect(origin: .zero, size: size) - // Republish the root view: with no display link in the never-ordered - // window, a frame change alone does not re-render the SwiftUI - // content, so resizes showed stretched stale pixels until the next - // scene tick repainted (~1s). Reuses the cached render; nothing is - // re-interpreted here. + // Republish the root view so SwiftUI re-evaluates the tree at the new + // size inside THIS pump. Resizing the hosting view alone only marks + // AppKit layout dirty; SwiftUI's own render update would otherwise + // wait for a display cycle the never-ordered window never runs, + // leaving the repaint to the host's next 1 s scene tick. Reuses the + // cached interpretation and the displayed (possibly last-good) state; + // nothing is re-interpreted here. hosting.rootView = currentContent(state: displayedState) - pump() + debugLog("geometry applied \(Int(geometry.width))x\(Int(geometry.height))@\(geometry.scale) rootView republished") + pump(reason: "geometry") } /// Re-arms Observation on the model so disk reloads (kqueue → model state @@ -233,13 +254,14 @@ final class RenderWorkerCoordinator { } displayedState = displayState hosting.rootView = currentContent(state: displayState) - pump() + debugLog("rootView republished (scene refresh)") + pump(reason: "refresh") } private func currentContent(state: CustomSidebarModel.State? = nil) -> RemoteWorkerRootView { RemoteWorkerRootView( content: CustomSidebarContentView( - state: state ?? model?.state ?? .missing, + state: state ?? displayedState ?? model?.state ?? .missing, swiftRender: swiftRender, hasRenderedSwift: hasRendered, dispatch: dispatch, @@ -247,6 +269,9 @@ final class RenderWorkerCoordinator { ), onTapTargetsChange: { [weak self] targets in self?.tapTargets = targets + self?.debugLog( + "tap targets updated count=\(targets.count) maxX=\(Int(targets.map(\.frame.maxX).max() ?? 0))" + ) } ) } @@ -254,8 +279,9 @@ final class RenderWorkerCoordinator { /// Forces the offscreen view tree through layout and commits the layer /// tree to the window server. The explicit flush is the worker's display /// driver — there is no on-screen window to drive one. - private func pump() { + private func pump(reason: StaticString = "message") { guard let window, let hosting else { return } + let start = CACurrentMediaTime() window.layoutIfNeeded() hosting.layoutSubtreeIfNeeded() if let layer = hosting.layer, geometry.scale != 1 { @@ -271,6 +297,12 @@ final class RenderWorkerCoordinator { remoteContext.layer = layer } CATransaction.flush() + // This commit flushed everything invalidated so far; let the display + // pump pause instead of re-pumping it on the next tick. + displayPump.pumpCompleted() + debugLog( + "pump committed reason=\(reason) bounds=\(Int(hosting.bounds.width))x\(Int(hosting.bounds.height)) took=\(String(format: "%.2f", (CACurrentMediaTime() - start) * 1000))ms" + ) } // MARK: - Input diff --git a/Packages/CmuxSidebarInterpreterService/Tests/CmuxSidebarRemoteRenderTests/RenderPumpGateTests.swift b/Packages/CmuxSidebarInterpreterService/Tests/CmuxSidebarRemoteRenderTests/RenderPumpGateTests.swift new file mode 100644 index 000000000000..51de3866a99c --- /dev/null +++ b/Packages/CmuxSidebarInterpreterService/Tests/CmuxSidebarRemoteRenderTests/RenderPumpGateTests.swift @@ -0,0 +1,76 @@ +import Testing +@testable import CmuxSidebarRemoteRender + +/// Behavior of the display pump's dirtiness gate: invalidations arm it (and +/// say when the paused display link must resume), dirty ticks pump, the pump's +/// commit cleans it, and the first clean tick pauses the link so an idle +/// worker costs nothing. +@Suite struct RenderPumpGateTests { + @Test func firstInvalidationDemandsALinkResume() { + var gate = RenderPumpGate() + let mustResume = gate.markDirty() + #expect(mustResume) + #expect(gate.isDirty) + } + + @Test func invalidationsCoalesceWhileDirty() { + var gate = RenderPumpGate() + let first = gate.markDirty() + #expect(first) + // Already armed: a flood of needsLayout/needsDisplay flips during one + // frame must not keep re-resuming the link. + let second = gate.markDirty() + let third = gate.markDirty() + #expect(!second) + #expect(!third) + #expect(gate.isDirty) + } + + @Test func dirtyTickPumpsAndCleanTickPauses() { + var gate = RenderPumpGate() + _ = gate.markDirty() + #expect(gate.tickAction() == .pump) + gate.pumpCompleted() + // Nothing new since the commit: the next tick parks the link. + #expect(gate.tickAction() == .pause) + } + + @Test func commitAbsorbsInvalidationsRaisedDuringThePump() { + var gate = RenderPumpGate() + _ = gate.markDirty() + #expect(gate.tickAction() == .pump) + // Layout inside the pump re-marks the view dirty before the commit; + // that work is flushed by the same CATransaction.flush, so completion + // returns the gate to clean instead of scheduling a redundant pump. + _ = gate.markDirty() + gate.pumpCompleted() + #expect(!gate.isDirty) + #expect(gate.tickAction() == .pause) + } + + @Test func explicitMessagePumpClearsPendingDirtinessWithoutATick() { + var gate = RenderPumpGate() + _ = gate.markDirty() + // A host message (scene/geometry/pointer) pumps synchronously; its + // commit also flushes whatever armed the gate, so the link's next + // tick pauses instead of double-pumping. + gate.pumpCompleted() + #expect(gate.tickAction() == .pause) + } + + @Test func invalidationAfterACommitRearms() { + var gate = RenderPumpGate() + _ = gate.markDirty() + gate.pumpCompleted() + // The clean -> dirty transition must resume the link again. + let mustResume = gate.markDirty() + #expect(mustResume) + #expect(gate.tickAction() == .pump) + } + + @Test func idleGateStaysClean() { + let gate = RenderPumpGate() + #expect(!gate.isDirty) + #expect(gate.tickAction() == .pause) + } +} diff --git a/Packages/CmuxSwiftRender/Sources/CmuxSwiftRender/RecursionBudget.swift b/Packages/CmuxSwiftRender/Sources/CmuxSwiftRender/RecursionBudget.swift index 956af94c15c2..22a134bfb952 100644 --- a/Packages/CmuxSwiftRender/Sources/CmuxSwiftRender/RecursionBudget.swift +++ b/Packages/CmuxSwiftRender/Sources/CmuxSwiftRender/RecursionBudget.swift @@ -1,20 +1,33 @@ -/// A shared recursion-depth counter that bounds interpreter nesting so -/// pathological or malicious authored source degrades to a truncated render -/// instead of overflowing the stack and crashing the host. +/// A shared evaluation budget that bounds interpreter nesting *and* total +/// produced view nodes, so pathological or malicious authored source degrades +/// to a contained failure instead of overflowing the stack or handing SwiftUI +/// a multi-thousand-node tree that freezes the host. /// /// One instance is created at the root ``EvalEnvironment`` and shared with every /// child scope. Recursive evaluation entry points call ``enter()`` on the way /// in (always paired with ``leave()`` via `defer`) and bail when ``exceeded``. +/// Node-producing entry points call ``recordNode()`` and bail when +/// ``nodesExceeded``; the top-level evaluate returns `nil` for a tripped +/// render so the host's last-good-sticky publish keeps the previous output. final class RecursionBudget { private(set) var depth = 0 + private(set) var nodesProduced = 0 private let limit: Int + private let nodeLimit: Int - /// - Parameter limit: Maximum interpreter nesting depth. The default (400) - /// is far beyond any legitimate sidebar yet well under the native stack - /// limit, so deep-but-finite trees still render while infinite recursion - /// is cut off. - init(limit: Int = 400) { + /// - Parameters: + /// - limit: Maximum interpreter nesting depth. The default (400) is far + /// beyond any legitimate sidebar yet well under the native stack + /// limit, so deep-but-finite trees still render while infinite + /// recursion is cut off. + /// - nodeLimit: Maximum total ``RenderNode``s one evaluation may + /// produce. The default (3000) is an order of magnitude above a rich + /// real sidebar (a few hundred nodes) yet small enough that a + /// pathological `ForEach(0..<100_000)` trips in milliseconds instead + /// of building a tree SwiftUI cannot lay out. + init(limit: Int = 400, nodeLimit: Int = 3000) { self.limit = limit + self.nodeLimit = nodeLimit } /// Records entry into one more nesting level. @@ -31,4 +44,16 @@ final class RecursionBudget { var exceeded: Bool { depth > limit } + + /// Records one produced view node. + func recordNode() { + nodesProduced += 1 + } + + /// Whether the evaluation has produced more nodes than the budget allows; + /// node-producing callers should bail when true and the top-level + /// evaluate should discard the truncated result. + var nodesExceeded: Bool { + nodesProduced > nodeLimit + } } diff --git a/Packages/CmuxSwiftRender/Sources/CmuxSwiftRender/SwiftViewInterpreter.swift b/Packages/CmuxSwiftRender/Sources/CmuxSwiftRender/SwiftViewInterpreter.swift index f70a5c66fb78..2c86a471f768 100644 --- a/Packages/CmuxSwiftRender/Sources/CmuxSwiftRender/SwiftViewInterpreter.swift +++ b/Packages/CmuxSwiftRender/Sources/CmuxSwiftRender/SwiftViewInterpreter.swift @@ -65,7 +65,11 @@ public struct SwiftViewInterpreter: Sendable { self.registerFunctions(program.file.statements, env) for item in program.file.statements { if let expr = item.item.as(ExprSyntax.self), let node = self.evalView(expr, env) { - return node + // A tripped node budget means the tree was truncated + // mid-walk; publish nothing so the host's last-good-sticky + // render keeps the previous output instead of flashing a + // partial tree. + return env.budget.nodesExceeded ? nil : node } } return nil @@ -119,7 +123,7 @@ public struct SwiftViewInterpreter: Sendable { private func evalView(_ expr: ExprSyntax, _ env: EvalEnvironment) -> RenderNode? { env.budget.enter() defer { env.budget.leave() } - guard !env.budget.exceeded else { return nil } + guard !env.budget.exceeded, !env.budget.nodesExceeded else { return nil } guard let call = expr.as(FunctionCallExprSyntax.self) else { return nil } return evalCall(call, env) } @@ -305,10 +309,13 @@ public struct SwiftViewInterpreter: Sendable { private func evalItems(_ items: CodeBlockItemListSyntax, _ env: EvalEnvironment) -> [RenderNode] { env.budget.enter() defer { env.budget.leave() } - guard !env.budget.exceeded else { return [] } + guard !env.budget.exceeded, !env.budget.nodesExceeded else { return [] } registerFunctions(items, env) var out: [RenderNode] = [] for item in items { + // Stop producing once the node budget trips; the top-level + // evaluate discards the truncated walk. + if env.budget.nodesExceeded { break } let node = item.item if let decl = node.as(VariableDeclSyntax.self) { applyBinding(decl, env) @@ -325,12 +332,14 @@ public struct SwiftViewInterpreter: Sendable { if let call = expr.as(FunctionCallExprSyntax.self), isForEach(call) { out += evalForEach(call, env) } else if let child = evalView(expr, env) { + env.budget.recordNode() out.append(child) } } else if let expr = node.as(ExprSyntax.self) { if let call = expr.as(FunctionCallExprSyntax.self), isForEach(call) { out += evalForEach(call, env) } else if let child = evalView(expr, env) { + env.budget.recordNode() out.append(child) } } @@ -441,6 +450,10 @@ public struct SwiftViewInterpreter: Sendable { let names = closureParameterNames(closure) var out: [RenderNode] = [] for value in values { + // A pathological sequence (e.g. `ForEach(0..<100_000)`) must trip + // the node budget after a few thousand rows, not iterate to the + // end doing wasted work. + if env.budget.nodesExceeded { break } let scope = env.makeChild() if names.count >= 2 { // Two-param form, e.g. `ForEach(Array(xs.enumerated()), id: \.offset) @@ -554,6 +567,7 @@ public struct SwiftViewInterpreter: Sendable { let values = sequence.iterationValues else { return [] } var out: [RenderNode] = [] for value in values { + if env.budget.nodesExceeded { break } // same early-out as evalForEach let scope = env.makeChild() scope.define(name, value) out += evalItems(loop.body.statements, scope) diff --git a/Packages/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/EvaluationNodeBudgetTests.swift b/Packages/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/EvaluationNodeBudgetTests.swift new file mode 100644 index 000000000000..a4f79cbb03fa --- /dev/null +++ b/Packages/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/EvaluationNodeBudgetTests.swift @@ -0,0 +1,70 @@ +import Testing +@testable import CmuxSwiftRender + +/// Behavior of the evaluation node budget: pathological sources must come back +/// as a contained `nil` (so the host's last-good-sticky publish keeps the +/// previous render) while realistic sidebars render unaffected. +@Suite struct EvaluationNodeBudgetTests { + let interp = SwiftViewInterpreter() + + @Test func pathologicalHugeForEachReturnsNilInsteadOfAHugeTree() { + // 100_000 rows materialize (the range cap admits exactly 100_000) but + // must trip the node budget long before the walk finishes. + let node = interp.evaluate(""" + VStack { + ForEach(0..<100_000) { i in + Text("Row \\(i)") + } + } + """) + #expect(node == nil) + } + + @Test func pathologicalNestedForEachReturnsNil() { + // 500 x 500 = 250_000 nodes via nesting; neither loop alone exceeds + // the range cap, so only the node budget contains this. + let node = interp.evaluate(""" + VStack { + ForEach(0..<500) { i in + HStack { + ForEach(0..<500) { j in + Text("\\(i).\\(j)") + } + } + } + } + """) + #expect(node == nil) + } + + @Test func deepButRealisticListStillRendersCompletely() { + // An order of magnitude above a typical sidebar, still under budget: + // every row must be present (no silent truncation of legal sources). + let node = interp.evaluate(""" + VStack { + ForEach(0..<400) { i in + Text("Row \\(i)") + } + } + """) + #expect(node?.kind == .vstack) + #expect(node?.children.count == 400) + } + + @Test func budgetTripIsFastEnoughToBeAContainmentMechanism() { + // The point of the budget is host responsiveness: tripping must cost + // milliseconds, not iterate 100k rows doing full work. The bound is + // deliberately loose (CI machines vary); the failure mode it guards + // against is multi-second hangs. + let start = ContinuousClock.now + _ = interp.evaluate(""" + VStack { + ForEach(0..<100_000) { i in + Text("Row \\(i)") + } + } + """) + let elapsed = ContinuousClock.now - start + #expect(elapsed < .seconds(5)) + } +} diff --git a/Packages/CmuxSwiftRenderUI/Tests/CmuxSwiftRenderUITests/CustomSidebarLastGoodTests.swift b/Packages/CmuxSwiftRenderUI/Tests/CmuxSwiftRenderUITests/CustomSidebarLastGoodTests.swift new file mode 100644 index 000000000000..8f7833955688 --- /dev/null +++ b/Packages/CmuxSwiftRenderUI/Tests/CmuxSwiftRenderUITests/CustomSidebarLastGoodTests.swift @@ -0,0 +1,45 @@ +import Foundation +import Testing +@testable import CmuxSwiftRenderUI + +/// Containment behavior at the publish layer: when a source's evaluation is +/// rejected (here, by tripping the node budget), the model must keep the last +/// good render instead of flashing empty or publishing a truncated tree. +@Suite("Custom sidebar last-good publish") +@MainActor +struct CustomSidebarLastGoodTests { + private func makeSidebarFile(_ source: String) throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-lastgood-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let fileURL = directory.appendingPathComponent("demo.swift", isDirectory: false) + try source.write(to: fileURL, atomically: true, encoding: .utf8) + return fileURL + } + + @Test func budgetTrippedSourceKeepsThePreviousRender() async throws { + let fileURL = try makeSidebarFile(""" + VStack { + Text("good") + } + """) + let model = CustomSidebarModel(fileURL: fileURL) + model.reload() + await model.renderSwift(dataContext: [:]) + #expect(model.swiftRender?.children.first?.text == "good") + + // Author saves a pathological edit: 100k rows trips the node budget, + // the render comes back nil, and the previous output must stay up. + try """ + VStack { + ForEach(0..<100_000) { i in + Text("Row \\(i)") + } + } + """.write(to: fileURL, atomically: true, encoding: .utf8) + model.reload() + await model.renderSwift(dataContext: [:]) + #expect(model.swiftRender?.children.first?.text == "good") + #expect(model.hasRenderedSwift) + } +} diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index d65fe6d6d087..d40bd79ad3b0 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -104008,6 +104008,193 @@ } } }, + "customSidebarRenderer.inProcess.description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Renders as native SwiftUI inside cmux with hover, focus, and typing. A faulty sidebar shares the app process." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux 内でネイティブ SwiftUI として描画し、ホバー・フォーカス・入力に対応します。サイドバーに問題があるとアプリプロセスに影響します。" + } + } + } + }, + "customSidebarRenderer.inProcess.name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "In-app (full input)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリ内(フル入力)" + } + } + } + }, + "customSidebarRenderer.remote.description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Renders in a crash-isolated helper process. Clicks only: no hover, focus, or typing." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クラッシュから隔離されたヘルパープロセスで描画します。クリックのみ対応で、ホバー・フォーカス・入力はできません。" + } + } + } + }, + "customSidebarRenderer.remote.name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Isolated process" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "分離プロセス" + } + } + } + }, + "settings.customSidebars.enabled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Custom Sidebars" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタムサイドバーを表示" + } + } + } + }, + "settings.customSidebars.enabled.subtitleOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hides custom sidebars from the sidebar picker until you enable them here." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ここで有効にするまで、カスタムサイドバーをサイドバーピッカーに表示しません。" + } + } + } + }, + "settings.customSidebars.enabled.subtitleOn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Lists your sidebars from ~/.config/cmux/sidebars in the sidebar picker." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "~/.config/cmux/sidebars のサイドバーをサイドバーピッカーに表示します。" + } + } + } + }, + "settings.customSidebars.note": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom sidebars are SwiftUI-style files in ~/.config/cmux/sidebars. Pick one from the sidebar toggle button's right-click menu; edits hot-reload on save. Use the in-app renderer only for sidebars you trust." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタムサイドバーは ~/.config/cmux/sidebars にある SwiftUI 形式のファイルです。サイドバー切り替えボタンの右クリックメニューから選択でき、保存すると即時に再読み込みされます。アプリ内レンダラーは信頼できるサイドバーのみに使用してください。" + } + } + } + }, + "settings.customSidebars.renderer": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Renderer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "レンダラー" + } + } + } + }, + "settings.search.alias.section.customSidebars": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "custom sidebars vibe code swift json interpreted renderer in-process remote worker isolated" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタムサイドバー バイブコード swift json インタープリタ レンダラー アプリ内 分離プロセス" + } + } + } + }, + "settings.section.customSidebars": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom Sidebars" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタムサイドバー" + } + } + } + }, "settings.betaFeatures.customSidebars": { "extractionState": "manual", "localizations": { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 693a1571ff76..aa68882993c8 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -10666,6 +10666,7 @@ struct VerticalTabsSidebar: View { private var selectedExtensionSidebarProviderId = CmuxExtensionSidebarSelection.defaultProviderId @LiveSetting(\.betaFeatures.extensions) private var extensionsExperimentalEnabled @LiveSetting(\.betaFeatures.customSidebars) private var customSidebarsExperimentalEnabled + @LiveSetting(\.customSidebars.renderer) private var customSidebarRenderer // The provider to actually render. Built-in views are always honored; only // the hosted-extension selection falls back to the default workspaces @@ -11411,18 +11412,25 @@ struct VerticalTabsSidebar: View { // Periodic tick so the custom sidebar re-renders live (clock, // countdowns, and refreshed workspace/data context), mirroring the // default sidebar's TimelineView. No banned timers involved. - // Fully out-of-process: the render worker interprets AND renders - // the file; this view only hosts the worker's remote layer and - // forwards input, so no file-derived view code runs in the host. + // The surface mounts the in-process renderer by default (native + // hover/focus/keyboard, same-frame resize); the + // `customSidebars.renderer` setting switches it to the + // out-of-process worker for untrusted sources (no file-derived + // view code runs in the host). The @LiveSetting's initial value + // lags one store round-trip on remount, so a non-default choice + // can mount the other renderer for one tick before flipping; + // harmless (the host shuts the short-lived client down on + // unmount). TimelineView(.periodic(from: .now, by: 1)) { timeline in - // No .id(customSidebarURL): the worker swaps files in place on - // the next scene message, so remounting the surface would only - // flash the previous sidebar's pixels during the switch. - RemoteCustomSidebarHost( + CustomSidebarSurface( fileURL: customSidebarURL, dataContext: customSidebarDataContext(now: timeline.date), dispatch: makeCmuxSidebarActionDispatch(), - contentInsets: CustomSidebarContentInsets(top: SidebarWorkspaceScrollInsets.workspaceList.top, bottom: SidebarWorkspaceScrollInsets.workspaceList.bottom), + contentInsets: CustomSidebarContentInsets( + top: SidebarWorkspaceScrollInsets.workspaceList.top, + bottom: SidebarWorkspaceScrollInsets.workspaceList.bottom + ), + rendersInProcess: customSidebarRenderer == .inProcess, client: $sidebarRenderWorkerClient ) } diff --git a/Sources/SettingsNavigation.swift b/Sources/SettingsNavigation.swift index 97fcf516bedf..f935a1bde8aa 100644 --- a/Sources/SettingsNavigation.swift +++ b/Sources/SettingsNavigation.swift @@ -7,6 +7,7 @@ enum SettingsNavigationTarget: String, CaseIterable, Identifiable { case textBox case mobile case sidebarAppearance + case customSidebars case betaFeatures case automation case browser @@ -35,6 +36,8 @@ enum SettingsNavigationTarget: String, CaseIterable, Identifiable { return String(localized: "settings.section.workspaceColors", defaultValue: "Workspace Colors") case .sidebarAppearance: return String(localized: "settings.section.sidebarAppearance", defaultValue: "Sidebar") + case .customSidebars: + return String(localized: "settings.section.customSidebars", defaultValue: "Custom Sidebars") case .betaFeatures: return String(localized: "settings.section.betaFeatures", defaultValue: "Beta Features") case .automation: @@ -70,6 +73,8 @@ enum SettingsNavigationTarget: String, CaseIterable, Identifiable { return "paintpalette" case .sidebarAppearance: return "sidebar.left" + case .customSidebars: + return "sidebar.squares.left" case .betaFeatures: return "exclamationmark.triangle" case .automation: @@ -105,6 +110,8 @@ enum SettingsNavigationTarget: String, CaseIterable, Identifiable { return "\(title) palette tabs" case .sidebarAppearance: return "\(title) sidebar details branches badges material terminal background" + case .customSidebars: + return "\(title) custom sidebars vibe swift json interpreted renderer in-process remote worker isolated" case .betaFeatures: return "\(title) beta experimental unstable feed dock right sidebar" case .automation: @@ -379,6 +386,8 @@ enum SettingsSearchIndex { setting(.sidebarAppearance, "show-log", String(localized: "settings.app.showLog", defaultValue: "Show Latest Log in Sidebar"), "status message"), setting(.sidebarAppearance, "show-progress", String(localized: "settings.app.showProgress", defaultValue: "Show Progress in Sidebar"), "progress bar"), setting(.sidebarAppearance, "show-metadata", String(localized: "settings.app.showMetadata", defaultValue: "Show Custom Metadata in Sidebar"), "report meta status block"), + setting(.customSidebars, "enabled", String(localized: "settings.customSidebars.enabled", defaultValue: "Show Custom Sidebars"), "custom sidebars enable show vibe swift json interpreted picker"), + setting(.customSidebars, "renderer", String(localized: "settings.customSidebars.renderer", defaultValue: "Renderer"), "renderer in-process in app remote worker isolated process hover focus typing input"), setting(.betaFeatures, "feed", String(localized: "settings.betaFeatures.feed", defaultValue: "Feed"), "feed right sidebar agent decisions permissions questions"), setting(.betaFeatures, "dock", String(localized: "settings.betaFeatures.dock", defaultValue: "Dock"), "dock right sidebar terminal controls tui"), setting(.automation, "socket-mode", String(localized: "settings.automation.socketMode", defaultValue: "Socket Control Mode"), "unix socket api access password auth"), @@ -502,6 +511,7 @@ enum SettingsSearchIndex { "workspaceColors.selectionColor": settingID(for: .workspaceColors, idSuffix: "selection"), "workspaceColors.notificationBadgeColor": settingID(for: .workspaceColors, idSuffix: "badge"), "sidebarAppearance.matchTerminalBackground": settingID(for: .sidebarAppearance, idSuffix: "match-terminal"), + "customSidebars.renderer": settingID(for: .customSidebars, idSuffix: "renderer"), "automation.socketControlMode": settingID(for: .automation, idSuffix: "socket-mode"), "automation.socketPassword": settingID(for: .automation, idSuffix: "socket-password"), "automation.claudeCodeIntegration": settingID(for: .automation, idSuffix: "claude-code"), diff --git a/Sources/SettingsSearchAliases.swift b/Sources/SettingsSearchAliases.swift index 6022aba820f9..28a812bc7919 100644 --- a/Sources/SettingsSearchAliases.swift +++ b/Sources/SettingsSearchAliases.swift @@ -13,6 +13,8 @@ enum SettingsSearchAliasIndex { return localized("settings.search.alias.section.mobile", defaultValue: "ios iphone ipad mobile pairing local network permission sync") case .sidebarAppearance: return localized("settings.search.alias.section.sidebarAppearance", defaultValue: "sidebar left rail navigation details branches badges material terminal background") + case .customSidebars: + return localized("settings.search.alias.section.customSidebars", defaultValue: "custom sidebars vibe code swift json interpreted renderer in-process remote worker isolated") case .betaFeatures: return localized("settings.search.alias.section.betaFeatures", defaultValue: "beta experimental unstable preview feed dock right sidebar") case .automation: diff --git a/docs/custom-sidebars.md b/docs/custom-sidebars.md index 133b1958ee13..673e6edc9eac 100644 --- a/docs/custom-sidebars.md +++ b/docs/custom-sidebars.md @@ -6,7 +6,7 @@ native SwiftUI in the real sidebar, hot-reloads on save, binds to live cmux state, and can run cmux commands on tap. This guide is the authoring contract for you or a coding agent. -It is an opt-in beta: turn on **Settings → Beta features → Custom sidebars** +It is a beta, on by default. Turn it off in **Settings → Custom Sidebars** (`customSidebars.beta.enabled`). While off, custom sidebars do not appear. ## If you are an agent building this for someone @@ -49,6 +49,28 @@ hot-reloads. If both `.swift` and `.json` exist, `.swift` wins. A sidebar file is a single SwiftUI-style view expression (no `struct`, no `var body` wrapper, just the view). +## Choosing the renderer (in-process vs remote) + +By default a custom sidebar renders in-process: the interpreted view mounts +as real SwiftUI inside the cmux window, so hover styling, focus, keyboard, +and same-frame resize all work natively. The tradeoff is that the +interpreter shares the host process. + +For sidebars from sources you do not fully trust you can switch to the +remote renderer, an out-of-process worker. That is the containment lane: a +crash or hang caused by the interpreted file cannot take down cmux, but +input is limited to forwarded clicks (no hover, focus, or keyboard). + +Set it in **Settings → Custom Sidebars**, or in `~/.config/cmux/cmux.json`: + + { "customSidebars": { "renderer": "remote" } } + +Valid values are `"inProcess"` (default) and `"remote"`. The setting is read +live; flipping it re-renders the selected sidebar without a restart. Both +renderers protect the host against pathological sources with an evaluation +budget (nesting depth and total produced nodes): a render that exceeds the +budget is discarded and the last good render stays up. + ## Downloadable examples The repo includes ready-to-copy sidebars in `Examples/CustomSidebars/`: diff --git a/ghostty b/ghostty index e5c962a72795..34cbf180d891 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit e5c962a72795088b9f6a478236a421fe00b0950e +Subproject commit 34cbf180d8917b802d61d9929cfb493594f2ab52