diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 17308aed..6ddd7437 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -8035,10 +8035,12 @@ struct CMUXCLI { private func printWelcome() { let reset = "\u{001B}[0m" let bold = "\u{001B}[1m" - let dim = "\u{001B}[2m" func trueColor(_ red: Int, _ green: Int, _ blue: Int) -> String { "\u{001B}[38;2;\(red);\(green);\(blue)m" } + + let isDark = UserDefaults.standard.string(forKey: "AppleInterfaceStyle") == "Dark" + let c1 = trueColor(0, 212, 255) let c2 = trueColor(24, 181, 250) let c3 = trueColor(48, 150, 245) @@ -8046,7 +8048,17 @@ struct CMUXCLI { let c5 = trueColor(96, 88, 239) let c6 = trueColor(110, 73, 238) let c7 = trueColor(124, 58, 237) - let tagline = trueColor(130, 130, 140) + + let tagline: String + let subdued: String + + if isDark { + tagline = trueColor(130, 130, 140) + subdued = "\u{001B}[2m" + } else { + tagline = trueColor(90, 90, 98) + subdued = trueColor(100, 100, 108) + } let logo = """ \(c1) ::\(reset) @@ -8061,14 +8073,14 @@ struct CMUXCLI { let shortcuts = """ \(bold)Shortcuts\(reset) - \(bold)\u{2318}N\(reset)\(dim) New workspace\(reset) - \(bold)\u{2318}P\(reset)\(dim) Go to workspace\(reset) - \(bold)\u{2318}D\(reset)\(dim) Split right\(reset) - \(bold)\u{2318}\u{21E7}D\(reset)\(dim) Split down\(reset) - \(bold)\u{2318}\u{21E7}P\(reset)\(dim) Command palette\(reset) - \(bold)\u{2318}\u{21E7}R\(reset)\(dim) Rename workspace\(reset) - \(bold)\u{2318}\u{21E7}L\(reset)\(dim) New browser\(reset) - \(bold)\u{2318}\u{21E7}U\(reset)\(dim) Jump to latest unread\(reset) + \(bold)\u{2318}N\(reset)\(subdued) New workspace\(reset) + \(bold)\u{2318}P\(reset)\(subdued) Go to workspace\(reset) + \(bold)\u{2318}D\(reset)\(subdued) Split right\(reset) + \(bold)\u{2318}\u{21E7}D\(reset)\(subdued) Split down\(reset) + \(bold)\u{2318}\u{21E7}P\(reset)\(subdued) Command palette\(reset) + \(bold)\u{2318}\u{21E7}R\(reset)\(subdued) Rename workspace\(reset) + \(bold)\u{2318}\u{21E7}L\(reset)\(subdued) New browser\(reset) + \(bold)\u{2318}\u{21E7}U\(reset)\(subdued) Jump to latest unread\(reset) """ print() @@ -8076,14 +8088,14 @@ struct CMUXCLI { print() print(shortcuts) print() - print(" \(bold)Docs\(reset)\(dim) https://cmux.dev/docs\(reset)") - print(" \(bold)Discord\(reset)\(dim) https://discord.gg/xsgFEVrWCZ\(reset)") - print(" \(bold)GitHub\(reset)\(dim) https://github.com/manaflow-ai/cmux (please leave a star ⭐)\(reset)") - print(" \(bold)Email\(reset)\(dim) founders@manaflow.com\(reset)") + print(" \(bold)Docs\(reset)\(subdued) https://cmux.dev/docs\(reset)") + print(" \(bold)Discord\(reset)\(subdued) https://discord.gg/xsgFEVrWCZ\(reset)") + print(" \(bold)GitHub\(reset)\(subdued) https://github.com/manaflow-ai/cmux (please leave a star ⭐)\(reset)") + print(" \(bold)Email\(reset)\(subdued) founders@manaflow.com\(reset)") print() - print(" \(dim)Run \(reset)\(bold)cmux --help\(reset)\(dim) for all commands.\(reset)") - print(" \(dim)Run \(reset)\(bold)cmux shortcuts\(reset)\(dim) to edit shortcuts.\(reset)") - print(" \(dim)Run \(reset)\(bold)cmux feedback\(reset)\(dim) to report a bug.\(reset)") + print(" \(subdued)Run \(reset)\(bold)cmux --help\(reset)\(subdued) for all commands.\(reset)") + print(" \(subdued)Run \(reset)\(bold)cmux shortcuts\(reset)\(subdued) to edit shortcuts.\(reset)") + print(" \(subdued)Run \(reset)\(bold)cmux feedback\(reset)\(subdued) to report a bug.\(reset)") print() } diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index ac1048cf..f102cf9a 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -2259,6 +2259,71 @@ final class WindowBrowserPortal: NSObject { frame.maxY > bounds.maxY + epsilon } + private static func hasVisibleInspectorDescendant(in root: NSView) -> Bool { + var stack: [NSView] = [root] + while let current = stack.popLast() { + if current !== root { + let className = String(describing: type(of: current)) + if className.contains("WKInspector"), + !current.isHidden, + current.alphaValue > 0, + current.frame.width > 1, + current.frame.height > 1 { + return true + } + } + stack.append(contentsOf: current.subviews) + } + return false + } + + private static func inferredBottomDockedInspectorFrame( + in containerView: NSView, + primaryWebView: WKWebView, + epsilon: CGFloat = 1 + ) -> NSRect? { + let pageFrame = primaryWebView.frame + let containerBounds = containerView.bounds + + let candidates = containerView.subviews.compactMap { candidate -> NSRect? in + guard candidate !== primaryWebView else { return nil } + guard hasVisibleInspectorDescendant(in: candidate) else { return nil } + + let frame = candidate.frame + guard frame.width > 1, frame.height > 1 else { return nil } + let overlapWidth = min(pageFrame.maxX, frame.maxX) - max(pageFrame.minX, frame.minX) + guard overlapWidth > min(pageFrame.width, frame.width) * 0.7 else { return nil } + guard frame.minY <= containerBounds.minY + epsilon else { return nil } + guard frame.maxY <= pageFrame.minY + epsilon else { return nil } + return frame + } + + return candidates.max(by: { $0.height < $1.height }) + } + + private static func repairedBottomDockedPageFrame( + in containerView: NSView, + primaryWebView: WKWebView, + epsilon: CGFloat = 0.5 + ) -> NSRect? { + let pageFrame = primaryWebView.frame + let containerBounds = containerView.bounds + guard frameExtendsOutsideBounds(pageFrame, bounds: containerBounds, epsilon: epsilon), + let inspectorFrame = inferredBottomDockedInspectorFrame( + in: containerView, + primaryWebView: primaryWebView + ) else { + return nil + } + + return NSRect( + x: containerBounds.minX, + y: inspectorFrame.maxY, + width: containerBounds.width, + height: max(0, containerBounds.maxY - inspectorFrame.maxY) + ) + } + #if DEBUG private static func inspectorSubviewCount(in root: NSView) -> Int { var stack: [NSView] = [root] @@ -3111,7 +3176,30 @@ final class WindowBrowserPortal: NSObject { #if DEBUG let inspectorSubviews = Self.inspectorSubviewCount(in: containerView) #endif - if containerOwnsWebView && Self.frameExtendsOutsideBounds(preNormalizeWebFrame, bounds: containerBounds) { + if containerOwnsWebView, + let repairedBottomDockFrame = Self.repairedBottomDockedPageFrame( + in: containerView, + primaryWebView: webView + ) { + let oldWebFrame = preNormalizeWebFrame + CATransaction.begin() + CATransaction.setDisableActions(true) + webView.frame = repairedBottomDockFrame + CATransaction.commit() +#if DEBUG + dlog( + "browser.portal.webframe.bottomDockRepair web=\(browserPortalDebugToken(webView)) " + + "container=\(browserPortalDebugToken(containerView)) old=\(browserPortalDebugFrame(oldWebFrame)) " + + "new=\(browserPortalDebugFrame(repairedBottomDockFrame)) bounds=\(browserPortalDebugFrame(containerBounds)) " + + "inspectorHApprox=\(String(format: "%.1f", inspectorHeightApprox)) " + + "inspectorInsets=\(String(format: "%.1f", inspectorHeightFromInsets)) " + + "inspectorOverflow=\(String(format: "%.1f", inspectorHeightFromOverflow)) " + + "inspectorSubviews=\(inspectorSubviews) " + + "source=\(source)" + ) +#endif + refreshReasons.append("webFrameBottomDock") + } else if containerOwnsWebView && Self.frameExtendsOutsideBounds(preNormalizeWebFrame, bounds: containerBounds) { let oldWebFrame = preNormalizeWebFrame CATransaction.begin() CATransaction.setDisableActions(true) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index d11a67cf..e029499b 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -4992,6 +4992,8 @@ struct WebViewRepresentable: NSViewRepresentable { return true } guard !relatedSubviews.isEmpty else { return } + let preserveSlotLocalFrames = sourceSuperview is WindowBrowserSlotView + let sourceSlotBoundsSize = sourceSuperview.bounds.size #if DEBUG dlog( "browser.localHost.reparent.batch reason=\(reason) source=\(Self.objectID(sourceSuperview)) " + @@ -5000,11 +5002,17 @@ struct WebViewRepresentable: NSViewRepresentable { ) #endif for view in relatedSubviews { - let frameInWindow = sourceSuperview.convert(view.frame, to: nil) let className = String(describing: type(of: view)) + let targetFrame: NSRect + if preserveSlotLocalFrames { + targetFrame = view.frame + } else { + let frameInWindow = sourceSuperview.convert(view.frame, to: nil) + targetFrame = container.convert(frameInWindow, from: nil) + } view.removeFromSuperview() container.addSubview(view, positioned: .above, relativeTo: nil) - view.frame = container.convert(frameInWindow, from: nil) + view.frame = targetFrame #if DEBUG dlog( "browser.localHost.reparent.batch.item reason=\(reason) class=\(className) " + @@ -5012,6 +5020,11 @@ struct WebViewRepresentable: NSViewRepresentable { ) #endif } + if preserveSlotLocalFrames, sourceSlotBoundsSize != container.bounds.size { + container.resizeSubviews(withOldSize: sourceSlotBoundsSize) + container.needsLayout = true + container.layoutSubtreeIfNeeded() + } } private static func installPortalAnchorView(_ anchorView: NSView, in host: NSView) { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 08d6a67c..982cc6a5 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -2565,6 +2565,18 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { return nil } + private func findWindowBrowserSlotView(in root: NSView) -> WindowBrowserSlotView? { + if let slot = root as? WindowBrowserSlotView { + return slot + } + for subview in root.subviews { + if let slot = findWindowBrowserSlotView(in: subview) { + return slot + } + } + return nil + } + func testRestoreReopensInspectorAfterAttachWhenPreferredVisible() { let (panel, inspector) = makePanelWithInspector() @@ -2858,6 +2870,106 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { "An off-window replacement host should leave DevTools companion views in the visible local host" ) } + + func testVisibleReplacementLocalHostNormalizesBottomDockedInspectorFrames() { + let (panel, _) = makePanelWithInspector() + XCTAssertTrue(panel.showDeveloperTools()) + + let paneId = PaneID(id: UUID()) + let representable = WebViewRepresentable( + panel: panel, + paneId: paneId, + shouldAttachWebView: false, + useLocalInlineHosting: true, + shouldFocusWebView: false, + isPanelFocused: true, + portalZPriority: 0, + paneDropZone: nil, + searchOverlay: nil, + paneTopChromeHeight: 0 + ) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let narrowHosting = NSHostingView(rootView: representable) + narrowHosting.frame = NSRect(x: 180, y: 0, width: 180, height: 240) + contentView.addSubview(narrowHosting) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + narrowHosting.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + guard let initialSlot = panel.webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected initial local inline slot") + return + } + + let inspectorView = WKInspectorProbeView( + frame: NSRect(x: 0, y: 0, width: initialSlot.bounds.width, height: 72) + ) + inspectorView.autoresizingMask = [.width] + initialSlot.addSubview(inspectorView) + panel.webView.frame = NSRect( + x: 0, + y: inspectorView.frame.maxY, + width: initialSlot.bounds.width, + height: initialSlot.bounds.height - inspectorView.frame.height + ) + initialSlot.layoutSubtreeIfNeeded() + + let replacementHosting = NSHostingView(rootView: representable) + replacementHosting.frame = contentView.bounds + replacementHosting.autoresizingMask = [.width, .height] + contentView.addSubview(replacementHosting, positioned: .above, relativeTo: narrowHosting) + contentView.layoutSubtreeIfNeeded() + replacementHosting.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + replacementHosting.rootView = representable + contentView.layoutSubtreeIfNeeded() + replacementHosting.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + narrowHosting.removeFromSuperview() + contentView.layoutSubtreeIfNeeded() + replacementHosting.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + guard let replacementHost = findHostContainerView(in: replacementHosting), + let replacementSlot = findWindowBrowserSlotView(in: replacementHost) else { + XCTFail("Expected replacement local inline host") + return + } + + XCTAssertTrue( + panel.webView.superview === replacementSlot, + "A visible replacement local host should take over the hosted page" + ) + XCTAssertTrue( + inspectorView.superview === replacementSlot, + "A visible replacement local host should move the DevTools companion views with the page" + ) + XCTAssertEqual(inspectorView.frame.minX, 0, accuracy: 0.5) + XCTAssertEqual(inspectorView.frame.minY, 0, accuracy: 0.5) + XCTAssertEqual(inspectorView.frame.width, replacementSlot.bounds.width, accuracy: 0.5) + XCTAssertEqual(inspectorView.frame.height, 72, accuracy: 0.5) + XCTAssertEqual(panel.webView.frame.minX, 0, accuracy: 0.5) + XCTAssertEqual(panel.webView.frame.minY, 72, accuracy: 0.5) + XCTAssertEqual(panel.webView.frame.width, replacementSlot.bounds.width, accuracy: 0.5) + XCTAssertEqual(panel.webView.frame.height, replacementSlot.bounds.height - 72, accuracy: 0.5) + } } final class WorkspaceShortcutMapperTests: XCTestCase { @@ -11618,6 +11730,76 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase { ) } + func testPortalSyncRepairsBottomDockedInspectorOverflowedPageFrame() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 260, height: 180)) + contentView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + contentView.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor) + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + + let inspectorHeight: CGFloat = 84 + let inspectorContainer = NSView( + frame: NSRect(x: 0, y: 0, width: slot.bounds.width, height: inspectorHeight) + ) + inspectorContainer.autoresizingMask = [.width] + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + slot.addSubview(inspectorContainer) + + webView.frame = NSRect( + x: 0, + y: inspectorHeight, + width: slot.bounds.width, + height: slot.bounds.height + ) + webView.autoresizingMask = [.width, .height] + slot.layoutSubtreeIfNeeded() + + portal.synchronizeWebViewForAnchor(anchor) + + XCTAssertFalse(slot.isHidden, "Portal sync should keep the hosted browser visible") + XCTAssertEqual( + webView.frame.minY, + inspectorHeight, + accuracy: 0.5, + "Portal sync should keep the page viewport below a bottom-docked inspector instead of shifting the page upward" + ) + XCTAssertEqual( + webView.frame.height, + slot.bounds.height - inspectorHeight, + accuracy: 0.5, + "Portal sync should shrink the page viewport to the space above a bottom-docked inspector" + ) + XCTAssertEqual( + webView.frame.maxY, + slot.bounds.maxY, + accuracy: 0.5, + "The repaired page viewport should stay flush with the top edge of the slot" + ) + } + func testHidingBrowserSlotYieldsOwnedInspectorFirstResponder() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 520, height: 320), @@ -11627,7 +11809,6 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase { ) defer { window.orderOut(nil) } realizeWindowLayout(window) - guard let contentView = window.contentView else { XCTFail("Expected content view") return @@ -11652,10 +11833,16 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase { slot.isHidden = true - XCTAssertNil( - window.firstResponder, + XCTAssertFalse( + window.firstResponder === inspectorView, "Hiding a browser slot should yield any owned inspector responder before it goes off-screen" ) + if let firstResponderView = window.firstResponder as? NSView { + XCTAssertFalse( + firstResponderView === slot || firstResponderView.isDescendant(of: slot), + "Hiding a browser slot should not leave first responder inside the hidden slot" + ) + } } func testHiddenPortalSyncDoesNotStealLocallyHostedDevToolsWebViewDuringResize() {