Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue-1208-browser-pane-stale-content
This commit is contained in:
commit
1dbd1e5011
4 changed files with 323 additions and 23 deletions
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue